From e7992f47dc1991b3bd74d6c0ffd49d51ceec0514 Mon Sep 17 00:00:00 2001 From: sawradip Date: Sun, 1 Nov 2026 21:29:00 +0600 Subject: [PATCH 01/15] feat(reader): edit.move, an object from one page to another in one edit Authors want to move a placed video, 3D model, lab (or anything else) to another page. edit.move takes it off one page and puts it on the other in a single edit, so it is one undo step and a patch save sends both pages. The object keeps its id (a host that tracks objects by id keeps tracking it); an id the target page already uses is refused, and freeIdOn gives the object a new one first. move.ts holds where it lands: placeAt for a drag, relocate (same place relative to the page, inside it) for Move to page. Co-Authored-By: Claude Opus 5.5 --- packages/reader/src/annotate/state.ts | 20 +++++ packages/reader/src/author/move.test.ts | 103 ++++++++++++++++++++++++ packages/reader/src/author/move.ts | 46 +++++++++++ 3 files changed, 169 insertions(+) create mode 100644 packages/reader/src/author/move.test.ts create mode 100644 packages/reader/src/author/move.ts diff --git a/packages/reader/src/annotate/state.ts b/packages/reader/src/annotate/state.ts index 6a579b8..b7bde20 100644 --- a/packages/reader/src/annotate/state.ts +++ b/packages/reader/src/annotate/state.ts @@ -102,4 +102,24 @@ export const edit = { }); return changed ? { ...l, pages } : l; }, + /** + * Move an object from one page to another: it leaves `fromId` and lands on top of `toId` as `obj` (the same id, so whatever tracks it by + * its id keeps tracking it; its new place is in `obj`). Both pages change in the one edit, so it is one undo step. With the same page it + * is a replace. An id is unique only within its page (spec 1.0), so an object whose id the other page already uses must be given a free + * one first (`freeIdOn`; `was` is then the id it has on `fromId`). Nothing changes when the object is not on `fromId`, there is no + * page `toId`, or `toId` already has an object with the id it lands with. + */ + move: (fromId: string, toId: string, obj: LessonObject, was: string = obj.id) => (l: Lesson): Lesson => { + if (fromId === toId) return edit.replace(fromId, obj)(l); + const to = l.pages.find((p) => p.id === toId); + if (!to || to.objects.some((x) => x.id === obj.id) || !l.pages.some((p) => p.id === fromId && p.objects.some((x) => x.id === was))) return l; + return { + ...l, + pages: l.pages.map((p) => { + if (p.id === fromId) return { ...p, objects: p.objects.filter((x) => x.id !== was) }; + if (p.id === toId) return { ...p, objects: [...p.objects, obj] }; + return p; + }), + }; + }, }; diff --git a/packages/reader/src/author/move.test.ts b/packages/reader/src/author/move.test.ts new file mode 100644 index 0000000..7d8aec9 --- /dev/null +++ b/packages/reader/src/author/move.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { createPatch } from '@chalk-format/container'; +import { MEDIA_ICON_SIZE, type Lesson, type LessonObject, type LessonPage } from '@chalk-format/model'; +import { edit } from '../annotate/state.js'; +import { KEEP_ON_PAGE, boxOf, freeIdOn, placeAt, relocate } from './move.js'; + +const page = (id: string, objects: LessonObject[], over: Record = {}) => ({ id, title: id, background: { type: 'blank' }, objects, ...over }) as LessonPage; +const lesson = (pages: LessonPage[]): Lesson => ({ id: 'b', title: 'B', locale: 'bn', pages, snipPages: [], createdAt: '', updatedAt: '' }) as Lesson; +const lab = { id: 'lab1', type: 'embed', embed: 'plugin', plugin: 'chalk.lab', x: 100, y: 200, width: 400, height: 300, lab: { id: 'org.x.pendulum', version: '1.2.0', title: 'দোলক', entry: `c1:${'a'.repeat(26)}` } } as LessonObject; +const video = { id: 'v1', type: 'embed', embed: 'video', assetId: `c1:${'b'.repeat(26)}`, x: 50, y: 60, width: 320, height: 180, presentation: { open: 'popup' } } as LessonObject; +const line = { id: 'l1', type: 'line', x: 10, y: 10, x2: 110, y2: 60, stroke: '#000', strokeWidth: 2 } as LessonObject; +const ids = (l: Lesson, p: number) => l.pages[p]!.objects.map((o) => o.id); + +describe('edit.move: an object to another page', () => { + const book = lesson([page('p1', [video, lab]), page('p2', [line]), page('p3', [])]); + + it('takes it off one page and puts it on top of the other, the same object (id, library link, settings) at its new place', () => { + const there = { ...lab, x: 5, y: 6 } as LessonObject; + const l = edit.move('p1', 'p2', there)(book); + expect(ids(l, 0)).toEqual(['v1']); + expect(ids(l, 1)).toEqual(['l1', 'lab1']); + expect(l.pages[1]!.objects[1]).toEqual(there); + expect((l.pages[1]!.objects[1] as { lab: unknown }).lab).toEqual((lab as { lab: unknown }).lab); + expect(l.pages[2]).toBe(book.pages[2]); // other pages untouched + }); + + it('works for every kind of object: media keeps its presentation, a line both its ends', () => { + let l = edit.move('p1', 'p3', video)(book); + l = edit.move('p2', 'p3', line)(l); + expect(ids(l, 2)).toEqual(['v1', 'l1']); + expect(l.pages[2]!.objects[0]).toEqual(video); + expect(l.pages[2]!.objects[1]).toEqual(line); + }); + + it('to its own page it is a replace (it keeps its place in the order)', () => { + const l = edit.move('p1', 'p1', { ...video, x: 9 } as LessonObject)(book); + expect(ids(l, 0)).toEqual(['v1', 'lab1']); + expect(l.pages[0]!.objects[0]!.x).toBe(9); + }); + + it('changes nothing when the object is not on the page it is said to leave, or the target page does not exist', () => { + expect(edit.move('p2', 'p3', lab)(book)).toBe(book); + expect(edit.move('p1', 'nope', lab)(book)).toBe(book); + }); + + it('can be repeated during a drag (each step from where it is now) and never leaves two copies', () => { + let l = edit.move('p1', 'p2', lab)(book); + l = edit.move('p2', 'p3', lab)(l); + l = edit.move('p3', 'p2', { ...lab, y: 1 } as LessonObject)(l); + const all = l.pages.flatMap((p) => p.objects.map((o) => o.id)); + expect(all.filter((id) => id === 'lab1')).toHaveLength(1); + expect(ids(l, 1)).toEqual(['l1', 'lab1']); + }); + + it('never puts two objects with one id on a page: an id the target already uses is refused, and freeIdOn gives a new one', () => { + const twin = lesson([page('p1', [video]), page('p2', [{ ...video, x: 400 } as LessonObject])]); + expect(edit.move('p1', 'p2', video)(twin)).toBe(twin); + const landing = freeIdOn(twin.pages[1], video); + expect(landing.id).not.toBe('v1'); + expect(landing).toEqual({ ...video, id: landing.id }); + const l = edit.move('p1', 'p2', landing, 'v1')(twin); + expect(ids(l, 0)).toEqual([]); + expect(ids(l, 1)).toEqual(['v1', landing.id]); + expect(freeIdOn(book.pages[2], video)).toBe(video); // free: the same object, the same id + }); + + it('a partial save sends both pages: the one it left and the one it is on', () => { + const l = edit.move('p1', 'p2', lab)(book); + const patch = createPatch({ version: 'v0', lesson: book, hashes: new Map([['p1', 'aaaaaaaa'], ['p2', 'bbbbbbbb'], ['p3', 'cccccccc']]) }, l); + expect(Object.keys(patch.mine.pages).sort()).toEqual(['p1', 'p2']); + expect(patch.mine.pages.p2!.objects.map((o) => o.id)).toEqual(['l1', 'lab1']); + expect(patch.mine.deleted).toEqual([]); + }); +}); + +describe('where a moved object goes', () => { + const size = { width: 800, height: 1100 }; + + it('placeAt puts its top left where it was dropped, and keeps part of it on the page', () => { + expect(placeAt(video, { x: 200, y: 300 }, size)).toMatchObject({ x: 200, y: 300, width: 320, height: 180 }); + const far = placeAt(video, { x: 5000, y: -5000 }, size); + expect(far.x).toBe(800 - KEEP_ON_PAGE); + expect(far.y).toBe(KEEP_ON_PAGE - 180); + }); + + it('placeAt moves both ends of a line and keeps an icon-sized marker by its drawn box', () => { + expect(placeAt(line, { x: 0, y: 0 }, size)).toMatchObject({ x: 0, y: 0, x2: 100, y2: 50 }); + const icon = { ...lab, displayMode: 'icon' } as LessonObject; + expect(boxOf(icon).width).toBe(MEDIA_ICON_SIZE); + expect(placeAt(icon, { x: 2000, y: 0 }, size).x).toBe(800 - KEEP_ON_PAGE); + }); + + it('relocate keeps the place relative to the page, scaled to a page of another size, and inside it', () => { + expect(relocate(video, size, size)).toMatchObject({ x: 50, y: 60 }); + expect(relocate(video, size, { width: 400, height: 550 })).toMatchObject({ x: 25, y: 30, width: 320, height: 180 }); + // near the bottom right of a big page: on a small one it is pulled in so all of it shows + const low = { ...video, x: 700, y: 1000 } as LessonObject; + expect(relocate(low, size, { width: 600, height: 800 })).toMatchObject({ x: 600 - 320, y: 800 - 180 }); + // bigger than the page: its top left corner + const big = { ...video, width: 1000, height: 1200 } as LessonObject; + expect(relocate(big, size, { width: 600, height: 800 })).toMatchObject({ x: 0, y: 0 }); + }); +}); diff --git a/packages/reader/src/author/move.ts b/packages/reader/src/author/move.ts new file mode 100644 index 0000000..91de72a --- /dev/null +++ b/packages/reader/src/author/move.ts @@ -0,0 +1,46 @@ +import { MEDIA_ICON_SIZE, SNIP_ICON_SIZE, generateId, type LessonObject, type LessonPage } from '@chalk-format/model'; +import { bounds, moved, type Point } from '../annotate/ops.js'; + +type Size = { width: number; height: number }; + +/** The box an object takes on the page as drawn: icon-mode media and snips are fixed-size markers. */ +export function boxOf(o: LessonObject) { + if (o.type === 'snip') return { x: o.x, y: o.y, width: SNIP_ICON_SIZE, height: SNIP_ICON_SIZE }; + if ((o.type === 'image' || o.type === 'embed') && o.displayMode === 'icon') return { x: o.x, y: o.y, width: MEDIA_ICON_SIZE, height: MEDIA_ICON_SIZE }; + return bounds(o); +} + +/** How much of an object a drag must leave on the page, so that it can still be reached and picked up again. */ +export const KEEP_ON_PAGE = 24; + +/** + * The object with its box's top left corner at `at` on a page of `page` size, held so that at least `keep` page units of it stay on the + * page each way (what a drag does: it may hang over an edge, but never leave). Only the place changes; the object keeps its id and + * everything else, whatever its kind (lines move both ends, drawings all their points). + */ +export function placeAt(o: T, at: Point, page: Size, keep = KEEP_ON_PAGE): T { + const b = boxOf(o); + const x = Math.min(Math.max(at.x, keep - b.width), page.width - keep); + const y = Math.min(Math.max(at.y, keep - b.height), page.height - keep); + return moved(o, Math.round(x) - b.x, Math.round(y) - b.y); +} + +/** + * The object as it goes to another page by "Move to page": at the same place relative to the page (a page of another size scales the + * place, not the object), then pulled fully inside the page where it fits (an object bigger than the page starts at its top left). + */ +export function relocate(o: T, from: Size, to: Size): T { + const b = boxOf(o); + const fx = from.width > 0 ? to.width / from.width : 1, fy = from.height > 0 ? to.height / from.height : 1; + const x = Math.max(0, Math.min(b.x * fx, to.width - b.width)); + const y = Math.max(0, Math.min(b.y * fy, to.height - b.height)); + return moved(o, Math.round(x) - b.x, Math.round(y) - b.y); +} + +/** + * The object as it can land on `page`: as it is when its id is free there (the usual case: ids are random), else with a new id, because an + * id is unique only within its page (spec 1.0) and two objects on one page must not share one. + */ +export function freeIdOn(page: Pick | undefined, o: T): T { + return page?.objects.some((x) => x.id === o.id) ? { ...o, id: generateId(o.type) } : o; +} From 993a766aada3108d1d96665cfb49091e59e05c07 Mon Sep 17 00:00:00 2001 From: sawradip Date: Sun, 1 Nov 2026 21:29:00 +0600 Subject: [PATCH 02/15] feat(reader): drag an object onto another page, or send it with Move to page A drag that carries the selected object past its page's edge moves it onto the page under the pointer (above or below in the continuous view, the facing page in a spread); near the edge of the view the view scrolls, so mouse and finger reach pages not in view. The drag is shared by every page's surface and the pointer capture goes with the object. Dropped on another page it is kept wholly on that page. For far pages the sidebar has Move to page (a page picker with previous and next buttons, in English and Bengali), and Alt+Page Up / Page Down does the same from the keyboard. The selection follows the object, and author-mode pages no longer select text under a drag. Co-Authored-By: Claude Opus 5.5 --- packages/reader/src/ChalkReader.tsx | 45 +++++- packages/reader/src/author/AuthorSurface.tsx | 150 +++++++++++++++---- packages/reader/src/author/Inspector.tsx | 25 +++- packages/reader/src/author/Sidebar.tsx | 6 +- packages/reader/src/author/i18n.ts | 2 + packages/reader/src/styles.css | 9 +- 6 files changed, 200 insertions(+), 37 deletions(-) diff --git a/packages/reader/src/ChalkReader.tsx b/packages/reader/src/ChalkReader.tsx index 26afb27..8c539c4 100644 --- a/packages/reader/src/ChalkReader.tsx +++ b/packages/reader/src/ChalkReader.tsx @@ -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, freeIdOn, relocate } 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'; @@ -240,7 +241,7 @@ function Reader(props: Omit & { 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('select'); // the media type being added (its options are in the sidebar), what drawing tools draw, how added media starts const [adding, setAdding] = useState(null); @@ -265,6 +266,19 @@ function Reader(props: Omit & { 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 of either): found by its id, on the + // nearest page that has it (an id is unique only within its page) + const sel = useMemo(() => { + if (!selState || pages.find((p) => p.id === selState.pageId)?.objects.some((o) => o.id === selState.id)) return selState; + const was = pages.findIndex((p) => p.id === selState.pageId); + let now = -1; + pages.forEach((p, i) => { if (p.objects.some((o) => o.id === selState.id) && (now < 0 || Math.abs(i - was) < Math.abs(now - was))) now = i; }); + return now >= 0 ? { pageId: pages[now]!.id, id: selState.id } : selState; + }, [selState, pages]); + // the drag in progress, shared by every page (an object can be carried from one page onto another) + const drag = useRef(null); + 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(null); const root = useRef(null); const narrow = () => (root.current?.clientWidth ?? 1000) < 720; @@ -344,6 +358,18 @@ function Reader(props: Omit & { 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)); + if (there.id !== o.id) setSel({ pageId: to.id, id: there.id }); + viewer.current?.reveal(i, b.x + b.width / 2, b.y); + }, [sel, auLesson, auApply]); useEffect(() => { @@ -368,6 +394,13 @@ function Reader(props: Omit & { 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); } @@ -744,12 +777,12 @@ function Reader(props: Omit & { book: Book; config:
openInsertMenu(e, pg, scale)}> {authorTool === 'select' ? 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, on) => setSel(id ? { pageId: on ?? pg.id, id } : null)} apply={au.apply} pageOf={pageOf} + onDropFiles={(files, p) => void addFiles(files, pg, p)} labels={{ resize: at_.resize, drop: at_.drop }} drag={drag} /> : } {addAt?.pageId === pg.id && }
- ), [sel, au.apply, addFiles, at_, authorTool, authorPen, erasable, authorLocale, openInsertMenu, addAt]); + ), [sel, au.apply, addFiles, at_, authorTool, authorPen, erasable, authorLocale, openInsertMenu, addAt, pageOf]); // + 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) @@ -823,7 +856,7 @@ function Reader(props: Omit & { book: Book; config: 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_} /> diff --git a/packages/reader/src/author/AuthorSurface.tsx b/packages/reader/src/author/AuthorSurface.tsx index 611b02e..21d3c80 100644 --- a/packages/reader/src/author/AuthorSurface.tsx +++ b/packages/reader/src/author/AuthorSurface.tsx @@ -1,14 +1,10 @@ -import { useEffect, useRef, useState } from 'react'; -import { MEDIA_ICON_SIZE, SNIP_ICON_SIZE, generateId, getPageSize, type Lesson, type LessonObject, type LessonPage } from '@chalk-format/model'; -import { bounds, hits, moved, type Point } from '../annotate/ops.js'; +import { useEffect, useRef, useState, type MutableRefObject } from 'react'; +import { generateId, getPageSize, type Lesson, type LessonObject, type LessonPage } from '@chalk-format/model'; +import { hits, moved, type Point } from '../annotate/ops.js'; import { edit } from '../annotate/state.js'; +import { boxOf, freeIdOn, placeAt, relocate } from './move.js'; -/** The box an object takes on the page as drawn: icon-mode media and snips are fixed-size markers. */ -export function boxOf(o: LessonObject) { - if (o.type === 'snip') return { x: o.x, y: o.y, width: SNIP_ICON_SIZE, height: SNIP_ICON_SIZE }; - if ((o.type === 'image' || o.type === 'embed') && o.displayMode === 'icon') return { x: o.x, y: o.y, width: MEDIA_ICON_SIZE, height: MEDIA_ICON_SIZE }; - return bounds(o); -} +export { boxOf } from './move.js'; const boxed = (o: LessonObject) => o.type === 'image' || o.type === 'embed' || o.type === 'snip' || o.type === 'text' || o.type === 'rect' || o.type === 'ellipse'; /** Can its size change? Markers cannot; media keeps its proportions. */ const resizable = (o: LessonObject) => (o.type === 'image' || o.type === 'embed' ? o.displayMode !== 'icon' : o.type === 'rect' || o.type === 'ellipse'); @@ -23,18 +19,56 @@ function topAt(objects: readonly LessonObject[], p: Point, tol: number): LessonO } /** - * A page in author mode: tap selects any of the book's objects, a drag moves it, the corner handle resizes it, - * files dropped on the page are placed where they land. Edits go through `apply`; one gesture is one undo. + * A drag in progress. Every page's surface shares one, because an object can be carried from its page onto another: when it lands there, + * the pointer capture goes with it, and that page's surface goes on with the same drag (the page it left may even scroll out and unmount). + */ +export type Drag = { + /** The undo gesture: every step of the drag amends one history entry. */ + id: string; pointer: number; + /** The object as it was when the drag began; each step places this copy, so nothing accumulates. */ + orig: LessonObject; + /** Where the pointer is on the object (page units): it stays there wherever the object goes. */ + grab: Point; + /** The page the object is on now, and the page it started on. */ + on: string; from: string; + /** Where the last step put it, on a page of this size (page units). */ + placed?: { obj: LessonObject; size: { width: number; height: number } }; + /** Resize by the corner handle: stays on its page. */ + resize: boolean; start: Point; + /** Screen pixels: where it began, and where the pointer is now (for the auto-scroll). */ + startClient: { x: number; y: number }; last: { x: number; y: number }; + moved: boolean; + viewer: HTMLElement | null; + raf: number; +}; + +/** How close to the edge of the view (screen pixels) a drag scrolls it, so an object can be carried to a page that is not in view. */ +const SCROLL_EDGE = 48; + +/** The author-mode page surface under a screen point in this viewer, if any (only when it is the topmost thing there: not under the sheet). */ +function surfaceAt(viewer: HTMLElement | null, x: number, y: number): HTMLElement | null { + const top = document.elementFromPoint(x, y)?.closest('.chalk-author[data-chalk-page]'); + return top && (!viewer || viewer.contains(top)) ? top : null; +} + +/** + * A page in author mode: tap selects any of the book's objects, a drag moves it (onto another page too, when the pointer carries it there: + * the pages above and below in the continuous view, the facing page in a spread; near the edge of the view it scrolls), the corner handle + * resizes it, files dropped on the page are placed where they land. Edits go through `apply`; one gesture is one undo. */ -export function AuthorSurface({ page, scale, selected, onSelect, apply, onDropFiles, labels }: { +export function AuthorSurface({ page, scale, selected, onSelect, apply, onDropFiles, labels, drag, pageOf }: { page: LessonPage; scale: number; selected: string | null; - onSelect: (id: string | null) => void; + /** Select an object (null: nothing); `on` names its page when that is not this one (it was carried onto another). */ + onSelect: (id: string | null, on?: string) => void; apply: (fn: (l: Lesson) => Lesson, gesture?: string) => void; onDropFiles: (files: File[], at: Point) => void; labels: { resize: string; drop: string }; + /** The drag in progress, shared by all pages of the reader. */ + drag: MutableRefObject; + /** Any page of the book as it is now (an object carried onto a page must not take an id that page already uses). */ + pageOf: (id: string) => LessonPage | undefined; }) { const el = useRef(null); - const g = useRef<{ id: string; pointer: number; start: Point; orig: LessonObject; resize: boolean; moved: boolean } | null>(null); const [dropping, setDropping] = useState(false); const [hover, setHover] = useState(null); const { width, height } = getPageSize(page); @@ -67,36 +101,100 @@ export function AuthorSurface({ page, scale, selected, onSelect, apply, onDropFi onSelect(hit?.id ?? null); if (!hit) return; el.current!.setPointerCapture(e.pointerId); - g.current = { id: generateId('g'), pointer: e.pointerId, start: p, orig: hit, resize, moved: false }; + const b = boxOf(hit), client = { x: e.clientX, y: e.clientY }; + drag.current = { + id: generateId('g'), pointer: e.pointerId, orig: hit, grab: { x: p.x - b.x, y: p.y - b.y }, on: page.id, from: page.id, resize, start: p, + startClient: client, last: client, moved: false, viewer: el.current!.closest('.chalk-viewer'), raf: 0, + }; }; + + /** + * Put the dragged object under the pointer: on the page the pointer is over (moving it there if that is another page, and handing the + * pointer capture to that page), else on the page it is on, held so that part of it stays on the page. + */ + const carry = (s: Drag) => { + const { x, y } = s.last; + const target = surfaceAt(s.viewer, x, y) ?? s.viewer?.querySelector(`.chalk-author[data-chalk-page="${CSS.escape(s.on)}"]`) ?? null; + const to = target?.dataset.chalkPage; + if (!target || !to) return; + const r = target.getBoundingClientRect(); + const p = { x: (x - r.left) / scale, y: (y - r.top) / scale }; + // offsetWidth/Height: the surface's own size, in page units (it is drawn scaled) + const size = { width: target.offsetWidth, height: target.offsetHeight }; + const obj = placeAt(s.orig, { x: p.x - s.grab.x, y: p.y - s.grab.y }, size); + if (to === s.on) { apply(edit.move(s.on, to, obj), s.id); s.placed = { obj, size }; return; } + const landing = freeIdOn(pageOf(to), obj); + s.placed = { obj: landing, size }; + apply(edit.move(s.on, to, landing, obj.id), s.id); + s.orig = { ...s.orig, id: landing.id }; + s.on = to; + onSelect(landing.id, to); + try { target.setPointerCapture(s.pointer); } catch { /* the pointer is already up */ } + }; + + /** While the pointer is near the top or bottom (or a side) of the view, scroll it and keep the object under the pointer. */ + const autoScroll = (s: Drag) => { + if (s.raf || !s.viewer) return; + const tick = () => { + s.raf = 0; + const v = s.viewer; + if (drag.current !== s || !v) return; + const box = v.getBoundingClientRect(); + // on a phone the properties sheet covers the lower part of the view: its top is the edge + const sheet = v.parentElement?.querySelector('.chalk-sidebar')?.getBoundingClientRect(); + const bottom = sheet && sheet.top > box.top && sheet.left <= box.left + 1 && sheet.right >= box.right - 1 ? Math.min(box.bottom, sheet.top) : box.bottom; + const speed = (d: number) => Math.round(Math.min(d, 2 * SCROLL_EDGE) / 3); + const { x, y } = s.last; + const dy = y < box.top + SCROLL_EDGE ? -speed(box.top + SCROLL_EDGE - y) : y > bottom - SCROLL_EDGE ? speed(y - bottom + SCROLL_EDGE) : 0; + const dx = x < box.left + SCROLL_EDGE ? -speed(box.left + SCROLL_EDGE - x) : x > box.right - SCROLL_EDGE ? speed(x - box.right + SCROLL_EDGE) : 0; + if (!dx && !dy) return; + const before = { top: v.scrollTop, left: v.scrollLeft }; + v.scrollBy(dx, dy); + if (v.scrollTop === before.top && v.scrollLeft === before.left) return; // at the end: nothing more to scroll + carry(s); + s.raf = requestAnimationFrame(tick); + }; + s.raf = requestAnimationFrame(tick); + }; + const move = (e: React.PointerEvent) => { - const s = g.current; + const s = drag.current; // no drag: outline what a click would select if (!s) { if (e.pointerType === 'mouse') setHover(topAt(page.objects, at(e), tol)?.id ?? null); return; } if (s.pointer !== e.pointerId) return; - const p = at(e), dx = p.x - s.start.x, dy = p.y - s.start.y; - if (!s.moved && Math.hypot(dx, dy) < tol / 2) return; + s.last = { x: e.clientX, y: e.clientY }; + if (!s.moved && Math.hypot(s.last.x - s.startClient.x, s.last.y - s.startClient.y) < 3) return; s.moved = true; const o = s.orig; - if (s.resize && 'width' in o && 'height' in o) { + if (s.resize) { + if (!('width' in o && 'height' in o) || s.on !== page.id) return; + const p = at(e), dx = p.x - s.start.x, dy = p.y - s.start.y; const keep = o.type === 'image' || o.type === 'embed'; const w = Math.max(24, Math.round(o.width + dx)); const h = keep ? Math.round((o.height * w) / o.width) : Math.max(24, Math.round(o.height + dy)); apply(edit.replace(page.id, { ...o, width: w, height: h } as LessonObject), s.id); return; } - // stay on the page: at least part of the object remains reachable - const b = boxOf(o); - const cx = Math.min(Math.max(dx, -b.x - b.width + 24), width - b.x - 24), cy = Math.min(Math.max(dy, -b.y - b.height + 24), height - b.y - 24); - apply(edit.replace(page.id, moved(o, Math.round(cx), Math.round(cy))), s.id); + carry(s); + autoScroll(s); + }; + const end = (e: React.PointerEvent) => { + const s = drag.current; + if (s?.pointer !== e.pointerId) return; + if (s.raf) cancelAnimationFrame(s.raf); + drag.current = null; + // dropped on another page: all of it on that page where it fits (on its own page it may hang over an edge, as it always could) + if (s.placed && s.on !== s.from) { + const inside = relocate(s.placed.obj, s.placed.size, s.placed.size); + if (inside.x !== s.placed.obj.x || inside.y !== s.placed.obj.y) apply(edit.replace(s.on, inside), s.id); + } }; - const up = (e: React.PointerEvent) => { if (g.current?.pointer === e.pointerId) g.current = null; }; const sb = sel && boxOf(sel); const handle = 14 / scale; return ( -
down(e)} onPointerMove={move} onPointerUp={up} onPointerCancel={() => { g.current = null; }} +
down(e)} onPointerMove={move} onPointerUp={end} onPointerCancel={end} onDragOver={(e) => { if (e.dataTransfer.types.includes('Files')) { e.preventDefault(); setDropping(true); } }} onPointerLeave={() => setHover(null)} onDragLeave={() => setDropping(false)} diff --git a/packages/reader/src/author/Inspector.tsx b/packages/reader/src/author/Inspector.tsx index aca84d1..e6ad2d2 100644 --- a/packages/reader/src/author/Inspector.tsx +++ b/packages/reader/src/author/Inspector.tsx @@ -1,9 +1,11 @@ -import { ArrowLineDownIcon, ArrowLineUpIcon, TrashIcon } from '@phosphor-icons/react'; +import { ArrowLineDownIcon, ArrowLineUpIcon, CaretLeftIcon, CaretRightIcon, TrashIcon } from '@phosphor-icons/react'; import type { Lesson, LessonObject, Presentation } from '@chalk-format/model'; import { edit } from '../annotate/state.js'; import { kindOf } from '../present.js'; import { Colors, Field, Num, PresentationFields, Section, Seg, TEXT_SIZES, WIDTHS, numbers } from './controls.js'; import type { AuthorStrings } from './i18n.js'; +import type { Locale } from '../config.js'; +import { num } from '../i18n.js'; /** #rrggbbaa (a highlighter stroke): keep its see-through-ness when the colour changes. */ const alphaOf = (c: string) => (/^#[0-9a-f]{8}$/i.test(c) ? c.slice(7) : ''); @@ -28,10 +30,13 @@ const arrange = (pageId: string, id: string, to: 'front' | 'back') => (l: Lesson * has (spec 1.3 presentation for media; words, size and colour for text; line, fill and thickness for shapes). * Each control changes the book at once; typing in a field is one undo step. */ -export function Inspector({ o, pageId, apply, onDelete, t }: { +export function Inspector({ o, pageId, apply, onDelete, move, t }: { o: LessonObject; pageId: string; apply: (fn: (l: Lesson) => Lesson, gesture?: string) => void; - onDelete: () => void; t: AuthorStrings; + onDelete: () => void; + /** "Move to page": the page it is on (0-based), how many there are, and the move. Absent for a one-page book. */ + move?: { index: number; count: number; to: (index: number) => void; locale: Locale } | undefined; + t: AuthorStrings; }) { const media = o.type === 'image' || o.type === 'embed'; const setObj = (patch: Record, gesture?: string) => apply(edit.replace(pageId, { ...o, ...patch } as LessonObject), gesture); @@ -68,6 +73,20 @@ export function Inspector({ o, pageId, apply, onDelete, t }: {
+ {move && ( +
+ {/* for a far page; a near one is easier by dragging the object onto it */} +
+ + + +
+

{t.moveHint}

+
+ )} + {media && ( setObj({ displayMode: v === 'full' ? undefined : 'icon' })} diff --git a/packages/reader/src/author/Sidebar.tsx b/packages/reader/src/author/Sidebar.tsx index 985b661..061ca4d 100644 --- a/packages/reader/src/author/Sidebar.tsx +++ b/packages/reader/src/author/Sidebar.tsx @@ -55,6 +55,8 @@ export function AuthorSidebar(p: { defaults: InsertDefaults; onDefaults: (d: InsertDefaults) => void; apply: (fn: (l: Lesson) => Lesson, gesture?: string) => void; onSelect: (id: string) => void; onDelete: () => void; + /** Move the selected object to the page at this index (0-based). */ + onMoveTo?: ((index: number) => void) | undefined; onChooseFile: (kind: MediaKind) => void; onDropFiles: (files: File[]) => void; /** Connected libraries: the gallery searches them; with `onLibrariesChange` the panel can connect and reorder them. */ libraries: Library[]; onLibrariesChange?: ((l: Library[]) => void) | undefined; @@ -71,7 +73,9 @@ export function AuthorSidebar(p: { let icon: ReactNode, title: string, hint: string | null = null, body: ReactNode; if (p.selected && page) { icon = glyphOf(p.selected, pr); title = kindName(p.selected, t); - body = ; + const at = p.lesson.pages.findIndex((x) => x.id === page.id), onMoveTo = p.onMoveTo; + const move = onMoveTo && at >= 0 && p.lesson.pages.length > 1 ? { index: at, count: p.lesson.pages.length, to: onMoveTo, locale: p.locale } : undefined; + body = ; } else if (p.adding) { const kind = p.adding, d = p.defaults[kind] ?? {}; const setD = (next: InsertDefaults[MediaKind]) => p.onDefaults({ ...p.defaults, [kind]: next }); diff --git a/packages/reader/src/author/i18n.ts b/packages/reader/src/author/i18n.ts index 58a0940..2be5adb 100644 --- a/packages/reader/src/author/i18n.ts +++ b/packages/reader/src/author/i18n.ts @@ -26,6 +26,7 @@ const STRINGS = { addNote: 'How it will be presented once added. Change it any time by selecting it.', toolHint: 'For what you draw next.', eraserHint: 'Drag over drawings, text and shapes to erase them. To remove media, select it and delete it.', tools: 'Tools', shapes: 'Shapes', text: 'Text', textSize: 'Text size', fill: 'Fill', noFill: 'No fill', stroke: 'Line', thickness: 'Thickness', + movePage: 'Move to page', movePrev: 'Move to the previous page', moveNext: 'Move to the next page', pageN: 'Page {n}', moveHint: 'Or drag it onto another page. Alt+Page Up / Page Down moves it too.', labTitle: 'Lab name', remove: 'Delete', resize: 'Resize', drop: 'Drop to add to this page', rect: 'Rectangle', ellipse: 'Ellipse', line: 'Line', arrow: 'Arrow', ink: 'Drawing', vector: 'Drawing', image: 'Image', video: 'Video', audio: 'Sound', model3d: '3D model', lab: 'Lab', plugin: 'Widget', snip: 'Snip', other: 'Object', @@ -54,6 +55,7 @@ const STRINGS = { addNote: 'যোগ করার পর এভাবে দেখাবে। পরে নির্বাচন করে যেকোনো সময় বদলাতে পারবেন।', toolHint: 'পরের বার যা আঁকবেন তার জন্য।', eraserHint: 'আঁকা, লেখা ও আকৃতির ওপর দিয়ে টানলে মুছে যাবে। মিডিয়া সরাতে সেটি নির্বাচন করে মুছুন।', tools: 'সরঞ্জাম', shapes: 'আকৃতি', text: 'লেখা', textSize: 'লেখার আকার', fill: 'ভরাট', noFill: 'ভরাট নেই', stroke: 'রেখা', thickness: 'পুরুত্ব', + movePage: 'অন্য পাতায় সরান', movePrev: 'আগের পাতায় সরান', moveNext: 'পরের পাতায় সরান', pageN: 'পাতা {n}', moveHint: 'অথবা টেনে অন্য পাতায় নিয়ে যান। Alt+Page Up / Page Down দিয়েও সরানো যায়।', labTitle: 'ল্যাবের নাম', remove: 'মুছুন', resize: 'আকার বদলান', drop: 'এই পাতায় যোগ করতে এখানে ছাড়ুন', rect: 'আয়তক্ষেত্র', ellipse: 'উপবৃত্ত', line: 'রেখা', arrow: 'তীর', ink: 'আঁকা', vector: 'আঁকা', image: 'ছবি', video: 'ভিডিও', audio: 'শব্দ', model3d: 'ত্রিমাত্রিক মডেল', lab: 'ল্যাব', plugin: 'উইজেট', snip: 'স্নিপ', other: 'বস্তু', diff --git a/packages/reader/src/styles.css b/packages/reader/src/styles.css index 7ea2f8e..589ce84 100644 --- a/packages/reader/src/styles.css +++ b/packages/reader/src/styles.css @@ -490,7 +490,8 @@ button.chalk-box:focus-visible { outline: 3px solid var(--chalk-focus); outline- background-image: radial-gradient(color-mix(in oklch, var(--chalk-ink) 22%, transparent) 1px, transparent 1.2px); background-size: 18px 18px; } -.chalk-reader[data-mode="author"] .chalk-page { outline: 1px dashed color-mix(in oklch, var(--chalk-accent) 55%, transparent); outline-offset: 6px; } +.chalk-reader[data-mode="author"] .chalk-page { outline: 1px dashed color-mix(in oklch, var(--chalk-accent) 55%, transparent); outline-offset: 6px; user-select: none; -webkit-user-select: none; } +/* (a drag that carries an object over other pages selects none of their text: the fields of a text being typed keep theirs) */ /* what is under the mouse in select mode */ .chalk-author-hover { position: absolute; outline: calc(1.5px / var(--s)) dashed var(--chalk-accent); outline-offset: calc(2px / var(--s)); pointer-events: none; } .chalk-surface.chalk-author.is-drawing { cursor: crosshair; } @@ -556,6 +557,12 @@ button.chalk-box:focus-visible { outline: 3px solid var(--chalk-focus); outline- .chalk-side-btn:hover { border-color: var(--chalk-ink-muted); } .chalk-side-btn:focus-visible, .chalk-objrow:focus-visible { outline: 2px solid var(--chalk-focus); outline-offset: 1px; } .chalk-side-btn.is-primary { background: var(--chalk-accent); border-color: var(--chalk-accent); color: var(--chalk-accent-ink); font-weight: 600; } +/* "Move to page": previous, the page picker, next */ +.chalk-insp-move { flex-wrap: nowrap; } +.chalk-insp-move .chalk-side-btn { min-height: 36px; padding: 0 8px; } +.chalk-insp-move .chalk-side-btn:disabled { opacity: 0.4; cursor: default; } +.chalk-insp-move select { flex: 1; min-width: 0; min-height: 36px; padding: 0 8px; border: 1px solid var(--chalk-line); border-radius: var(--chalk-radius-sm); background: var(--chalk-surface); color: var(--chalk-ink); font: inherit; font-size: var(--chalk-text-md); } +.chalk-insp-move select:focus-visible { outline: 2px solid var(--chalk-focus); outline-offset: 1px; } .chalk-side-note { margin: 0; font-size: var(--chalk-text-sm); color: var(--chalk-ink-muted); } .chalk-drop { display: grid; justify-items: center; gap: var(--chalk-space-sm); padding: 20px 12px; border: 2px dashed var(--chalk-line); border-radius: var(--chalk-radius-md); color: var(--chalk-ink-muted); text-align: center; } .chalk-drop.is-over { border-color: var(--chalk-accent); background: var(--chalk-accent-soft); color: var(--chalk-accent); } From a2358db2f90accd0442074482821283c4251a1d3 Mon Sep 17 00:00:00 2001 From: sawradip Date: Sun, 1 Nov 2026 21:29:00 +0600 Subject: [PATCH 03/15] test(reader): moving an object to another page with real mouse and touch input The e2e drags the 3D model onto the next page with the mouse (the view auto-scrolls), undoes and redoes, uses Move to page and Alt+Page Up/Down, moves a video, checks the saved .chalk and the patch save (two pages, no media), carries the model with a real finger on a phone, and reads the Bengali strings. Docs list the script and describe the feature. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 1 + docs/reader-internals.md | 5 +- packages/reader/README.md | 5 + tools/reader-move-page-e2e.mjs | 210 +++++++++++++++++++++++++++++++++ 4 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 tools/reader-move-page-e2e.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b5559b0..81d9366 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 # 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 [--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 diff --git a/docs/reader-internals.md b/docs/reader-internals.md index 011f4e4..8316a59 100644 --- a/docs/reader-internals.md +++ b/docs/reader-internals.md @@ -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. diff --git a/packages/reader/README.md b/packages/reader/README.md index 34752b8..079436d 100644 --- a/packages/reader/README.md +++ b/packages/reader/README.md @@ -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 diff --git a/tools/reader-move-page-e2e.mjs b/tools/reader-move-page-e2e.mjs new file mode 100644 index 0000000..72cf9d3 --- /dev/null +++ b/tools/reader-move-page-e2e.mjs @@ -0,0 +1,210 @@ +// Author mode: an object goes to another page, with real input. A mouse drag carries the 3D model from its page onto the next one (the +// view scrolls when the pointer nears its edge); one undo puts it back, redo again; "Move to page" in the sidebar and Alt+Page Up / Down +// take it to a far page; the selection follows it everywhere and its id stays. On a phone a finger does the same (real touch events). +// The patch save sends both pages, the saved .chalk has it once, on its new page; Bengali author strings read right. +// +// npx tsx tools/reader-move-page-e2e.mjs [url] [shots-dir] (default http://localhost:5190/; the showcase must be running) +import { mkdirSync, readFileSync } from 'node:fs'; +import { chromium } from 'playwright-core'; +import { unpackLesson } from '../packages/container/src/index.ts'; + +const URL = (process.argv[2] ?? 'http://localhost:5190/').replace(/\/?$/, '/'); +const SHOTS = process.argv[3]; +if (SHOTS) mkdirSync(SHOTS, { recursive: true }); +const shot = async (p, name) => { if (SHOTS) await p.screenshot({ path: `${SHOTS}/${name}.png` }); }; +const b = await chromium.launch(); +let failed = false; +const check = (name, ok, detail = '') => { if (!ok) failed = true; console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${ok ? '' : detail ? ` (${detail})` : ''}`); }; + +/** Where an object is drawn: its page's number (1-based, by the order of the pages in the view), its screen box, how many there are. */ +const where = (p, id) => p.evaluate((i) => { + const all = [...document.querySelectorAll(`.chalk-viewer [data-chalk-id="${i}"]`)]; + const e = all[0]; if (!e) return null; + const pages = [...document.querySelectorAll('.chalk-viewer .chalk-page')]; + const pg = e.closest('.chalk-page'), r = e.getBoundingClientRect(); + const surface = pg?.querySelector('[data-chalk-page]'); + return { page: pages.indexOf(pg) + 1, pageId: surface?.getAttribute('data-chalk-page'), count: all.length, x: r.x, y: r.y, w: r.width, h: r.height, + selOnSame: !!pg?.querySelector('.chalk-author-sel'), sels: document.querySelectorAll('.chalk-author-sel').length }; +}, id); +const pageBox = (p, n) => p.evaluate((k) => { const r = document.querySelectorAll('.chalk-viewer .chalk-page')[k - 1]?.getBoundingClientRect(); return r ? { x: r.x, y: r.y, w: r.width, h: r.height } : null; }, n); +const scrollTo = (p, id) => p.evaluate((i) => document.querySelector(`.chalk-viewer [data-chalk-id="${i}"]`)?.scrollIntoView({ block: 'center' }), id); + +async function openAuthor(o, query = '', book) { + const ctx = await b.newContext({ acceptDownloads: true, ...o }); + const p = await ctx.newPage(); + await p.addInitScript({ content: 'window.__name = (f) => f' }); // tsx names functions; those sent to the page need the helper + const errs = []; + p.on('pageerror', (e) => errs.push(e.message)); + p.on('console', (m) => { if (m.type() === 'error' && !/CORS|ERR_FAILED|404/.test(m.text())) errs.push(m.text().slice(0, 200)); }); + await p.goto(`${URL}${query}`); + await p.waitForSelector('.chalk-dock-page'); + if (book) { await p.selectOption('.app-bar select', book); await p.waitForSelector('.chalk-dock-page'); } + await p.click('.app-mode button[role=radio]:has-text("Author")'); + await p.waitForSelector('.chalk-surface.chalk-author', { timeout: 60000 }); + await p.waitForTimeout(800); + return { ctx, p, errs }; +} + +// ---- desktop: drag with the mouse, undo, redo, Move to page, keys, save ---- +{ + const { ctx, p, errs } = await openAuthor({ viewport: { width: 1280, height: 800 } }); + await scrollTo(p, 'screw-gauge'); await p.waitForTimeout(800); + const a = await where(p, 'screw-gauge'); + check('the 3D model starts on page 2', a?.page === 2, JSON.stringify(a)); + await p.mouse.click(a.x + a.w / 2, a.y + a.h / 2); + await p.waitForSelector('.chalk-insp-move select', { timeout: 5000 }).catch(async () => { console.log(' sidebar:', await p.textContent('.chalk-sidebar-head'), JSON.stringify(a)); await p.screenshot({ path: 'out/fail.png' }); }); + check('selected: the sidebar offers Move to page, on page 2', (await p.inputValue('.chalk-insp-move select')) === '1'); + await shot(p, 'move-selected-desktop'); + + // drag it down past the bottom of its page, onto the top of page 3 (the view scrolls on its way) + const from = { x: a.x + a.w / 2, y: a.y + a.h / 2 }; + await p.mouse.move(from.x, from.y); await p.mouse.down(); + await p.mouse.move(from.x, 790, { steps: 12 }); + // hold near the bottom edge: the view scrolls until page 3 is under the pointer + for (let i = 0; i < 80; i++) { await p.mouse.move(from.x + (i % 2), 790); await p.waitForTimeout(30); if ((await where(p, 'screw-gauge'))?.page === 3 && (await pageBox(p, 3)).y < 300) break; } + const p3 = await pageBox(p, 3); + const dropAt = { x: from.x - 60, y: Math.round(p3.y + 140) }; + await p.mouse.move(dropAt.x, dropAt.y, { steps: 10 }); + await p.waitForTimeout(150); + await shot(p, 'move-dragging-desktop'); + await p.mouse.up(); + await p.waitForTimeout(300); + const c = await where(p, 'screw-gauge'), p3after = await pageBox(p, 3); + check('the drag carried it onto page 3, once, under the pointer', c?.page === 3 && c.count === 1 && Math.abs(c.x + c.w / 2 - dropAt.x) < 4 && c.y <= dropAt.y && c.y + c.h >= dropAt.y, JSON.stringify({ c, dropAt })); + check('dropped on another page, all of it is on that page', c && c.y >= p3after.y - 1 && c.y + c.h <= p3after.y + p3after.h + 1, JSON.stringify({ c, p3after })); + check('it stays selected, on its new page (one selection box)', c?.selOnSame && c.sels === 1 && (await p.textContent('.chalk-sidebar-head h2')).includes('3D model')); + check('the sidebar says page 3', (await p.inputValue('.chalk-insp-move select')) === '2'); + check('the book is unsaved', !!(await p.$('.chalk-dock-save.is-dirty'))); + await shot(p, 'move-dropped-desktop'); + + await p.keyboard.press('Control+z'); await p.waitForTimeout(300); + await scrollTo(p, 'screw-gauge'); await p.waitForTimeout(300); + const u = await where(p, 'screw-gauge'); + check('one undo puts it back on page 2, where it was', u?.page === 2 && u.count === 1 && Math.abs(u.w - a.w) < 1, JSON.stringify(u)); + check('the selection followed it back', u?.selOnSame === true); + await p.keyboard.press('Control+y'); await p.waitForTimeout(300); + check('redo carries it to page 3 again', (await where(p, 'screw-gauge'))?.page === 3); + + // the sidebar: a far page by its number, then the previous page by the button + await p.selectOption('.chalk-insp-move select', '4'); await p.waitForTimeout(600); + const f = await where(p, 'screw-gauge'); + check('Move to page › page 5: it is on page 5, in view, still selected', f?.page === 5 && f.selOnSame && f.y > 0 && f.y < 800, JSON.stringify(f)); + await shot(p, 'move-to-page5-desktop'); + await p.click('.chalk-insp-move button[aria-label="Move to the previous page"]'); await p.waitForTimeout(500); + check('the previous-page button takes it to page 4', (await where(p, 'screw-gauge'))?.page === 4); + check('the next-page button is off on the last page only', !(await p.isDisabled('.chalk-insp-move button[aria-label="Move to the next page"]'))); + // keys: Alt+Page Up / Page Down (focus on the page, as after a click) + await p.keyboard.press('Alt+PageUp'); await p.waitForTimeout(500); + check('Alt+Page Up: to page 3', (await where(p, 'screw-gauge'))?.page === 3); + await p.keyboard.press('Alt+PageDown'); await p.keyboard.press('Alt+PageDown'); await p.waitForTimeout(500); + check('Alt+Page Down twice: to page 5, the last', (await where(p, 'screw-gauge'))?.page === 5); + check('the next-page button is off on the last page', await p.isDisabled('.chalk-insp-move button[aria-label="Move to the next page"]')); + await p.keyboard.press('Alt+PageDown'); await p.waitForTimeout(300); + check('and Alt+Page Down there changes nothing', (await where(p, 'screw-gauge'))?.page === 5); + + // a video goes too, keeping how it is presented + // pages far from the view are not drawn: go to the video's page first + await p.click('.chalk-dock-page'); await p.fill('.chalk-dock-page input', '3'); await p.press('.chalk-dock-page input', 'Enter'); await p.waitForTimeout(800); + await scrollTo(p, 'pendulum-video'); await p.waitForTimeout(500); + const v = await where(p, 'pendulum-video'); + const vLabel = await p.evaluate(() => document.querySelector('[data-chalk-id="pendulum-video"]')?.getAttribute('aria-label')); + await p.mouse.click(v.x + v.w / 2, v.y + v.h / 2); await p.waitForTimeout(200); + await p.selectOption('.chalk-insp-move select', '0'); await p.waitForTimeout(500); + const v2 = await where(p, 'pendulum-video'); + check('the video moves to page 1 with its label', v2?.page === 1 && (await p.evaluate(() => document.querySelector('[data-chalk-id="pendulum-video"]')?.getAttribute('aria-label'))) === vLabel, JSON.stringify(v2)); + + // save (the showcase downloads the .chalk): each object once, on its new page, the same id + const dl = p.waitForEvent('download'); + await p.keyboard.press('Control+s'); + const file = await (await dl).path(); + const { lesson } = await unpackLesson(new Uint8Array(readFileSync(file))); + const on = (id) => lesson.pages.flatMap((pg, i) => pg.objects.filter((o) => o.id === id).map(() => i + 1)); + check('saved: the 3D model once, on page 5', JSON.stringify(on('screw-gauge')) === '[5]', JSON.stringify(on('screw-gauge'))); + check('saved: the video once, on page 1', JSON.stringify(on('pendulum-video')) === '[1]', JSON.stringify(on('pendulum-video'))); + check('no errors on the page', errs.length === 0, errs.join(' | ')); + await ctx.close(); +} + +// ---- the patch save of a streamed book: both pages, nothing else ---- +{ + const { ctx, p, errs } = await openAuthor({ viewport: { width: 1280, height: 800 } }, '?save=patch', 'long'); + // the lab of the sample's pages: the first page that has it + let l = null; + for (let n = 1; n <= 5 && !l; n++) { + await p.click('.chalk-dock-page'); await p.fill('.chalk-dock-page input', String(n)); await p.press('.chalk-dock-page input', 'Enter'); await p.waitForTimeout(800); + await scrollTo(p, 'pendulum-lab'); await p.waitForTimeout(500); + l = await where(p, 'pendulum-lab'); + } + await p.mouse.click(l.x + 20, l.y + 20); await p.waitForSelector('.chalk-insp-move select'); + const fromPage = Number(await p.inputValue('.chalk-insp-move select')); + await p.click('.chalk-insp-move button[aria-label="Move to the next page"]'); await p.waitForTimeout(500); + await p.locator('.chalk-dock-btn:has(.chalk-dock-save)').first().click(); + await p.waitForFunction(() => document.querySelector('.app-save')?.getAttribute('data-dirty') === 'false', null, { timeout: 30000 }); + const patches = await p.evaluate(() => window.__patches ?? []); + const sent = patches[0]; + check('one patch, with exactly the two pages', patches.length === 1 && sent.pages.length === 2 && sent.pages.includes(`long-${fromPage + 1}`) && sent.pages.includes(`long-${fromPage + 2}`), JSON.stringify(sent?.pages)); + check('the page it left has one object fewer, the page it is on one more', sent && sent.mineObjects[sent.pages.indexOf(`long-${fromPage + 1}`)] === sent.baseObjects[sent.base.indexOf(`long-${fromPage + 1}`)] - 1 && sent.mineObjects[sent.pages.indexOf(`long-${fromPage + 2}`)] === sent.baseObjects[sent.base.indexOf(`long-${fromPage + 2}`)] + 1, JSON.stringify(sent)); + check('no media is sent for a move', sent?.media.length === 0); + check('no errors (streamed book)', errs.length === 0, errs.join(' | ')); + await ctx.close(); +} + +// ---- phone: a finger selects, then carries the object onto the next page; the sheet's buttons; Bengali ---- +{ + const { ctx, p, errs } = await openAuthor({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 2 }); + const cdp = await ctx.newCDPSession(p); + const touch = (type, x, y) => cdp.send('Input.dispatchTouchEvent', { type, touchPoints: type === 'touchEnd' ? [] : [{ x, y, id: 1 }] }); + await scrollTo(p, 'screw-gauge'); await p.waitForTimeout(800); + let a = await where(p, 'screw-gauge'); + await p.touchscreen.tap(a.x + a.w / 2, a.y + a.h / 2); + await p.waitForSelector('.chalk-insp-move select'); await p.waitForTimeout(700); // the sheet opens and the object is scrolled up above it + a = await where(p, 'screw-gauge'); + check('phone: a tap selects; the sheet offers Move to page', a?.page === 2); + await shot(p, 'move-selected-phone'); + const sheetTop = await p.evaluate(() => document.querySelector('.chalk-sidebar').getBoundingClientRect().top); + // the finger goes down the page to just above the sheet and waits there: the view scrolls until page 3 is under it + const x0 = a.x + a.w / 2, y0 = a.y + a.h / 2; + await touch('touchStart', x0, y0); + for (let k = 1; k <= 10; k++) { await touch('touchMove', x0, y0 + ((sheetTop - 20 - y0) * k) / 10); await p.waitForTimeout(16); } + for (let i = 0; i < 80 && (await where(p, 'screw-gauge'))?.page !== 3; i++) { await touch('touchMove', x0 + (i % 2), sheetTop - 20); await p.waitForTimeout(30); } + const p3 = await pageBox(p, 3); + const dropY = Math.max(p3.y + 120, 120); + for (let k = 1; k <= 6; k++) { await touch('touchMove', x0, sheetTop - 20 + ((dropY - sheetTop + 20) * k) / 6); await p.waitForTimeout(16); } + await shot(p, 'move-dragging-phone'); + await touch('touchEnd'); await p.waitForTimeout(400); + const c = await where(p, 'screw-gauge'); + check('phone: the finger carried it onto page 3, once, still selected', c?.page === 3 && c.count === 1 && c.selOnSame, JSON.stringify(c)); + await shot(p, 'move-dropped-phone'); + await p.tap('.chalk-insp-move button[aria-label="Move to the next page"]'); await p.waitForTimeout(600); + check('phone: the next-page button in the sheet takes it to page 4', (await where(p, 'screw-gauge'))?.page === 4); + check('no errors (phone)', errs.length === 0, errs.join(' | ')); + await ctx.close(); +} + +// ---- the author strings in Bengali ---- +{ + const { ctx, p } = await openAuthor({ viewport: { width: 1280, height: 800 } }); + // the Configure playground: the language of the author's tools + await p.click('.app-btn:has-text("Configure")'); + await p.click('fieldset.pg-choice:has(legend:has-text("Language of the author")) label:has-text("bn")'); + await p.click('.app-btn:has-text("Configure")'); + await p.waitForTimeout(500); + await scrollTo(p, 'screw-gauge'); await p.waitForTimeout(800); + const a = await where(p, 'screw-gauge'); + await p.mouse.click(a.x + a.w / 2, a.y + a.h / 2); + const bn = await p.waitForSelector('.chalk-insp-move', { timeout: 3000 }).then(() => p.evaluate(() => ({ + title: [...document.querySelectorAll('.chalk-side-section h3')].map((h) => h.textContent).find((t) => t.includes('পাতায়')), + option: document.querySelector('.chalk-insp-move select option:checked')?.textContent, + prev: document.querySelector('.chalk-insp-move button')?.getAttribute('aria-label'), + })), () => null); + check('Bengali: the Move to page section is there', !!bn); + if (bn) { + check('Bengali: the section, the page (Bengali digits) and the button read right', bn.title === 'অন্য পাতায় সরান' && bn.option === 'পাতা ২' && bn.prev === 'আগের পাতায় সরান', JSON.stringify(bn)); + await shot(p, 'move-bengali-desktop'); + } + await ctx.close(); +} + +await b.close(); +console.log(failed ? 'SOME CHECKS FAILED' : 'all checks passed'); +process.exit(failed ? 1 : 0); From f28d1412e58ca97033461752da8f3955751073e8 Mon Sep 17 00:00:00 2001 From: sawradip Date: Sun, 1 Nov 2026 21:43:43 +0600 Subject: [PATCH 04/15] fix(reader): the selection follows a moved object along its moves, never to a twin id Codex review round 1: the selection was found again by id on the nearest page, so with the same id on two pages a move (or its undo) could select the other page's object, and after a move that renamed the id the undo lost the selection. The reader now records each move of this session (from, to) and follows the selection along them, from either end, until it finds the object; with no recorded path it is gone, as before. Co-Authored-By: Claude Opus 5.5 --- packages/reader/src/ChalkReader.tsx | 27 ++++++++++-------- packages/reader/src/author/AuthorSurface.tsx | 9 +++--- packages/reader/src/author/move.test.ts | 27 +++++++++++++++++- packages/reader/src/author/move.ts | 29 ++++++++++++++++++++ 4 files changed, 75 insertions(+), 17 deletions(-) diff --git a/packages/reader/src/ChalkReader.tsx b/packages/reader/src/ChalkReader.tsx index 8c539c4..b867ab5 100644 --- a/packages/reader/src/ChalkReader.tsx +++ b/packages/reader/src/ChalkReader.tsx @@ -22,7 +22,7 @@ import type { ToolId } from './config.js'; import { Panel } from './panels/Panel.js'; import { useAuthoring } from './author/state.js'; import { AuthorSurface, type Drag } from './author/AuthorSurface.js'; -import { boxOf, freeIdOn, relocate } from './author/move.js'; +import { boxOf, follow, freeIdOn, relocate, 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'; @@ -266,15 +266,18 @@ function Reader(props: Omit & { 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 of either): found by its id, on the - // nearest page that has it (an id is unique only within its page) + // 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([]); const sel = useMemo(() => { - if (!selState || pages.find((p) => p.id === selState.pageId)?.objects.some((o) => o.id === selState.id)) return selState; - const was = pages.findIndex((p) => p.id === selState.pageId); - let now = -1; - pages.forEach((p, i) => { if (p.objects.some((o) => o.id === selState.id) && (now < 0 || Math.abs(i - was) < Math.abs(now - was))) now = i; }); - return now >= 0 ? { pageId: pages[now]!.id, id: selState.id } : selState; + if (!selState) return null; + const has = (at: { pageId: string; id: string }) => !!pages.find((p) => p.id === at.pageId)?.objects.some((o) => o.id === at.id); + return has(selState) ? selState : (follow(selState, moves.current, has) ?? selState); }, [selState, pages]); + const recordMove = useCallback((from: { pageId: string; id: string }, to: { pageId: string; id: string }) => { + moves.current = [...moves.current.slice(-199), { from, to }]; + setSel(to); + }, []); // the drag in progress, shared by every page (an object can be carried from one page onto another) const drag = useRef(null); const lessonNow = useRef(au.lesson); lessonNow.current = au.lesson; @@ -367,9 +370,9 @@ function Reader(props: Omit & { book: Book; config: 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)); - if (there.id !== o.id) setSel({ pageId: to.id, id: there.id }); + recordMove({ pageId: from.id, id: o.id }, { pageId: to.id, id: there.id }); viewer.current?.reveal(i, b.x + b.width / 2, b.y); - }, [sel, auLesson, auApply]); + }, [sel, auLesson, auApply, recordMove]); useEffect(() => { @@ -777,12 +780,12 @@ function Reader(props: Omit & { book: Book; config:
openInsertMenu(e, pg, scale)}> {authorTool === 'select' ? setSel(id ? { pageId: on ?? pg.id, id } : null)} apply={au.apply} pageOf={pageOf} + 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} /> : } {addAt?.pageId === pg.id && }
- ), [sel, au.apply, addFiles, at_, authorTool, authorPen, erasable, authorLocale, openInsertMenu, addAt, pageOf]); + ), [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) diff --git a/packages/reader/src/author/AuthorSurface.tsx b/packages/reader/src/author/AuthorSurface.tsx index 21d3c80..848cfd1 100644 --- a/packages/reader/src/author/AuthorSurface.tsx +++ b/packages/reader/src/author/AuthorSurface.tsx @@ -56,10 +56,11 @@ function surfaceAt(viewer: HTMLElement | null, x: number, y: number): HTMLElemen * the pages above and below in the continuous view, the facing page in a spread; near the edge of the view it scrolls), the corner handle * resizes it, files dropped on the page are placed where they land. Edits go through `apply`; one gesture is one undo. */ -export function AuthorSurface({ page, scale, selected, onSelect, apply, onDropFiles, labels, drag, pageOf }: { +export function AuthorSurface({ page, scale, selected, onSelect, onCarried, apply, onDropFiles, labels, drag, pageOf }: { page: LessonPage; scale: number; selected: string | null; - /** Select an object (null: nothing); `on` names its page when that is not this one (it was carried onto another). */ - onSelect: (id: string | null, on?: string) => void; + onSelect: (id: string | null) => void; + /** The dragged object was carried from one page (and id) onto another: the selection goes with it. */ + onCarried: (from: { pageId: string; id: string }, to: { pageId: string; id: string }) => void; apply: (fn: (l: Lesson) => Lesson, gesture?: string) => void; onDropFiles: (files: File[], at: Point) => void; labels: { resize: string; drop: string }; @@ -126,9 +127,9 @@ export function AuthorSurface({ page, scale, selected, onSelect, apply, onDropFi const landing = freeIdOn(pageOf(to), obj); s.placed = { obj: landing, size }; apply(edit.move(s.on, to, landing, obj.id), s.id); + onCarried({ pageId: s.on, id: obj.id }, { pageId: to, id: landing.id }); s.orig = { ...s.orig, id: landing.id }; s.on = to; - onSelect(landing.id, to); try { target.setPointerCapture(s.pointer); } catch { /* the pointer is already up */ } }; diff --git a/packages/reader/src/author/move.test.ts b/packages/reader/src/author/move.test.ts index 7d8aec9..6cdd290 100644 --- a/packages/reader/src/author/move.test.ts +++ b/packages/reader/src/author/move.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { createPatch } from '@chalk-format/container'; import { MEDIA_ICON_SIZE, type Lesson, type LessonObject, type LessonPage } from '@chalk-format/model'; import { edit } from '../annotate/state.js'; -import { KEEP_ON_PAGE, boxOf, freeIdOn, placeAt, relocate } from './move.js'; +import { KEEP_ON_PAGE, boxOf, follow, freeIdOn, placeAt, relocate, type At } from './move.js'; const page = (id: string, objects: LessonObject[], over: Record = {}) => ({ id, title: id, background: { type: 'blank' }, objects, ...over }) as LessonPage; const lesson = (pages: LessonPage[]): Lesson => ({ id: 'b', title: 'B', locale: 'bn', pages, snipPages: [], createdAt: '', updatedAt: '' }) as Lesson; @@ -101,3 +101,28 @@ describe('where a moved object goes', () => { expect(relocate(big, size, { width: 600, height: 800 })).toMatchObject({ x: 0, y: 0 }); }); }); + +describe('the selection follows a moved object', () => { + const at = (pageId: string, id: string): At => ({ pageId, id }); + const where = (...places: At[]) => (a: At) => places.some((p) => p.pageId === a.pageId && p.id === a.id); + + it('never to another object that only shares its id', () => { + // x on p1 moved to p3; another x sits on p2: undoing the move leads back to p1, not to p2 + const moves = [{ from: at('p1', 'x'), to: at('p3', 'x') }]; + expect(follow(at('p3', 'x'), moves, where(at('p1', 'x'), at('p2', 'x')))).toEqual(at('p1', 'x')); + // with no move made, an object gone from its place is gone (no guess by id) + expect(follow(at('p1', 'x'), [], where(at('p2', 'x')))).toBeNull(); + }); + + it('through a renamed id, and back again on redo', () => { + const moves = [{ from: at('p1', 'x'), to: at('p2', 'x_new') }]; + expect(follow(at('p2', 'x_new'), moves, where(at('p1', 'x')))).toEqual(at('p1', 'x')); + expect(follow(at('p1', 'x'), moves, where(at('p2', 'x_new')))).toEqual(at('p2', 'x_new')); + }); + + it('along a drag that crossed several pages, undone as one step', () => { + const moves = [{ from: at('p1', 'x'), to: at('p2', 'x') }, { from: at('p2', 'x'), to: at('p3', 'x') }]; + expect(follow(at('p3', 'x'), moves, where(at('p1', 'x')))).toEqual(at('p1', 'x')); + expect(follow(at('p3', 'x'), moves, where())).toBeNull(); + }); +}); diff --git a/packages/reader/src/author/move.ts b/packages/reader/src/author/move.ts index 91de72a..810faea 100644 --- a/packages/reader/src/author/move.ts +++ b/packages/reader/src/author/move.ts @@ -44,3 +44,32 @@ export function relocate(o: T, from: Size, to: Size): T export function freeIdOn(page: Pick | undefined, o: T): T { return page?.objects.some((x) => x.id === o.id) ? { ...o, id: generateId(o.type) } : o; } + +/** An object at its place: its page and its id there. */ +export type At = { pageId: string; id: string }; +/** A move made in this session: the object was at `from`, and is at `to` since. */ +export type Moved = { from: At; to: At }; + +/** + * Where the selected object is now, when it is no longer where the selection says (a move, or the undo or redo of one): the selection is + * followed along the moves made in this session, newest first, from either end, until a place that `has` the object. Never a guess by id + * alone (an id is unique only within its page: another page's object with the same id is another object). Null when it is gone. + */ +export function follow(sel: At, moves: readonly Moved[], has: (at: At) => boolean): At | null { + const same = (a: At, b: At) => a.pageId === b.pageId && a.id === b.id; + const seen: At[] = [sel]; + let cur = sel; + for (let step = 0; step <= moves.length; step++) { + if (has(cur)) return cur; + let next: At | null = null; + for (let i = moves.length - 1; i >= 0 && !next; i--) { + const m = moves[i]!; + const other = same(m.from, cur) ? m.to : same(m.to, cur) ? m.from : null; + if (other && !seen.some((s) => same(s, other))) next = other; + } + if (!next) return null; + seen.push(next); + cur = next; + } + return null; +} From dfcb9fa13dc4ed1b030c5dfd8dc110d5e0a47134 Mon Sep 17 00:00:00 2001 From: sawradip Date: Sun, 1 Nov 2026 21:49:48 +0600 Subject: [PATCH 05/15] fix(reader): a followed selection becomes the selection; a drag ends with a tool change Codex review round 2: after the selection followed an object, the stale selection stayed in state, so a later undo bringing another object with the same id back to that place selected the wrong one; the followed place now replaces it. And a drag cut short by switching tools (the surfaces unmount and miss pointerup) stayed live: it now ends when the tool or mode changes, and on a mouse move with no button down. Co-Authored-By: Claude Opus 5.5 --- packages/reader/src/ChalkReader.tsx | 5 +++++ packages/reader/src/author/AuthorSurface.tsx | 2 ++ tools/reader-move-page-e2e.mjs | 9 +++++++++ 3 files changed, 16 insertions(+) diff --git a/packages/reader/src/ChalkReader.tsx b/packages/reader/src/ChalkReader.tsx index b867ab5..1fccbb0 100644 --- a/packages/reader/src/ChalkReader.tsx +++ b/packages/reader/src/ChalkReader.tsx @@ -274,12 +274,17 @@ function Reader(props: Omit & { book: Book; config: const has = (at: { pageId: string; id: string }) => !!pages.find((p) => p.id === at.pageId)?.objects.some((o) => o.id === at.id); 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)) setSel(sel); }, [sel, selState]); const recordMove = useCallback((from: { pageId: string; id: string }, to: { pageId: string; id: string }) => { moves.current = [...moves.current.slice(-199), { from, to }]; setSel(to); }, []); // the drag in progress, shared by every page (an object can be carried from one page onto another) const drag = useRef(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(null); diff --git a/packages/reader/src/author/AuthorSurface.tsx b/packages/reader/src/author/AuthorSurface.tsx index 848cfd1..57f09b7 100644 --- a/packages/reader/src/author/AuthorSurface.tsx +++ b/packages/reader/src/author/AuthorSurface.tsx @@ -163,6 +163,8 @@ export function AuthorSurface({ page, scale, selected, onSelect, onCarried, appl // no drag: outline what a click would select if (!s) { if (e.pointerType === 'mouse') setHover(topAt(page.objects, at(e), tol)?.id ?? null); return; } if (s.pointer !== e.pointerId) return; + // a mouse with no button down: the drag ended where this page did not hear it (e.g. the button was let go over another tool) + if (e.pointerType === 'mouse' && e.buttons === 0) { end(e); return; } s.last = { x: e.clientX, y: e.clientY }; if (!s.moved && Math.hypot(s.last.x - s.startClient.x, s.last.y - s.startClient.y) < 3) return; s.moved = true; diff --git a/tools/reader-move-page-e2e.mjs b/tools/reader-move-page-e2e.mjs index 72cf9d3..0760caf 100644 --- a/tools/reader-move-page-e2e.mjs +++ b/tools/reader-move-page-e2e.mjs @@ -113,6 +113,15 @@ async function openAuthor(o, query = '', book) { const v2 = await where(p, 'pendulum-video'); check('the video moves to page 1 with its label', v2?.page === 1 && (await p.evaluate(() => document.querySelector('[data-chalk-id="pendulum-video"]')?.getAttribute('aria-label'))) === vLabel, JSON.stringify(v2)); + // a drag cut short by a tool change (P, let go, V) is over: moving the mouse afterwards moves nothing + const s0 = await where(p, 'pendulum-video'); + await p.mouse.move(s0.x + s0.w / 2, s0.y + s0.h / 2); await p.mouse.down(); await p.mouse.move(s0.x + s0.w / 2 + 20, s0.y + s0.h / 2 + 10, { steps: 4 }); + await p.keyboard.press('p'); await p.mouse.up(); await p.keyboard.press('v'); await p.waitForTimeout(200); + const s1 = await where(p, 'pendulum-video'); + await p.mouse.move(s0.x + s0.w / 2 + 200, s0.y + s0.h / 2 + 120, { steps: 6 }); await p.waitForTimeout(200); + const s2 = await where(p, 'pendulum-video'); + check('a drag cut short by a tool change leaves the object where it was let go', Math.abs(s2.x - s1.x) < 1 && Math.abs(s2.y - s1.y) < 1, JSON.stringify({ s1, s2 })); + // save (the showcase downloads the .chalk): each object once, on its new page, the same id const dl = p.waitForEvent('download'); await p.keyboard.press('Control+s'); From 604e058b00c1f4be6452c7eaa70a10983a00be6a Mon Sep 17 00:00:00 2001 From: sawradip Date: Sun, 1 Nov 2026 21:54:25 +0600 Subject: [PATCH 06/15] fix(reader): follow a moved selection by searching all its moves, not one path Codex review round 3: after moves A to B to C and two undos the selection stayed on B, because follow took the newer B to C move, hit a dead end and never tried B to A. It now searches outward over every recorded move (nearest first, newer first among equals). Co-Authored-By: Claude Opus 5.5 --- packages/reader/src/author/move.test.ts | 6 +++++ packages/reader/src/author/move.ts | 34 +++++++++++++------------ 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/packages/reader/src/author/move.test.ts b/packages/reader/src/author/move.test.ts index 6cdd290..14cf6b5 100644 --- a/packages/reader/src/author/move.test.ts +++ b/packages/reader/src/author/move.test.ts @@ -120,6 +120,12 @@ describe('the selection follows a moved object', () => { expect(follow(at('p1', 'x'), moves, where(at('p2', 'x_new')))).toEqual(at('p2', 'x_new')); }); + it('back past a move it does not need: A to B to C, undone twice, finds A', () => { + const moves = [{ from: at('p1', 'x'), to: at('p2', 'x') }, { from: at('p2', 'x'), to: at('p3', 'x') }]; + // the first undo left the selection on p2; the second puts the object back on p1 + expect(follow(at('p2', 'x'), moves, where(at('p1', 'x')))).toEqual(at('p1', 'x')); + }); + it('along a drag that crossed several pages, undone as one step', () => { const moves = [{ from: at('p1', 'x'), to: at('p2', 'x') }, { from: at('p2', 'x'), to: at('p3', 'x') }]; expect(follow(at('p3', 'x'), moves, where(at('p1', 'x')))).toEqual(at('p1', 'x')); diff --git a/packages/reader/src/author/move.ts b/packages/reader/src/author/move.ts index 810faea..17ebc9f 100644 --- a/packages/reader/src/author/move.ts +++ b/packages/reader/src/author/move.ts @@ -51,25 +51,27 @@ export type At = { pageId: string; id: string }; export type Moved = { from: At; to: At }; /** - * Where the selected object is now, when it is no longer where the selection says (a move, or the undo or redo of one): the selection is - * followed along the moves made in this session, newest first, from either end, until a place that `has` the object. Never a guess by id - * alone (an id is unique only within its page: another page's object with the same id is another object). Null when it is gone. + * Where the selected object is now, when it is no longer where the selection says (a move, or the undo or redo of one): the places it was + * moved between in this session, searched outward from the selection (nearest first, newer moves first among equals) for one that `has` + * the object. Never a guess by id alone (an id is unique only within its page: another page's object with the same id is another object). + * Null when it is gone. */ export function follow(sel: At, moves: readonly Moved[], has: (at: At) => boolean): At | null { - const same = (a: At, b: At) => a.pageId === b.pageId && a.id === b.id; - const seen: At[] = [sel]; - let cur = sel; - for (let step = 0; step <= moves.length; step++) { - if (has(cur)) return cur; - let next: At | null = null; - for (let i = moves.length - 1; i >= 0 && !next; i--) { - const m = moves[i]!; - const other = same(m.from, cur) ? m.to : same(m.to, cur) ? m.from : null; - if (other && !seen.some((s) => same(s, other))) next = other; + const key = (a: At) => `${a.pageId}\u0000${a.id}`; + const seen = new Set([key(sel)]); + let ring: At[] = [sel]; + while (ring.length) { + const found = ring.find(has); + if (found) return found; + const next: At[] = []; + for (const cur of ring) { + for (let i = moves.length - 1; i >= 0; i--) { + const m = moves[i]!; + const other = key(m.from) === key(cur) ? m.to : key(m.to) === key(cur) ? m.from : null; + if (other && !seen.has(key(other))) { seen.add(key(other)); next.push(other); } + } } - if (!next) return null; - seen.push(next); - cur = next; + ring = next; } return null; } From 6755b6629ca68b053d99b666bc54765ce3fa02c5 Mon Sep 17 00:00:00 2001 From: sawradip Date: Sun, 1 Nov 2026 22:01:16 +0600 Subject: [PATCH 07/15] fix(reader): a followed selection must find the very object that was moved Codex review round 4: following page/id places could land on another object that later took a place the moved one passed through. Each recorded move now keeps what the object was (sigOf: everything but its id and place), and a place reached through a move counts only when it holds that same object. Co-Authored-By: Claude Opus 5.5 --- packages/reader/src/ChalkReader.tsx | 10 ++++---- packages/reader/src/author/AuthorSurface.tsx | 4 ++-- packages/reader/src/author/move.test.ts | 24 ++++++++++++++++---- packages/reader/src/author/move.ts | 24 ++++++++++++++------ 4 files changed, 43 insertions(+), 19 deletions(-) diff --git a/packages/reader/src/ChalkReader.tsx b/packages/reader/src/ChalkReader.tsx index 1fccbb0..0f2ea83 100644 --- a/packages/reader/src/ChalkReader.tsx +++ b/packages/reader/src/ChalkReader.tsx @@ -22,7 +22,7 @@ import type { ToolId } from './config.js'; import { Panel } from './panels/Panel.js'; import { useAuthoring } from './author/state.js'; import { AuthorSurface, type Drag } from './author/AuthorSurface.js'; -import { boxOf, follow, freeIdOn, relocate, type Moved } from './author/move.js'; +import { boxOf, follow, freeIdOn, 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'; @@ -271,14 +271,14 @@ function Reader(props: Omit & { book: Book; config: const moves = useRef([]); const sel = useMemo(() => { if (!selState) return null; - const has = (at: { pageId: string; id: string }) => !!pages.find((p) => p.id === at.pageId)?.objects.some((o) => o.id === at.id); + 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)) setSel(sel); }, [sel, selState]); - const recordMove = useCallback((from: { pageId: string; id: string }, to: { pageId: string; id: string }) => { - moves.current = [...moves.current.slice(-199), { from, to }]; + 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) }]; setSel(to); }, []); // the drag in progress, shared by every page (an object can be carried from one page onto another) @@ -375,7 +375,7 @@ function Reader(props: Omit & { book: Book; config: 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 }); + 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]); diff --git a/packages/reader/src/author/AuthorSurface.tsx b/packages/reader/src/author/AuthorSurface.tsx index 57f09b7..42ba975 100644 --- a/packages/reader/src/author/AuthorSurface.tsx +++ b/packages/reader/src/author/AuthorSurface.tsx @@ -60,7 +60,7 @@ export function AuthorSurface({ page, scale, selected, onSelect, onCarried, appl page: LessonPage; scale: number; selected: string | null; onSelect: (id: string | null) => void; /** The dragged object was carried from one page (and id) onto another: the selection goes with it. */ - onCarried: (from: { pageId: string; id: string }, to: { pageId: string; id: string }) => void; + onCarried: (from: { pageId: string; id: string }, to: { pageId: string; id: string }, obj: LessonObject) => void; apply: (fn: (l: Lesson) => Lesson, gesture?: string) => void; onDropFiles: (files: File[], at: Point) => void; labels: { resize: string; drop: string }; @@ -127,7 +127,7 @@ export function AuthorSurface({ page, scale, selected, onSelect, onCarried, appl const landing = freeIdOn(pageOf(to), obj); s.placed = { obj: landing, size }; apply(edit.move(s.on, to, landing, obj.id), s.id); - onCarried({ pageId: s.on, id: obj.id }, { pageId: to, id: landing.id }); + onCarried({ pageId: s.on, id: obj.id }, { pageId: to, id: landing.id }, landing); s.orig = { ...s.orig, id: landing.id }; s.on = to; try { target.setPointerCapture(s.pointer); } catch { /* the pointer is already up */ } diff --git a/packages/reader/src/author/move.test.ts b/packages/reader/src/author/move.test.ts index 14cf6b5..56dfa9a 100644 --- a/packages/reader/src/author/move.test.ts +++ b/packages/reader/src/author/move.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { createPatch } from '@chalk-format/container'; import { MEDIA_ICON_SIZE, type Lesson, type LessonObject, type LessonPage } from '@chalk-format/model'; import { edit } from '../annotate/state.js'; -import { KEEP_ON_PAGE, boxOf, follow, freeIdOn, placeAt, relocate, type At } from './move.js'; +import { KEEP_ON_PAGE, boxOf, follow, freeIdOn, placeAt, relocate, sigOf, type At } from './move.js'; const page = (id: string, objects: LessonObject[], over: Record = {}) => ({ id, title: id, background: { type: 'blank' }, objects, ...over }) as LessonPage; const lesson = (pages: LessonPage[]): Lesson => ({ id: 'b', title: 'B', locale: 'bn', pages, snipPages: [], createdAt: '', updatedAt: '' }) as Lesson; @@ -104,30 +104,44 @@ describe('where a moved object goes', () => { describe('the selection follows a moved object', () => { const at = (pageId: string, id: string): At => ({ pageId, id }); + const mv = (from: At, to: At, sig = 's') => ({ from, to, sig }); const where = (...places: At[]) => (a: At) => places.some((p) => p.pageId === a.pageId && p.id === a.id); it('never to another object that only shares its id', () => { // x on p1 moved to p3; another x sits on p2: undoing the move leads back to p1, not to p2 - const moves = [{ from: at('p1', 'x'), to: at('p3', 'x') }]; + const moves = [mv(at('p1', 'x'), at('p3', 'x'))]; expect(follow(at('p3', 'x'), moves, where(at('p1', 'x'), at('p2', 'x')))).toEqual(at('p1', 'x')); // with no move made, an object gone from its place is gone (no guess by id) expect(follow(at('p1', 'x'), [], where(at('p2', 'x')))).toBeNull(); }); + it('never to another object that came to a place the moved object passed through', () => { + // x (sig "a") moved from p1 to p3, then another object ("b") took p1 and the id x: it is not the moved one, and is never selected for it + const moves = [mv(at('p1', 'x'), at('p3', 'x'), 'a')]; + const objects = new Map([['p1 x', 'b']]); + const has = (a: At, sig?: string) => objects.has(`${a.pageId} ${a.id}`) && (sig === undefined || objects.get(`${a.pageId} ${a.id}`) === sig); + expect(follow(at('p3', 'x'), moves, has)).toBeNull(); + objects.set('p1 x', 'a'); + expect(follow(at('p3', 'x'), moves, has)).toEqual(at('p1', 'x')); + // what the signature leaves out: the id and the place + expect(sigOf({ ...video, id: 'other', x: 1, y: 2 } as LessonObject)).toBe(sigOf(video)); + expect(sigOf({ ...video, width: 1 } as LessonObject)).not.toBe(sigOf(video)); + }); + it('through a renamed id, and back again on redo', () => { - const moves = [{ from: at('p1', 'x'), to: at('p2', 'x_new') }]; + const moves = [mv(at('p1', 'x'), at('p2', 'x_new'))]; expect(follow(at('p2', 'x_new'), moves, where(at('p1', 'x')))).toEqual(at('p1', 'x')); expect(follow(at('p1', 'x'), moves, where(at('p2', 'x_new')))).toEqual(at('p2', 'x_new')); }); it('back past a move it does not need: A to B to C, undone twice, finds A', () => { - const moves = [{ from: at('p1', 'x'), to: at('p2', 'x') }, { from: at('p2', 'x'), to: at('p3', 'x') }]; + const moves = [mv(at('p1', 'x'), at('p2', 'x')), mv(at('p2', 'x'), at('p3', 'x'))]; // the first undo left the selection on p2; the second puts the object back on p1 expect(follow(at('p2', 'x'), moves, where(at('p1', 'x')))).toEqual(at('p1', 'x')); }); it('along a drag that crossed several pages, undone as one step', () => { - const moves = [{ from: at('p1', 'x'), to: at('p2', 'x') }, { from: at('p2', 'x'), to: at('p3', 'x') }]; + const moves = [mv(at('p1', 'x'), at('p2', 'x')), mv(at('p2', 'x'), at('p3', 'x'))]; expect(follow(at('p3', 'x'), moves, where(at('p1', 'x')))).toEqual(at('p1', 'x')); expect(follow(at('p3', 'x'), moves, where())).toBeNull(); }); diff --git a/packages/reader/src/author/move.ts b/packages/reader/src/author/move.ts index 17ebc9f..f7e7edc 100644 --- a/packages/reader/src/author/move.ts +++ b/packages/reader/src/author/move.ts @@ -47,28 +47,38 @@ export function freeIdOn(page: Pick = { ...o }; + for (const k of ['id', 'x', 'y', 'x2', 'y2']) delete rest[k]; + return JSON.stringify(rest); +} /** * Where the selected object is now, when it is no longer where the selection says (a move, or the undo or redo of one): the places it was * moved between in this session, searched outward from the selection (nearest first, newer moves first among equals) for one that `has` - * the object. Never a guess by id alone (an id is unique only within its page: another page's object with the same id is another object). + * the object. Never a guess by id alone (an id is unique only within its page: another page's object with the same id is another object): + * a place reached through a move must hold the object that move moved (`has` gets its `sig`), not another one that came to that page and id. * Null when it is gone. */ -export function follow(sel: At, moves: readonly Moved[], has: (at: At) => boolean): At | null { +export function follow(sel: At, moves: readonly Moved[], has: (at: At, sig?: string) => boolean): At | null { const key = (a: At) => `${a.pageId}\u0000${a.id}`; const seen = new Set([key(sel)]); + if (has(sel)) return sel; let ring: At[] = [sel]; while (ring.length) { - const found = ring.find(has); - if (found) return found; const next: At[] = []; for (const cur of ring) { for (let i = moves.length - 1; i >= 0; i--) { const m = moves[i]!; const other = key(m.from) === key(cur) ? m.to : key(m.to) === key(cur) ? m.from : null; - if (other && !seen.has(key(other))) { seen.add(key(other)); next.push(other); } + if (!other || seen.has(key(other))) continue; + if (has(other, m.sig)) return other; + seen.add(key(other)); + next.push(other); } } ring = next; From 9ecb70ea839f9fd91653e4c65af6eaa949940463 Mon Sep 17 00:00:00 2001 From: sawradip Date: Sun, 1 Nov 2026 22:06:10 +0600 Subject: [PATCH 08/15] fix(reader): follow only the moves of the object the selection last moved with Codex review round 5: the search could cross from one object's moves to another's at a page both passed through (same id), and select the other. It now walks only the moves with the signature of the newest move that touches the selection. Co-Authored-By: Claude Opus 5.5 --- packages/reader/src/author/move.test.ts | 10 ++++++++++ packages/reader/src/author/move.ts | 12 ++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/reader/src/author/move.test.ts b/packages/reader/src/author/move.test.ts index 56dfa9a..50e12d7 100644 --- a/packages/reader/src/author/move.test.ts +++ b/packages/reader/src/author/move.test.ts @@ -128,6 +128,16 @@ describe('the selection follows a moved object', () => { expect(sigOf({ ...video, width: 1 } as LessonObject)).not.toBe(sigOf(video)); }); + it('only along the moves of the one object: never across to another that passed through the same place', () => { + // A (sig a) dragged p1 to p2 to p3; B (sig b), same id, moved p4 to p2; both undone: the selection on p3 finds A on p1, never B on p4 + const moves = [mv(at('p1', 'x'), at('p2', 'x'), 'a'), mv(at('p2', 'x'), at('p3', 'x'), 'a'), mv(at('p4', 'x'), at('p2', 'x'), 'b')]; + const objects = new Map([['p1 x', 'a'], ['p4 x', 'b']]); + const has = (q: At, sig?: string) => objects.has(`${q.pageId} ${q.id}`) && (sig === undefined || objects.get(`${q.pageId} ${q.id}`) === sig); + expect(follow(at('p3', 'x'), moves, has)).toEqual(at('p1', 'x')); + objects.delete('p1 x'); + expect(follow(at('p3', 'x'), moves, has)).toBeNull(); + }); + it('through a renamed id, and back again on redo', () => { const moves = [mv(at('p1', 'x'), at('p2', 'x_new'))]; expect(follow(at('p2', 'x_new'), moves, where(at('p1', 'x')))).toEqual(at('p1', 'x')); diff --git a/packages/reader/src/author/move.ts b/packages/reader/src/author/move.ts index f7e7edc..a7f2367 100644 --- a/packages/reader/src/author/move.ts +++ b/packages/reader/src/author/move.ts @@ -61,19 +61,23 @@ export function sigOf(o: LessonObject): string { * Where the selected object is now, when it is no longer where the selection says (a move, or the undo or redo of one): the places it was * moved between in this session, searched outward from the selection (nearest first, newer moves first among equals) for one that `has` * the object. Never a guess by id alone (an id is unique only within its page: another page's object with the same id is another object): - * a place reached through a move must hold the object that move moved (`has` gets its `sig`), not another one that came to that page and id. - * Null when it is gone. + * only the moves of the one object the selection last moved with are walked (the newest move that touches the selection says which: its + * `sig`), and a place reached must hold that object (`has` gets the `sig`), not another one that came to that page and id. Null when it is + * gone. */ export function follow(sel: At, moves: readonly Moved[], has: (at: At, sig?: string) => boolean): At | null { const key = (a: At) => `${a.pageId}\u0000${a.id}`; const seen = new Set([key(sel)]); if (has(sel)) return sel; + const last = [...moves].reverse().find((m) => key(m.from) === key(sel) || key(m.to) === key(sel)); + if (!last) return null; + const mine = moves.filter((m) => m.sig === last.sig); let ring: At[] = [sel]; while (ring.length) { const next: At[] = []; for (const cur of ring) { - for (let i = moves.length - 1; i >= 0; i--) { - const m = moves[i]!; + for (let i = mine.length - 1; i >= 0; i--) { + const m = mine[i]!; const other = key(m.from) === key(cur) ? m.to : key(m.to) === key(cur) ? m.from : null; if (!other || seen.has(key(other))) continue; if (has(other, m.sig)) return other; From 8eabcd30b919f2b2141361943f63838ba934319e Mon Sep 17 00:00:00 2001 From: sawradip Date: Sun, 1 Nov 2026 22:09:10 +0600 Subject: [PATCH 09/15] fix(reader): follow one object's line of moves, checking each place against its own move Codex review round 6: filtering the moves by the newest move's signature dropped the earlier moves of the same object when it was changed between them (moved, resized, moved), so undoing everything stranded the selection. Each move now goes on a line (the line of the move that ended where it starts); follow walks that line and checks each place it reaches against the signature of the move that leads there. Co-Authored-By: Claude Opus 5.5 --- packages/reader/src/ChalkReader.tsx | 4 ++-- packages/reader/src/author/move.test.ts | 26 +++++++++++++++++-------- packages/reader/src/author/move.ts | 21 ++++++++++++++------ 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/packages/reader/src/ChalkReader.tsx b/packages/reader/src/ChalkReader.tsx index 0f2ea83..99e0bc5 100644 --- a/packages/reader/src/ChalkReader.tsx +++ b/packages/reader/src/ChalkReader.tsx @@ -22,7 +22,7 @@ import type { ToolId } from './config.js'; import { Panel } from './panels/Panel.js'; import { useAuthoring } from './author/state.js'; import { AuthorSurface, type Drag } from './author/AuthorSurface.js'; -import { boxOf, follow, freeIdOn, relocate, sigOf, type Moved } from './author/move.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'; @@ -278,7 +278,7 @@ function Reader(props: Omit & { book: Book; config: // object with the same id may come back to) useEffect(() => { if (sel && selState && (sel.pageId !== selState.pageId || sel.id !== selState.id)) setSel(sel); }, [sel, selState]); 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) }]; + 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) diff --git a/packages/reader/src/author/move.test.ts b/packages/reader/src/author/move.test.ts index 50e12d7..b312074 100644 --- a/packages/reader/src/author/move.test.ts +++ b/packages/reader/src/author/move.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { createPatch } from '@chalk-format/container'; import { MEDIA_ICON_SIZE, type Lesson, type LessonObject, type LessonPage } from '@chalk-format/model'; import { edit } from '../annotate/state.js'; -import { KEEP_ON_PAGE, boxOf, follow, freeIdOn, placeAt, relocate, sigOf, type At } from './move.js'; +import { KEEP_ON_PAGE, boxOf, follow, freeIdOn, lineFor, placeAt, relocate, sigOf, type At, type Moved } from './move.js'; const page = (id: string, objects: LessonObject[], over: Record = {}) => ({ id, title: id, background: { type: 'blank' }, objects, ...over }) as LessonPage; const lesson = (pages: LessonPage[]): Lesson => ({ id: 'b', title: 'B', locale: 'bn', pages, snipPages: [], createdAt: '', updatedAt: '' }) as Lesson; @@ -104,12 +104,14 @@ describe('where a moved object goes', () => { describe('the selection follows a moved object', () => { const at = (pageId: string, id: string): At => ({ pageId, id }); - const mv = (from: At, to: At, sig = 's') => ({ from, to, sig }); + // moves recorded as the reader does: each on the line of the move that ended where it starts + const line = (...ms: { from: At; to: At; sig?: string }[]) => ms.reduce((acc, m) => [...acc, { from: m.from, to: m.to, sig: m.sig ?? 's', line: lineFor(acc, m.from) }], []); + const mv = (from: At, to: At, sig?: string) => ({ from, to, ...(sig ? { sig } : {}) }); const where = (...places: At[]) => (a: At) => places.some((p) => p.pageId === a.pageId && p.id === a.id); it('never to another object that only shares its id', () => { // x on p1 moved to p3; another x sits on p2: undoing the move leads back to p1, not to p2 - const moves = [mv(at('p1', 'x'), at('p3', 'x'))]; + const moves = line(mv(at('p1', 'x'), at('p3', 'x'))); expect(follow(at('p3', 'x'), moves, where(at('p1', 'x'), at('p2', 'x')))).toEqual(at('p1', 'x')); // with no move made, an object gone from its place is gone (no guess by id) expect(follow(at('p1', 'x'), [], where(at('p2', 'x')))).toBeNull(); @@ -117,7 +119,7 @@ describe('the selection follows a moved object', () => { it('never to another object that came to a place the moved object passed through', () => { // x (sig "a") moved from p1 to p3, then another object ("b") took p1 and the id x: it is not the moved one, and is never selected for it - const moves = [mv(at('p1', 'x'), at('p3', 'x'), 'a')]; + const moves = line(mv(at('p1', 'x'), at('p3', 'x'), 'a')); const objects = new Map([['p1 x', 'b']]); const has = (a: At, sig?: string) => objects.has(`${a.pageId} ${a.id}`) && (sig === undefined || objects.get(`${a.pageId} ${a.id}`) === sig); expect(follow(at('p3', 'x'), moves, has)).toBeNull(); @@ -130,7 +132,7 @@ describe('the selection follows a moved object', () => { it('only along the moves of the one object: never across to another that passed through the same place', () => { // A (sig a) dragged p1 to p2 to p3; B (sig b), same id, moved p4 to p2; both undone: the selection on p3 finds A on p1, never B on p4 - const moves = [mv(at('p1', 'x'), at('p2', 'x'), 'a'), mv(at('p2', 'x'), at('p3', 'x'), 'a'), mv(at('p4', 'x'), at('p2', 'x'), 'b')]; + const moves = line(mv(at('p1', 'x'), at('p2', 'x'), 'a'), mv(at('p2', 'x'), at('p3', 'x'), 'a'), mv(at('p4', 'x'), at('p2', 'x'), 'b')); const objects = new Map([['p1 x', 'a'], ['p4 x', 'b']]); const has = (q: At, sig?: string) => objects.has(`${q.pageId} ${q.id}`) && (sig === undefined || objects.get(`${q.pageId} ${q.id}`) === sig); expect(follow(at('p3', 'x'), moves, has)).toEqual(at('p1', 'x')); @@ -138,20 +140,28 @@ describe('the selection follows a moved object', () => { expect(follow(at('p3', 'x'), moves, has)).toBeNull(); }); + it('along one object\'s moves even when it was changed between them (moved, resized, moved, all undone)', () => { + const moves = line(mv(at('p1', 'x'), at('p2', 'x'), 'small'), mv(at('p2', 'x'), at('p3', 'x'), 'big')); + expect(moves.map((m) => m.line)).toEqual([1, 1]); + // everything undone: back on p1, as it was before the resize + const has = (q: At, sig?: string) => q.pageId === 'p1' && q.id === 'x' && (sig === undefined || sig === 'small'); + expect(follow(at('p3', 'x'), moves, has)).toEqual(at('p1', 'x')); + }); + it('through a renamed id, and back again on redo', () => { - const moves = [mv(at('p1', 'x'), at('p2', 'x_new'))]; + const moves = line(mv(at('p1', 'x'), at('p2', 'x_new'))); expect(follow(at('p2', 'x_new'), moves, where(at('p1', 'x')))).toEqual(at('p1', 'x')); expect(follow(at('p1', 'x'), moves, where(at('p2', 'x_new')))).toEqual(at('p2', 'x_new')); }); it('back past a move it does not need: A to B to C, undone twice, finds A', () => { - const moves = [mv(at('p1', 'x'), at('p2', 'x')), mv(at('p2', 'x'), at('p3', 'x'))]; + const moves = line(mv(at('p1', 'x'), at('p2', 'x')), mv(at('p2', 'x'), at('p3', 'x'))); // the first undo left the selection on p2; the second puts the object back on p1 expect(follow(at('p2', 'x'), moves, where(at('p1', 'x')))).toEqual(at('p1', 'x')); }); it('along a drag that crossed several pages, undone as one step', () => { - const moves = [mv(at('p1', 'x'), at('p2', 'x')), mv(at('p2', 'x'), at('p3', 'x'))]; + const moves = line(mv(at('p1', 'x'), at('p2', 'x')), mv(at('p2', 'x'), at('p3', 'x'))); expect(follow(at('p3', 'x'), moves, where(at('p1', 'x')))).toEqual(at('p1', 'x')); expect(follow(at('p3', 'x'), moves, where())).toBeNull(); }); diff --git a/packages/reader/src/author/move.ts b/packages/reader/src/author/move.ts index a7f2367..61b7353 100644 --- a/packages/reader/src/author/move.ts +++ b/packages/reader/src/author/move.ts @@ -47,8 +47,17 @@ export function freeIdOn(page: Pick= 0; i--) if (moves[i]!.to.pageId === from.pageId && moves[i]!.to.id === from.id) return moves[i]!.line; + return moves.reduce((n, m) => Math.max(n, m.line), 0) + 1; +} /** An object without its id and place: the same object moved has the same one (another object that took its page and id has not). */ export function sigOf(o: LessonObject): string { @@ -61,9 +70,9 @@ export function sigOf(o: LessonObject): string { * Where the selected object is now, when it is no longer where the selection says (a move, or the undo or redo of one): the places it was * moved between in this session, searched outward from the selection (nearest first, newer moves first among equals) for one that `has` * the object. Never a guess by id alone (an id is unique only within its page: another page's object with the same id is another object): - * only the moves of the one object the selection last moved with are walked (the newest move that touches the selection says which: its - * `sig`), and a place reached must hold that object (`has` gets the `sig`), not another one that came to that page and id. Null when it is - * gone. + * only the moves on the line of the newest move that touches the selection are walked (one object's moves, not another's that passed through + * the same place), and a place reached through a move must hold the object as that move moved it (`has` gets its `sig`; an undo brings back + * exactly that), not another one that came to that page and id. Null when it is gone. */ export function follow(sel: At, moves: readonly Moved[], has: (at: At, sig?: string) => boolean): At | null { const key = (a: At) => `${a.pageId}\u0000${a.id}`; @@ -71,7 +80,7 @@ export function follow(sel: At, moves: readonly Moved[], has: (at: At, sig?: str if (has(sel)) return sel; const last = [...moves].reverse().find((m) => key(m.from) === key(sel) || key(m.to) === key(sel)); if (!last) return null; - const mine = moves.filter((m) => m.sig === last.sig); + const mine = moves.filter((m) => m.line === last.line); let ring: At[] = [sel]; while (ring.length) { const next: At[] = []; From 5f8c0f79d08c7a1c8c94038658ec8ab5fa2cf8fe Mon Sep 17 00:00:00 2001 From: sawradip Date: Sun, 1 Nov 2026 22:11:34 +0600 Subject: [PATCH 10/15] fix(reader): each move checks its own end, even a place another move reached first Codex review round 7: after A to B, a resize, B to A and three undos, the newer move's end (A, big) failed and marked A seen, so the older move (A, small) never checked it. A place is now checked against every move that leads to it; only the expansion is done once. Co-Authored-By: Claude Opus 5.5 --- packages/reader/src/author/move.test.ts | 7 +++++++ packages/reader/src/author/move.ts | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/reader/src/author/move.test.ts b/packages/reader/src/author/move.test.ts index b312074..64d2502 100644 --- a/packages/reader/src/author/move.test.ts +++ b/packages/reader/src/author/move.test.ts @@ -148,6 +148,13 @@ describe('the selection follows a moved object', () => { expect(follow(at('p3', 'x'), moves, has)).toEqual(at('p1', 'x')); }); + it('back and forth between two pages with a change between (A to B, resize, B to A, all undone)', () => { + const moves = line(mv(at('p1', 'x'), at('p2', 'x'), 'small'), mv(at('p2', 'x'), at('p1', 'x'), 'big')); + // the selection was on p1 (after the second move); all undone, the object is on p1 again, small: the older move knows it + const has = (q: At, sig?: string) => q.pageId === 'p1' && q.id === 'x' && (sig === undefined || sig === 'small'); + expect(follow(at('p2', 'x'), moves, has)).toEqual(at('p1', 'x')); + }); + it('through a renamed id, and back again on redo', () => { const moves = line(mv(at('p1', 'x'), at('p2', 'x_new'))); expect(follow(at('p2', 'x_new'), moves, where(at('p1', 'x')))).toEqual(at('p1', 'x')); diff --git a/packages/reader/src/author/move.ts b/packages/reader/src/author/move.ts index 61b7353..d7eadff 100644 --- a/packages/reader/src/author/move.ts +++ b/packages/reader/src/author/move.ts @@ -88,8 +88,10 @@ export function follow(sel: At, moves: readonly Moved[], has: (at: At, sig?: str for (let i = mine.length - 1; i >= 0; i--) { const m = mine[i]!; const other = key(m.from) === key(cur) ? m.to : key(m.to) === key(cur) ? m.from : null; - if (!other || seen.has(key(other))) continue; + if (!other) continue; + // each move checks its own end, even one already reached by another move (the object differs from move to move) if (has(other, m.sig)) return other; + if (seen.has(key(other))) continue; seen.add(key(other)); next.push(other); } From d0580e2202a3967c05c01f79dc6a379693eda553 Mon Sep 17 00:00:00 2001 From: sawradip Date: Sun, 1 Nov 2026 22:14:29 +0600 Subject: [PATCH 11/15] fix(reader): undo or redo of a move shows the object on its page Codex review round 8: in the single-page layout an undone move left the selection on a page that was not drawn, so its keys (Delete, arrows) had no surface. When the selection follows its object to another page the view now reveals it there, in every layout. The e2e checks undo and redo of a Move to page. Co-Authored-By: Claude Opus 5.5 --- packages/reader/src/ChalkReader.tsx | 10 +++++++++- tools/reader-move-page-e2e.mjs | 6 ++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/reader/src/ChalkReader.tsx b/packages/reader/src/ChalkReader.tsx index 99e0bc5..1536072 100644 --- a/packages/reader/src/ChalkReader.tsx +++ b/packages/reader/src/ChalkReader.tsx @@ -276,7 +276,15 @@ function Reader(props: Omit & { book: Book; config: }, [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)) setSel(sel); }, [sel, selState]); + 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); diff --git a/tools/reader-move-page-e2e.mjs b/tools/reader-move-page-e2e.mjs index 0760caf..db25fdd 100644 --- a/tools/reader-move-page-e2e.mjs +++ b/tools/reader-move-page-e2e.mjs @@ -101,6 +101,12 @@ async function openAuthor(o, query = '', book) { check('the next-page button is off on the last page', await p.isDisabled('.chalk-insp-move button[aria-label="Move to the next page"]')); await p.keyboard.press('Alt+PageDown'); await p.waitForTimeout(300); check('and Alt+Page Down there changes nothing', (await where(p, 'screw-gauge'))?.page === 5); + // undo: the object goes back to page 4, and the view goes with it, still selected + await p.keyboard.press('Control+z'); await p.waitForTimeout(700); + const back = await where(p, 'screw-gauge'); + check('undo of a move shows the object where it went back to, selected', back?.page === 4 && back.selOnSame && back.y > -back.h && back.y < 800, JSON.stringify(back)); + await p.keyboard.press('Control+y'); await p.waitForTimeout(700); + check('redo takes it (and the view) to page 5 again', (await where(p, 'screw-gauge'))?.page === 5); // a video goes too, keeping how it is presented // pages far from the view are not drawn: go to the video's page first From c08358e7f8e793b20cc04b06aac987e4f4435193 Mon Sep 17 00:00:00 2001 From: sawradip Date: Sun, 1 Nov 2026 22:17:40 +0600 Subject: [PATCH 12/15] fix(reader): the window hears a drag's release, even when its page has unmounted Codex review round 9: auto-scroll can take the page holding the pointer capture out of the drawn range; it unmounts with its handlers, and a release in the gutter then never ended the drag. The drag now also listens for pointerup and pointercancel on the window, once. Co-Authored-By: Claude Opus 5.5 --- packages/reader/src/author/AuthorSurface.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/reader/src/author/AuthorSurface.tsx b/packages/reader/src/author/AuthorSurface.tsx index 42ba975..0cdc893 100644 --- a/packages/reader/src/author/AuthorSurface.tsx +++ b/packages/reader/src/author/AuthorSurface.tsx @@ -103,10 +103,18 @@ export function AuthorSurface({ page, scale, selected, onSelect, onCarried, appl if (!hit) return; el.current!.setPointerCapture(e.pointerId); const b = boxOf(hit), client = { x: e.clientX, y: e.clientY }; - drag.current = { + const s: Drag = { id: generateId('g'), pointer: e.pointerId, orig: hit, grab: { x: p.x - b.x, y: p.y - b.y }, on: page.id, from: page.id, resize, start: p, startClient: client, last: client, moved: false, viewer: el.current!.closest('.chalk-viewer'), raf: 0, }; + drag.current = s; + // the page holding the pointer may scroll out of the view and unmount mid-drag (its handlers go with it): the window hears the release + const stop = (ev: PointerEvent) => { + if (ev.pointerId !== s.pointer) return; + removeEventListener('pointerup', stop, true); removeEventListener('pointercancel', stop, true); + if (drag.current === s) end(ev); + }; + addEventListener('pointerup', stop, true); addEventListener('pointercancel', stop, true); }; /** @@ -181,7 +189,7 @@ export function AuthorSurface({ page, scale, selected, onSelect, onCarried, appl carry(s); autoScroll(s); }; - const end = (e: React.PointerEvent) => { + const end = (e: { pointerId: number }) => { const s = drag.current; if (s?.pointer !== e.pointerId) return; if (s.raf) cancelAnimationFrame(s.raf); From 87a3ad2699e43e291be3397d5e8eb437a3f3a6eb Mon Sep 17 00:00:00 2001 From: sawradip Date: Mon, 2 Nov 2026 02:06:01 +0600 Subject: [PATCH 13/15] =?UTF-8?q?feat(model):=20question=20manifests:=20pr?= =?UTF-8?q?ovenance,=20the=20exam=20papers=20a=20question=20was=20set=20in?= =?UTF-8?q?=20(spec=201.7=20=C2=A72.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An optional array of {kind, code, name, year}: kind board_exam | institution_exam (open), code 2-8 of A-Z0-9, name a string or one per language, year an integer or null. checkQuestion reports the same paper listed twice. The spec says source may be left out by a publisher that must not name the book it took a question from. Co-Authored-By: Claude Opus 5.5 --- examples/questions/free-fall.question.json | 5 ++ packages/model/src/question-manifest.test.ts | 27 ++++++++++ packages/model/src/question-manifest.ts | 28 ++++++++++- schema/question-manifest.schema.json | 52 ++++++++++++++++++++ spec/1.7/README.md | 30 ++++++++++- 5 files changed, 139 insertions(+), 3 deletions(-) diff --git a/examples/questions/free-fall.question.json b/examples/questions/free-fall.question.json index 81d6c82..add13db 100644 --- a/examples/questions/free-fall.question.json +++ b/examples/questions/free-fall.question.json @@ -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"] } diff --git a/packages/model/src/question-manifest.test.ts b/packages/model/src/question-manifest.test.ts index e153afd..0c2387e 100644 --- a/packages/model/src/question-manifest.test.ts +++ b/packages/model/src/question-manifest.test.ts @@ -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).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([]); diff --git a/packages/model/src/question-manifest.ts b/packages/model/src/question-manifest.ts index b2261c3..c946726 100644 --- a/packages/model/src/question-manifest.ts +++ b/packages/model/src/question-manifest.ts @@ -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(), @@ -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), @@ -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(), @@ -84,6 +108,7 @@ export type QuestionManifest = z.infer; export type QuestionAnswer = z.infer; export type QuestionOption = z.infer; export type QuestionPart = z.infer; +export type QuestionProvenance = z.infer; const KEY_IN_TEXT = /c1:[a-z2-7]{25}[aeimquy4]|sha256:[0-9a-f]{64}/g; @@ -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; diff --git a/schema/question-manifest.schema.json b/schema/question-manifest.schema.json index 6f20dbb..84ff525 100644 --- a/schema/question-manifest.schema.json +++ b/schema/question-manifest.schema.json @@ -278,6 +278,58 @@ }, "additionalProperties": {} }, + "provenance": { + "maxItems": 100, + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]{0,39}$" + }, + "code": { + "type": "string", + "pattern": "^[A-Z0-9]{2,8}$" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-z]{2,3}(-[A-Za-z0-9]+)*$" + }, + "additionalProperties": { + "type": "string" + } + } + ] + }, + "year": { + "anyOf": [ + { + "type": "integer", + "minimum": 1900, + "maximum": 2200 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "code", + "name" + ], + "additionalProperties": {} + } + }, "assets": { "type": "array", "items": { diff --git a/spec/1.7/README.md b/spec/1.7/README.md index 8a265eb..1a2c110 100644 --- a/spec/1.7/README.md +++ b/spec/1.7/README.md @@ -52,11 +52,37 @@ Stored and delivered with the media type `application/vnd.chalk.question+json`: | `answer` | | `text` (the answer, or a worked solution), `detail` (`final` or `full`), and for a number `value` and `unit` | | `marks`, `difficulty`, `level` | | Marks (a number ≥ 0), difficulty 1 to 5, and the thinking asked for: `knowledge`, `comprehension`, `application`, `higher_order` | | `subject`, `classes`, `tags`, `license`, `authors` | | For finding questions in a store | -| `source` | | Where it was printed, for people: `book`, `edition`, `number`, `page` | +| `source` | | Where it was printed, for people: `book`, `edition`, `number`, `page`. A publisher that must not say which book it took the question from leaves it out | +| `provenance` | | The exam papers the question was set in, for students (§2.1) | | `assets` | | The content keys of the pictures the text uses (§4) | Like the lab manifest, a manifest is an open object: writers keep fields they do not know. +### 2.1 Provenance + +Where a question **appeared**: a board's exam, a school's or college's test paper. A reader shows each entry as a short tag +(the `code`) and the full `name` when the tag is hovered or tapped. + +```jsonc +"provenance": [ + { "kind": "board_exam", "code": "DHA", "name": "ঢাকা বোর্ড", "year": 2023 }, + { "kind": "institution_exam", "code": "VNSC", "name": "ভিকারুননিসা নূন স্কুল এন্ড কলেজ, ঢাকা", "year": null } +] +``` + +| Field | | Meaning | +|---|---|---| +| `kind` | required | `board_exam` (a board's public exam) or `institution_exam` (a school's or college's own test paper). Later versions may add kinds: lower-case letters, digits and `_` | +| `code` | required | The tag: 2 to 8 of `A`–`Z` and `0`–`9`, such as `DHA` or `VNSC`. A publisher gives each board or school one code and keeps it | +| `name` | required | The full name, as text (§4, without Markdown): a string, or one per language | +| `year` | | The year of the paper, an integer; `null` (or no `year`) when it is not known | + +- One entry per paper: the same `kind`, `code` and `year` appear once. A question set by two boards in the same year has two entries. +- A writer SHOULD list the newest year first, and entries without a year last. A reader may sort them again. +- A reader MUST NOT reject a manifest because of a `kind` it does not know: it shows the `code`, with the `name` on hover. +- `provenance` is not `source`. `source` is the book the publisher copied the question from, and it may be left out; a paper in + `provenance` is public knowledge, what a student wants to know ("this came in the Dhaka board exam, 2023"). + ## 3. Kinds | `kind` | Has | Rules | @@ -106,5 +132,5 @@ The manifest's own `tags` are listed as they are. The item's title SHOULD be the ## 7. Conformance - `schema/question-manifest.schema.json`. -- `examples/questions/`: a multiple-choice, a numeric and a creative question, valid against the schema. +- `examples/questions/`: a multiple-choice, a numeric and a creative question (with `provenance`), valid against the schema. - A library server accepts the media type, and finds questions by the tags of §5. From e2c13ed4311bfc1c52ea38909bceea4000083ac5 Mon Sep 17 00:00:00 2001 From: sawradip Date: Mon, 2 Nov 2026 02:33:55 +0600 Subject: [PATCH 14/15] =?UTF-8?q?docs(spec):=201.7=20=C2=A72.1=20states=20?= =?UTF-8?q?the=20limits=20the=20schema=20checks:=20kind=20pattern,=20year?= =?UTF-8?q?=20range,=20at=20most=20100=20entries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- spec/1.7/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/1.7/README.md b/spec/1.7/README.md index 1a2c110..1ba06b5 100644 --- a/spec/1.7/README.md +++ b/spec/1.7/README.md @@ -72,12 +72,12 @@ Where a question **appeared**: a board's exam, a school's or college's test pape | Field | | Meaning | |---|---|---| -| `kind` | required | `board_exam` (a board's public exam) or `institution_exam` (a school's or college's own test paper). Later versions may add kinds: lower-case letters, digits and `_` | +| `kind` | required | `board_exam` (a board's public exam) or `institution_exam` (a school's or college's own test paper). Later versions may add kinds: a lower-case letter, then up to 39 lower-case letters, digits and `_` | | `code` | required | The tag: 2 to 8 of `A`–`Z` and `0`–`9`, such as `DHA` or `VNSC`. A publisher gives each board or school one code and keeps it | | `name` | required | The full name, as text (§4, without Markdown): a string, or one per language | -| `year` | | The year of the paper, an integer; `null` (or no `year`) when it is not known | +| `year` | | The year of the paper, an integer from 1900 to 2200; `null` (or no `year`) when it is not known | -- One entry per paper: the same `kind`, `code` and `year` appear once. A question set by two boards in the same year has two entries. +- At most 100 entries. One entry per paper: the same `kind`, `code` and `year` appear once. A question set by two boards in the same year has two entries. - A writer SHOULD list the newest year first, and entries without a year last. A reader may sort them again. - A reader MUST NOT reject a manifest because of a `kind` it does not know: it shows the `code`, with the `name` on hover. - `provenance` is not `source`. `source` is the book the publisher copied the question from, and it may be left out; a paper in From 4f1e36acba3efecd003cdc4158614f76e51df765 Mon Sep 17 00:00:00 2001 From: sawradip Date: Mon, 2 Nov 2026 13:47:52 +0600 Subject: [PATCH 15/15] chore(release): 0.1.5 Co-Authored-By: Claude Opus 5.5 --- release.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release.json b/release.json index 949ca86..23efdfb 100644 --- a/release.json +++ b/release.json @@ -1,3 +1,3 @@ { - "version": "0.1.4" + "version": "0.1.5" }