Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e7992f4
feat(reader): edit.move, an object from one page to another in one edit
sawradip Nov 1, 2026
993a766
feat(reader): drag an object onto another page, or send it with Move …
sawradip Nov 1, 2026
a2358db
test(reader): moving an object to another page with real mouse and to…
sawradip Nov 1, 2026
f28d141
fix(reader): the selection follows a moved object along its moves, ne…
sawradip Nov 1, 2026
dfcb9fa
fix(reader): a followed selection becomes the selection; a drag ends …
sawradip Nov 1, 2026
604e058
fix(reader): follow a moved selection by searching all its moves, not…
sawradip Nov 1, 2026
6755b66
fix(reader): a followed selection must find the very object that was …
sawradip Nov 1, 2026
9ecb70e
fix(reader): follow only the moves of the object the selection last m…
sawradip Nov 1, 2026
8eabcd3
fix(reader): follow one object's line of moves, checking each place a…
sawradip Nov 1, 2026
5f8c0f7
fix(reader): each move checks its own end, even a place another move …
sawradip Nov 1, 2026
d0580e2
fix(reader): undo or redo of a move shows the object on its page
sawradip Nov 1, 2026
c08358e
fix(reader): the window hears a drag's release, even when its page ha…
sawradip Nov 1, 2026
87a3ad2
feat(model): question manifests: provenance, the exam papers a questi…
sawradip Nov 1, 2026
e2c13ed
docs(spec): 1.7 §2.1 states the limits the schema checks: kind patter…
sawradip Nov 1, 2026
f11f642
Merge pull request #48 from runagent-dev/feat/question-provenance
sawradip Nov 1, 2026
fd27053
Merge pull request #43 from runagent-dev/feat/move-object-across-pages
sawradip Nov 2, 2026
4f1e36a
chore(release): 0.1.5
sawradip Nov 2, 2026
b164038
Merge pull request #49 from runagent-dev/chore/release-0.1.5
sawradip Nov 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ npx tsx tools/reader-library-auth-e2e.ts [url] [--port 4923] # signing in to a
npx tsx tools/reader-media-recover-e2e.ts <library> <token> <disk-dir> # media adopted at two paths, one deleted while the book is open: the reader recovers (a disk test library, ADOPT_PREFIXES=books/, built showcase)
npx tsx tools/question-current-e2e.ts <library> <token> [--read-token t] # a corrected question reaches readers with no new links item (test library: writes)
node tools/reader-insert-menu-e2e.mjs [url] # author mode's right-click menu: what is added lands where you clicked
npx tsx tools/reader-move-page-e2e.mjs [url] [shots] # author mode: an object dragged (mouse, finger) or sent (Move to page, Alt+Page Up/Down) to another page; undo, patch save
node tools/reader-save-e2e.mjs [url] # author mode's Save: the filled button, Ctrl+S, onDirtyChange, the ref's save()
node tools/reader-window-e2e.mjs [url] [shots] # the floating window (open: window): author UI, the window, side panel, sheet, host chrome
node tools/reader-shots.mjs out/shots [url] [--steps steps.json] # desktop + phone screenshots
Expand Down
5 changes: 4 additions & 1 deletion docs/reader-internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ How `@chalk-format/reader` is built, for people changing it. For using it, see i
- `state.ts`: `useAuthoring` edits the book itself (not an overlay): edits, undo and redo, save. For a streamed
book it loads every page first.
- `AuthorRail.tsx`: the left rail (select, pen, highlighter, eraser, text, shapes, add media) with keyboard
shortcuts. `AuthorSurface.tsx`: selection, move and resize handles, drawing.
shortcuts. `AuthorSurface.tsx`: selection, move and resize handles, drawing. A drag is shared by every page's surface
(`Drag`, held by `ChalkReader`): when the pointer carries an object onto another page, `edit.move` moves it there and the pointer
capture goes to that page's surface. `move.ts`: where a moved object goes (`placeAt` for a drag, `relocate` for Move to page,
`freeIdOn` when the target page already uses its id).
- `Sidebar.tsx`: the right sidebar holds every setting, by context: the selection, else the media being added,
else the active tool, else the page (with the book's libraries and the objects on the page).
`Inspector.tsx` and `controls.tsx` are its fields.
Expand Down
5 changes: 5 additions & 0 deletions examples/questions/free-fall.question.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,10 @@
],
"marks": 10, "difficulty": 3,
"subject": "physics", "classes": ["9", "10"], "tags": ["motion", "free fall"],
"provenance": [
{ "kind": "board_exam", "code": "DHA", "name": { "bn": "ঢাকা বোর্ড", "en": "Dhaka Board" }, "year": 2023 },
{ "kind": "board_exam", "code": "RAJ", "name": { "bn": "রাজশাহী বোর্ড", "en": "Rajshahi Board" }, "year": 2019 },
{ "kind": "institution_exam", "code": "VNSC", "name": "ভিকারুননিসা নূন স্কুল এন্ড কলেজ, ঢাকা", "year": null }
],
"license": "CC-BY-4.0", "authors": ["PathshalaX"]
}
27 changes: 27 additions & 0 deletions packages/model/src/question-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,33 @@ describe('question manifests (spec 1.7)', () => {
expect(questionStem(base({ stem: { en: 'Hello' } }), 'bn')).toBe('Hello');
});

