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/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/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/packages/reader/src/ChalkReader.tsx b/packages/reader/src/ChalkReader.tsx index 26afb27..1536072 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, 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'; @@ -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,35 @@ 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 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) return null; + const has = (at: { pageId: string; id: string }, sig?: string) => !!pages.find((p) => p.id === at.pageId)?.objects.some((o) => o.id === at.id && (sig === undefined || sigOf(o) === sig)); + return has(selState) ? selState : (follow(selState, moves.current, has) ?? selState); + }, [selState, pages]); + // what the selection followed becomes the selection, so a later undo starts from where the object really is (not from a place another + // object with the same id may come back to) + useEffect(() => { + if (!sel || !selState || (sel.pageId === selState.pageId && sel.id === selState.id)) return; + setSel(sel); + if (sel.pageId === selState.pageId) return; + // an undo or redo took the object to another page: the view goes there too (in the single-page layout that page is not drawn otherwise, + // and its keys, Delete and the arrows, belong to its surface) + const i = pages.findIndex((p) => p.id === sel.pageId), o = pages[i]?.objects.find((x) => x.id === sel.id); + if (o) { const b = boxOf(o); viewer.current?.reveal(i, b.x + b.width / 2, b.y); } + }, [sel, selState, pages]); + const recordMove = useCallback((from: { pageId: string; id: string }, to: { pageId: string; id: string }, obj: LessonObject) => { + moves.current = [...moves.current.slice(-199), { from, to, sig: sigOf(obj), line: lineFor(moves.current, from) }]; + setSel(to); + }, []); + // the drag in progress, shared by every page (an object can be carried from one page onto another) + const drag = useRef(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); const root = useRef(null); const narrow = () => (root.current?.clientWidth ?? 1000) < 720; @@ -344,6 +374,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)); + recordMove({ pageId: from.id, id: o.id }, { pageId: to.id, id: there.id }, there); + viewer.current?.reveal(i, b.x + b.width / 2, b.y); + }, [sel, auLesson, auApply, recordMove]); useEffect(() => { @@ -368,6 +410,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 +793,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) => 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]); + ), [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) @@ -823,7 +872,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/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/AuthorSurface.tsx b/packages/reader/src/author/AuthorSurface.tsx index 611b02e..0cdc893 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,57 @@ 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 function AuthorSurface({ page, scale, selected, onSelect, apply, onDropFiles, labels }: { +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, onCarried, apply, onDropFiles, labels, drag, pageOf }: { 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 }, obj: LessonObject) => 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 +102,110 @@ 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 }; + 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); }; + + /** + * 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); + 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 */ } + }; + + /** 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; + // 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; 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: { pointerId: number }) => { + 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/author/move.test.ts b/packages/reader/src/author/move.test.ts new file mode 100644 index 0000000..64d2502 --- /dev/null +++ b/packages/reader/src/author/move.test.ts @@ -0,0 +1,175 @@ +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, 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; +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 }); + }); +}); + +describe('the selection follows a moved object', () => { + const at = (pageId: string, id: string): At => ({ pageId, id }); + // 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 = 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(); + }); + + 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 = 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(); + 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('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 = 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')); + objects.delete('p1 x'); + 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('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')); + 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 = 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 = 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 new file mode 100644 index 0000000..d7eadff --- /dev/null +++ b/packages/reader/src/author/move.ts @@ -0,0 +1,102 @@ +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; +} + +/** 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; `sig` is what it was (`sigOf`), to know it again; `line` + * names the object's run of moves (a move from where an earlier one ended goes on its line, whatever was changed on the object between). + */ +export type Moved = { from: At; to: At; sig: string; line: number }; + +/** The line a new move from `from` goes on: that of the newest move that ended there, else a new one. */ +export function lineFor(moves: readonly Moved[], from: At): number { + for (let i = moves.length - 1; i >= 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 { + const rest: Record = { ...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): + * 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}`; + 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.line === last.line); + let ring: At[] = [sel]; + while (ring.length) { + const next: At[] = []; + for (const cur of ring) { + 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) 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); + } + } + ring = next; + } + return null; +} 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); } 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" } 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..1ba06b5 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: 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 from 1900 to 2200; `null` (or no `year`) when it is not known | + +- 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 + `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. diff --git a/tools/reader-move-page-e2e.mjs b/tools/reader-move-page-e2e.mjs new file mode 100644 index 0000000..db25fdd --- /dev/null +++ b/tools/reader-move-page-e2e.mjs @@ -0,0 +1,225 @@ +// 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); + // 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 + 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)); + + // 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'); + 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);