describe('provenance (§2.1)', () => {
const board = { kind: 'board_exam', code: 'DHA', name: 'ঢাকা বোর্ড', year: 2023 };
const school = { kind: 'institution_exam', code: 'VNSC', name: { bn: 'ভিকারুননিসা নূন স্কুল এন্ড কলেজ, ঢাকা', en: 'Viqarunnisa Noon School & College, Dhaka' }, year: null };
it('accepts board and school papers, a year or null, a name in one language or many', () => {
const m = base({ provenance: [board, school] });
expect(m.provenance).toEqual([board, school]);
expect(checkQuestion(m)).toEqual([]);
expect(questionManifestSchema.safeParse({ ...m, provenance: [{ kind: 'board_exam', code: 'RAJ', name: 'রাজশাহী বোর্ড' }] }).success).toBe(true); // no year: not known
});
it('accepts a kind it does not know (readers show its code)', () => {
expect(questionManifestSchema.safeParse({ ...base(), provenance: [{ ...board, kind: 'olympiad' }] }).success).toBe(true);
});
it('refuses a code that is not 2 to 8 capitals and digits, a missing name, a year that is not a whole number', () => {
for (const bad of [{ code: 'D' }, { code: 'dha' }, { code: 'TOOLONGXX' }, { code: 'ঢা' }, { code: 'DH-A' }, { name: undefined }, { year: 2023.5 }, { year: '2023' }, { kind: 'Board' }]) {
expect(questionManifestSchema.safeParse({ ...base(), provenance: [{ ...board, ...bad }] }).success, JSON.stringify(bad)).toBe(false);
}
});
it('keeps fields of an entry it does not know', () => {
const m = base({ provenance: [{ ...board, paper: '1st' }] });
expect((m.provenance![0] as Record<string, unknown>).paper).toBe('1st');
});
it('says when the same paper is listed twice', () => {
expect(checkQuestion(base({ provenance: [board, board] }))).toEqual(['provenance board_exam DHA 2023: the same paper twice']);
expect(checkQuestion(base({ provenance: [board, { ...board, year: 2022 }] }))).toEqual([]);
});
});

describe('checkQuestion', () => {
it('a multiple-choice question has two or more options and at most one marked correct', () => {
expect(checkQuestion(mcq([1]))).toEqual([]);
Expand Down
28 changes: 27 additions & 1 deletion packages/model/src/question-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ export const questionPartSchema = z.looseObject({
answer: questionAnswerSchema.optional(),
});

/** Where the question was printed, for people (and so that the same question is not published twice). */
/**
* Where the question was printed, for people (and so that the same question is not published twice). A publisher that must not say which
* book it took a question from leaves it out (spec 1.7 §2.1).
*/
export const questionSourceSchema = z.looseObject({
book: z.string().max(300).optional(),
edition: z.string().max(100).optional(),
Expand All @@ -51,6 +54,25 @@ export const questionSourceSchema = z.looseObject({
page: z.number().int().positive().optional(),
});

/** The kinds of paper this version names in `provenance`. Others may follow: readers show the code of a kind they do not know. */
export const PROVENANCE_KINDS = ['board_exam', 'institution_exam'] as const;

/**
* One exam paper the question was set in (spec 1.7 §2.1): a board's exam (`board_exam`) or a school's or college's test paper
* (`institution_exam`), with a short code to show as a tag and the full name to show on hover. A paper, not the book the question was
* copied from: that is `source`, which a publisher may leave out.
*/
export const questionProvenanceSchema = z.looseObject({
/** `board_exam`, `institution_exam`, or a kind a later version adds. */
kind: z.string().regex(/^[a-z][a-z0-9_]{0,39}$/),
/** The tag: 2 to 8 capital letters and digits, such as `DHA` or `VNSC`. */
code: z.string().regex(/^[A-Z0-9]{2,8}$/),
/** The full name, for people: a string or one per language. */
name: localizedTextSchema,
/** The year of the paper, or null when it is not known. */
year: z.number().int().min(1900).max(2200).nullable().optional(),
});

export const questionManifestSchema = z.looseObject({
format: z.literal('chalk.question'),
manifestVersion: z.literal(1),
Expand All @@ -74,6 +96,8 @@ export const questionManifestSchema = z.looseObject({
classes: z.array(z.string()).optional(),
tags: z.array(z.string()).optional(),
source: questionSourceSchema.optional(),
/** The exam papers the question was set in, newest first (spec 1.7 §2.1). */
provenance: z.array(questionProvenanceSchema).max(100).optional(),
/** The content keys of the pictures the text uses. */
assets: z.array(contentKeySchema).optional(),
license: z.string().optional(),
Expand All @@ -84,6 +108,7 @@ export type QuestionManifest = z.infer<typeof questionManifestSchema>;
export type QuestionAnswer = z.infer<typeof questionAnswerSchema>;
export type QuestionOption = z.infer<typeof questionOptionSchema>;
export type QuestionPart = z.infer<typeof questionPartSchema>;
export type QuestionProvenance = z.infer<typeof questionProvenanceSchema>;

const KEY_IN_TEXT = /c1:[a-z2-7]{25}[aeimquy4]|sha256:[0-9a-f]{64}/g;

Expand Down Expand Up @@ -130,6 +155,7 @@ export function checkQuestion(m: QuestionManifest): string[] {
if (m.kind !== 'cq' && parts.length) problems.push(`${m.kind}: parts are for a creative question`);
for (const l of dup(options.map((o) => o.label))) problems.push(`option ${l}: used twice`);
for (const l of dup(parts.map((p) => p.label))) problems.push(`part ${l}: used twice`);
for (const l of dup((m.provenance ?? []).map((p) => `${p.kind} ${p.code} ${p.year ?? '-'}`))) problems.push(`provenance ${l}: the same paper twice`);
const listed = new Set(m.assets ?? []);
for (const k of questionAssetKeys(m)) if (!listed.has(k)) problems.push(`${k}: used in the text but not in assets`);
return problems;
Expand Down
5 changes: 5 additions & 0 deletions packages/reader/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,11 @@ dock (or drive it yourself with the `mode` prop and `onModeChange`).
Escape in a field leaves the field; a second Escape clears the selection.
- **Select:** click any object; drag to move it, the corner handle to resize it (media keeps its proportions),
arrows to nudge, Delete to remove; the object under the mouse is outlined.
- **Move to another page:** drag the object past the edge of its page onto the page above or below (or the facing page in a
spread); near the edge of the view it scrolls, so a finger can carry it too. Dropped there, it is kept wholly on that page. For a far
page, **Move to page** in the sidebar (a page picker with previous and next buttons) or Alt+Page Up / Alt+Page Down: it lands at the
same place relative to the page. Either way it is one undo step, it stays selected, keeps its id and everything it had, and a patch
save sends both pages.
- **Right-click a page** (any tool) to add media where you click: a menu of Image, Video, Sound, 3D model and Lab opens at the
pointer; choosing one opens its picker in the sidebar, a small cross on the page marks the spot, and what you pick or upload
is placed centred there. The **+** in the rail does the same but places in the middle of the current page. A host can turn the menu
Expand Down
61 changes: 55 additions & 6 deletions packages/reader/src/ChalkReader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import { ToolsMenu } from './annotate/ToolsMenu.js';
import type { ToolId } from './config.js';
import { Panel } from './panels/Panel.js';
import { useAuthoring } from './author/state.js';
import { AuthorSurface } from './author/AuthorSurface.js';
import { AuthorSurface, type Drag } from './author/AuthorSurface.js';
import { boxOf, follow, freeIdOn, lineFor, relocate, sigOf, type Moved } from './author/move.js';
import { InsertMenu } from './author/InsertMenu.js';
import { AuthorSidebar, DEFAULT_TOOLS, type InsertDefaults, type ToolSettings } from './author/Sidebar.js';
import { ACCEPT, mediaKindOf, naturalSize, objectFromFile, objectFromItem, videoPoster, type MediaKind } from './author/insert.js';
Expand Down Expand Up @@ -240,7 +241,7 @@ function Reader(props: Omit<ChalkReaderProps, 'config'> & { book: Book; config:
const notesBase = useRef(original.lesson);
if (!author) notesBase.current = authoringOn ? au.lesson : original.lesson;
const ann = useAnnotations(notesBase.current, { overlay: props.overlay, defaultOverlay: props.defaultOverlay, onOverlayChange: props.onOverlayChange, persistence: config.persistence });
const [sel, setSel] = useState<{ pageId: string; id: string } | null>(null);
const [selState, setSel] = useState<{ pageId: string; id: string } | null>(null);
const [authorTool, setAuthorToolState] = useState<AuthorTool>('select');
// the media type being added (its options are in the sidebar), what drawing tools draw, how added media starts
const [adding, setAdding] = useState<MediaKind | null>(null);
Expand All @@ -265,6 +266,35 @@ function Reader(props: Omit<ChalkReaderProps, 'config'> & { book: Book; config:
const book = useMemo(() => withLibraries(authoringOn ? au.book : original, libs, lookup), [authoringOn, au.book, original, libs, lookup]);
const lesson = author ? au.lesson : ann.lesson;
const pages = lesson.pages;
// the selection follows its object to another page (a drag across pages, "Move to page", or the undo or redo of either), along the moves
// made in this session: never to another object that only shares its id
const moves = useRef<Moved[]>([]);
const sel = useMemo(() => {
if (!selState) return null;
const has = (at: { pageId: string; id: string }, sig?: string) => !!pages.find((p) => p.id === at.pageId)?.objects.some((o) => o.id === at.id && (sig === undefined || sigOf(o) === sig));
return has(selState) ? selState : (follow(selState, moves.current, has) ?? selState);
}, [selState, pages]);
// what the selection followed becomes the selection, so a later undo starts from where the object really is (not from a place another
// object with the same id may come back to)
useEffect(() => {
if (!sel || !selState || (sel.pageId === selState.pageId && sel.id === selState.id)) return;
setSel(sel);
if (sel.pageId === selState.pageId) return;
// an undo or redo took the object to another page: the view goes there too (in the single-page layout that page is not drawn otherwise,
// and its keys, Delete and the arrows, belong to its surface)
const i = pages.findIndex((p) => p.id === sel.pageId), o = pages[i]?.objects.find((x) => x.id === sel.id);
if (o) { const b = boxOf(o); viewer.current?.reveal(i, b.x + b.width / 2, b.y); }
}, [sel, selState, pages]);
const recordMove = useCallback((from: { pageId: string; id: string }, to: { pageId: string; id: string }, obj: LessonObject) => {
moves.current = [...moves.current.slice(-199), { from, to, sig: sigOf(obj), line: lineFor(moves.current, from) }];
setSel(to);
}, []);
// the drag in progress, shared by every page (an object can be carried from one page onto another)
const drag = useRef<Drag | null>(null);
// a drag ends with its pointer; leaving the select tool or author mode mid-drag (the surfaces go away) ends it too
useEffect(() => () => { const d = drag.current; if (d?.raf) cancelAnimationFrame(d.raf); drag.current = null; }, [authorTool, author]);
const lessonNow = useRef(au.lesson); lessonNow.current = au.lesson;
const pageOf = useCallback((id: string) => lessonNow.current.pages.find((p) => p.id === id), []);
const viewer = useRef<ViewerHandle>(null);
const root = useRef<HTMLDivElement>(null);
const narrow = () => (root.current?.clientWidth ?? 1000) < 720;
Expand Down Expand Up @@ -344,6 +374,18 @@ function Reader(props: Omit<ChalkReaderProps, 'config'> & { book: Book; config:
const next = useCallback(() => goTo(page + step), [goTo, page, step]);
const prev = useCallback(() => goTo(page - step), [goTo, page, step]);
const zoomBy = useCallback((f: number) => viewer.current?.zoomTo(zoom * f), [zoom]);
// "Move to page" (the sidebar, Alt+PageUp / Alt+PageDown): the selected object goes to that page, at the same place relative to the page
// and inside it, as one undo step; it stays selected and the view follows it there
const auLesson = au.lesson, auApply = au.apply;
const moveSelTo = useCallback((i: number) => {
if (!sel) return;
const from = auLesson.pages.find((p) => p.id === sel.pageId), to = auLesson.pages[i], o = from?.objects.find((x) => x.id === sel.id);
if (!from || !to || !o || to.id === from.id) return;
const there = freeIdOn(to, relocate(o, getPageSize(from), getPageSize(to))), b = boxOf(there);
auApply(edit.move(from.id, to.id, there, o.id));
recordMove({ pageId: from.id, id: o.id }, { pageId: to.id, id: there.id }, there);
viewer.current?.reveal(i, b.x + b.width / 2, b.y);
}, [sel, auLesson, auApply, recordMove]);


useEffect(() => {
Expand All @@ -368,6 +410,13 @@ function Reader(props: Omit<ChalkReaderProps, 'config'> & { book: Book; config:
}
// arrow keys move the selected object in author mode, not the page
if (author && sel && k.startsWith('Arrow')) return;
// Alt+PageDown / Alt+PageUp carries the selected object to the next / previous page
if (author && sel && e.altKey && (k === 'PageDown' || k === 'PageUp')) {
e.preventDefault();
const at = pages.findIndex((p) => p.id === sel.pageId);
if (at >= 0) moveSelTo(at + (k === 'PageDown' ? 1 : -1));
return;
}
if (k === 'PageDown' || (k === 'ArrowRight' && !mod) || (k === ' ' && layout === 'single')) { e.preventDefault(); next(); }
else if (k === 'PageUp' || (k === 'ArrowLeft' && !mod)) { e.preventDefault(); prev(); }
else if (k === 'Home') { e.preventDefault(); goTo(0); }
Expand Down Expand Up @@ -744,12 +793,12 @@ function Reader(props: Omit<ChalkReaderProps, 'config'> & { book: Book; config:
<div style={{ display: 'contents' }} onContextMenu={(e) => openInsertMenu(e, pg, scale)}>
{authorTool === 'select'
? <AuthorSurface page={pg} scale={scale} selected={sel?.pageId === pg.id ? sel.id : null}
onSelect={(id) => setSel(id ? { pageId: pg.id, id } : null)} apply={au.apply}
onDropFiles={(files, p) => void addFiles(files, pg, p)} labels={{ resize: at_.resize, drop: at_.drop }} />
onSelect={(id) => setSel(id ? { pageId: pg.id, id } : null)} onCarried={recordMove} apply={au.apply} pageOf={pageOf}
onDropFiles={(files, p) => void addFiles(files, pg, p)} labels={{ resize: at_.resize, drop: at_.drop }} drag={drag} />
: <Surface page={pg} scale={scale} pen={authorPen} editable={erasable} apply={au.apply} textPrompt={strings(authorLocale).textPrompt} />}
{addAt?.pageId === pg.id && <span className="chalk-author-target" aria-hidden style={{ left: addAt.x, top: addAt.y, '--s': scale } as CSSProperties} />}
</div>
), [sel, au.apply, addFiles, at_, authorTool, authorPen, erasable, authorLocale, openInsertMenu, addAt]);
), [sel, au.apply, addFiles, at_, authorTool, authorPen, erasable, authorLocale, openInsertMenu, addAt, pageOf, recordMove]);
// + in the rail, or the right-click menu (with the page point it was opened at): that kind of media, with its options in the sidebar
const pickMedia = useCallback((kind: MediaKind, at?: { pageId: string; x: number; y: number }) => { setAuthorToolState('select'); setSel(null); setAdding(kind); setAddAt(at ? { ...at, from: page } : null); }, [page]);
// every opening sets what the picker offers (a cancelled one never reports back, so nothing is left narrowed)
Expand Down Expand Up @@ -823,7 +872,7 @@ function Reader(props: Omit<ChalkReaderProps, 'config'> & { book: Book; config:
<AuthorSidebar lesson={au.lesson} page={sel ? au.lesson.pages.find((x) => x.id === sel.pageId) : pages[page]} selected={selectedObject}
tool={authorTool} adding={adding} tools={toolSettings} onTools={setToolSettings} defaults={insertDefaults} onDefaults={setInsertDefaults}
apply={au.apply} onSelect={(id) => { const pg = pages[page]; if (pg) setSel({ pageId: pg.id, id }); }}
onDelete={() => { if (sel) au.apply(edit.remove(sel.pageId, new Set([sel.id]))); setSel(null); }}
onDelete={() => { if (sel) au.apply(edit.remove(sel.pageId, new Set([sel.id]))); setSel(null); }} onMoveTo={moveSelTo}
libraries={libs} onLibrariesChange={props.onLibrariesChange} onPickItem={(it, lib) => void placeItem(it, lib)} onUploadTo={(fs, lib) => void uploadTo(fs, lib)}
onChooseFile={chooseFile} onDropFiles={(fs) => { const { pg, at } = target(); if (pg) void addFiles(fs, pg, at); }}
onClose={rootWidth < 720 ? () => { setSel(null); setAdding(null); } : undefined} closeLabel={strings(authorLocale).close} locale={authorLocale} t={at_} />
Expand Down
Loading
Loading