From 547659ecd2e88fcc6499504c7117f04003a61f9a Mon Sep 17 00:00:00 2001 From: RedtRocks <56254751+RedtRocks@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:39:07 +0000 Subject: [PATCH 01/53] Add deck and slide to the glossary Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JXvo1FTmUxCdQr2fZL9uqr --- CONTEXT.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index ed47c39..00f3ca8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -5,12 +5,20 @@ A free, open-source reader and light editor for documents, images, 3D models and ## Documents **Document**: -One opened file, shown in its own tab. It is a *PDF document*, an *image document*, or a *model document*. +One opened file, shown in its own tab. It is a *PDF document*, an *image document*, a *model document*, or a *deck*. _Avoid_: File (on-disk only), tab (UI only) **Page**: One page of a PDF document or of a multi-page TIFF. Pages can be reordered, rotated, deleted, and dragged between documents. -_Avoid_: Slide, sheet +_Avoid_: Slide (that belongs to a deck), sheet + +**Deck**: +A document Glance creates and edits as a set of slides, which people or AI apps build, rearrange and restyle, and export as PowerPoint, PDF or images. A PowerPoint file that is only viewed is not a deck until it is converted. +_Avoid_: Presentation, slideshow, PPT + +**Slide**: +One part of a deck: a layout with its content filled in. Slides can be added, reordered, restyled, hidden and deleted. +_Avoid_: Page (that belongs to page-based documents), card **Frame**: One still image of an animated GIF. Frames are shown for inspection and export but can't be reordered or edited. From 247e7421bef0820a3aeac26fbf9386fbff9e329e Mon Sep 17 00:00:00 2001 From: RedtRocks <56254751+RedtRocks@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:47:44 +0000 Subject: [PATCH 02/53] Record the deck and document formats and Glance skills ADR 0018: decks and documents use Glance's own formats, Office and PDF are exports. ADR 0019: Glance gives its skills to whichever agent runs. Glossary gains Template, Theme, Brand Kit, Skill and Check. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JXvo1FTmUxCdQr2fZL9uqr --- CONTEXT.md | 22 +++++++++++++++++++ ...018-own-formats-for-decks-and-documents.md | 12 ++++++++++ docs/adr/0019-skills-given-to-any-agent.md | 10 +++++++++ docs/adr/README.md | 2 ++ 4 files changed, 46 insertions(+) create mode 100644 docs/adr/0018-own-formats-for-decks-and-documents.md create mode 100644 docs/adr/0019-skills-given-to-any-agent.md diff --git a/CONTEXT.md b/CONTEXT.md index 00f3ca8..4188880 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -66,6 +66,28 @@ A new image composed from several images arranged on a layout. **Quartz Filter**: An appearance filter applied at export time (Black & White, Sepia, Reduce File Size, and so on). +## Creating + +**Template**: +A finished deck or document, with sample content, that a person or an AI app starts from. +_Avoid_: Blank, starter, preset + +**Theme**: +The fonts, colours, sizes and spacing a deck or document uses. Switching theme restyles every slide at once. +_Avoid_: Style, skin, look (that means the app's own Wollo or Windows look) + +**Brand Kit**: +A person's own logo, colours, fonts and tone, which every template and theme can use. +_Avoid_: Brand, style guide + +**Skill**: +Know-how for one kind of work, such as slide design or writing a resume, that Glance gives to whichever AI is helping. +_Avoid_: Prompt, plugin + +**Check**: +An automatic review of a file, such as design, spelling or accessibility, that lists what to fix. +_Avoid_: Lint, validation + ## Inspection **Inspector**: diff --git a/docs/adr/0018-own-formats-for-decks-and-documents.md b/docs/adr/0018-own-formats-for-decks-and-documents.md new file mode 100644 index 0000000..8dbeb21 --- /dev/null +++ b/docs/adr/0018-own-formats-for-decks-and-documents.md @@ -0,0 +1,12 @@ +# Decks and documents Glance creates use its own formats, with Office and PDF as exports + +The maintainer wants AI apps and people to create slide decks and documents in Glance, rearrange and restyle them, and get better results than an agent writing a .pptx or .docx with a script. Agents writing Office files directly place boxes by coordinates and pick fonts and colours themselves, and PowerPoint's and Word's freeform layout gives them every way to get it wrong. + +So a deck (`.gdeck`) and a document (`.gdoc`) are Glance's own formats: a zip holding a JSON model (a theme, then slides or sections with stable ids, each a layout with filled slots) and a `media/` folder. Agents send content and intent; Glance's layouts and themes decide how it looks. Every change is an operation in `src/core/deck/` (pure, tested), shared by the MCP tools, the Keep and Undo card, and the person's own editing. PowerPoint, Word, PDF, PNG and HTML are exports. + +The other option was editing .pptx and .docx as the main format. It opens anywhere without an export step, but Glance could not keep layouts reliable or reflow a slide when its text changes. Spreadsheets are different: a grid maps cleanly onto .xlsx and .csv, so those are edited directly. + +## Consequences +- An existing PowerPoint or Word file gets safe operations (reorder, delete, duplicate, replace text) written to a new file, and Convert to make it a deck or document, which can lose layout detail and says so. +- Export fidelity is ours to keep: pptxgenjs and docx (both MIT) write the Office files; they are lazy loaded like the other engines. +- Each format has its own extension so Explorer can show its icon and "Open with". diff --git a/docs/adr/0019-skills-given-to-any-agent.md b/docs/adr/0019-skills-given-to-any-agent.md new file mode 100644 index 0000000..8454a2a --- /dev/null +++ b/docs/adr/0019-skills-given-to-any-agent.md @@ -0,0 +1,10 @@ +# Glance gives design and writing skills to whichever agent is running + +The maintainer wants Claude Code, Codex, Gemini and the other agents to make better files through Glance than on their own, the way design skills improve Claude's output. Writing that know-how into each company's own skill system would mean one copy per company, and agents without skills would get none. + +So Glance keeps its own skills: a folder with a `SKILL.md` (the same shape as Claude's Agent Skills, so they can move both ways) plus examples and templates, for one kind of work such as slide design, letters or resumes. Glance picks the skills for the file and the request and passes them to the agent through the MCP server's instructions, prompts and resources, so every agent gets the same guidance. Skills ship with the app, and people can add their own; installing shared skills from the web is not offered, because Glance fetches nothing (see PRIVACY.md). + +## Consequences +- The design rules live in Glance, so improving a skill improves every agent at once; a fixed set of test briefs scores each change. +- Settings > AI apps can also install the skills as a Claude Code skill and an AGENTS.md note, so they are there before the first tool call. +- Skills are guidance, not enforcement: the layouts and the design check keep a deck sound even when an agent ignores them. diff --git a/docs/adr/README.md b/docs/adr/README.md index a472f2d..1b6f36a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -19,6 +19,8 @@ Each file records one decision: the problem, what was chosen and why, and what f - [0015](0015-ai-edits-as-matching-markup.md): AI edits text as markup that matches the page - [0016](0016-anonymous-install-count.md): Count installs with one anonymous daily check-in - [0017](0017-linux-builds.md): Ship Linux as RPM and .deb packages +- [0018](0018-own-formats-for-decks-and-documents.md): Decks and documents Glance creates use its own formats, with Office and PDF as exports +- [0019](0019-skills-given-to-any-agent.md): Glance gives design and writing skills to whichever agent is running ## Writing one From 61a3ec0557741408f12078a5f320533aaa5cce9e Mon Sep 17 00:00:00 2001 From: RedtRocks <56254751+RedtRocks@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:11:48 +0000 Subject: [PATCH 03/53] Add the deck model, its operations and the design check A deck is a theme plus slides; a slide is one of 12 layouts with its slots filled in. applyOps applies a list of operations all or nothing (add, move, delete, duplicate, hide, setLayout, setSlot, clearSlot, setNotes, setTheme, split, merge) and checks values against the layout. checkDeck lists what to fix: missing title slide, missing or too-long slots, too many entries, three same layouts in a row, missing alt text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JXvo1FTmUxCdQr2fZL9uqr --- src/core/deck/check.ts | 76 +++++++++++++++ src/core/deck/model.ts | 85 ++++++++++++++++ src/core/deck/ops.ts | 216 +++++++++++++++++++++++++++++++++++++++++ tests/deck.test.ts | 115 ++++++++++++++++++++++ 4 files changed, 492 insertions(+) create mode 100644 src/core/deck/check.ts create mode 100644 src/core/deck/model.ts create mode 100644 src/core/deck/ops.ts create mode 100644 tests/deck.test.ts diff --git a/src/core/deck/check.ts b/src/core/deck/check.ts new file mode 100644 index 0000000..c479ea3 --- /dev/null +++ b/src/core/deck/check.ts @@ -0,0 +1,76 @@ +/** + * The design check (glance_deck_check): what an AI app has to fix before the person sees + * the deck. Each finding names the slide and says what to do, in words a model can act on. + */ +import { LAYOUTS, type Deck, type Slide, type SlotSpec } from './model' + +export type FindingCode = 'empty' | 'noTitleSlide' | 'missing' | 'overflow' | 'tooMany' | 'repeatLayout' | 'missingAlt' + +export interface Finding { + code: FindingCode + /** The slide it is about; absent for the deck as a whole. */ + id?: string + slot?: string + detail: string +} + +/** Three slides in a row with one layout reads as a wall; the third gets a finding. */ +const REPEAT_RUN = 3 + +export function checkDeck(deck: Deck): Finding[] { + const shown = deck.slides.filter((s) => !s.hidden) + if (!shown.length) return [{ code: 'empty', detail: 'The deck has no slides. Add a title slide first.' }] + const findings: Finding[] = [] + if (shown[0].layout !== 'title') { + findings.push({ code: 'noTitleSlide', id: shown[0].id, detail: 'Start with a title slide: add one with layout title before this slide.' }) + } + shown.forEach((slide, i) => { + findings.push(...checkSlide(slide)) + if (i >= REPEAT_RUN - 1 && shown.slice(i - REPEAT_RUN + 1, i + 1).every((s) => s.layout === slide.layout)) { + findings.push({ code: 'repeatLayout', id: slide.id, detail: `${REPEAT_RUN} ${slide.layout} slides in a row. Give this one another layout, such as threeCards, bigNumber or imageRight.` }) + } + }) + return findings +} + +function checkSlide(slide: Slide): Finding[] { + const out: Finding[] = [] + for (const spec of LAYOUTS[slide.layout] as SlotSpec[]) { + const value = slide.slots[spec.name] + const at = { id: slide.id, slot: spec.name } + if (!value) { + if (spec.required) out.push({ ...at, code: 'missing', detail: `Fill ${spec.name}: the ${slide.layout} layout needs it.` }) + continue + } + const long = (text: string) => spec.maxChars !== undefined && text.length > spec.maxChars + const tooLong = (what: string) => `${what} is too long for ${slide.layout}: keep it under ${spec.maxChars} characters, or split the slide.` + switch (value.kind) { + case 'text': + if (!value.value.trim() && spec.required) out.push({ ...at, code: 'missing', detail: `Fill ${spec.name}: the ${slide.layout} layout needs it.` }) + else if (long(value.value)) out.push({ ...at, code: 'overflow', detail: tooLong(spec.name) }) + break + case 'list': + value.value.forEach((entry, n) => { + if (long(entry)) out.push({ ...at, code: 'overflow', detail: tooLong(`${spec.name} entry ${n + 1}`) }) + }) + break + case 'items': + value.value.forEach((item, n) => { + if (long(item.heading + item.body)) out.push({ ...at, code: 'overflow', detail: tooLong(`${spec.name} entry ${n + 1}`) }) + }) + break + case 'table': + value.value.forEach((row, r) => row.forEach((cell, c) => { + if (long(cell)) out.push({ ...at, code: 'overflow', detail: tooLong(`${spec.name} row ${r + 1}, column ${c + 1}`) }) + })) + break + case 'image': + if (!value.value.alt.trim()) out.push({ ...at, code: 'missingAlt', detail: `Describe the picture in ${spec.name} (alt) for people who can't see it.` }) + break + } + if ((value.kind === 'list' || value.kind === 'items' || value.kind === 'table') && spec.maxItems !== undefined && value.value.length > spec.maxItems) { + out.push({ ...at, code: 'tooMany', detail: `${spec.name} has ${value.value.length} entries; ${slide.layout} fits ${spec.maxItems}. Split the slide or cut entries.` }) + } + } + return out +} diff --git a/src/core/deck/model.ts b/src/core/deck/model.ts new file mode 100644 index 0000000..2bc36ff --- /dev/null +++ b/src/core/deck/model.ts @@ -0,0 +1,85 @@ +/** + * Decks (ADR 0018): a deck is a theme and a list of slides; a slide is a layout with its + * slots filled in. Agents and people say what goes in a slot, the layout and theme decide + * how it looks, so nothing here holds a position, a font or a colour. + * + * Pure data, saved as deck.json inside a .gdeck file. + */ + +export const DECK_VERSION = 1 + +export type SlotKind = 'text' | 'list' | 'image' | 'items' | 'table' + +export interface ImageRef { + /** A path inside the deck's media/ folder. */ + src: string + alt: string + /** Where the subject is, 0 to 1 from the top left, so crops keep it in view. */ + focus?: { x: number; y: number } +} + +/** A short heading with a line under it: one card of a three-card row, a timeline step. */ +export interface Item { + heading: string + body: string +} + +export type SlotValue = + | { kind: 'text'; value: string } + | { kind: 'list'; value: string[] } + | { kind: 'image'; value: ImageRef } + | { kind: 'items'; value: Item[] } + | { kind: 'table'; value: string[][] } + +export interface Slide { + /** Stable across edits, so "move s7 after s2" still means the same slides later. */ + id: string + layout: LayoutId + slots: Record + notes: string + hidden: boolean +} + +export interface Deck { + version: number + theme: string + slides: Slide[] +} + +export interface SlotSpec { + name: string + kind: SlotKind + required: boolean + /** Characters that fit before the text would have to shrink, for text slots and each list entry. */ + maxChars?: number + /** Entries that fit, for list, items and table (rows) slots. */ + maxItems?: number +} + +const slot = (name: string, kind: SlotKind, required: boolean, maxChars?: number, maxItems?: number): SlotSpec => ({ name, kind, required, maxChars, maxItems }) + +/** The layouts, in the order a layout picker shows them. */ +export const LAYOUTS = { + title: [slot('title', 'text', true, 60), slot('subtitle', 'text', false, 120), slot('image', 'image', false)], + section: [slot('title', 'text', true, 50), slot('subtitle', 'text', false, 100)], + bullets: [slot('title', 'text', true, 60), slot('bullets', 'list', true, 70, 6)], + twoColumns: [slot('title', 'text', true, 60), slot('left', 'list', true, 60, 5), slot('right', 'list', true, 60, 5)], + imageLeft: [slot('title', 'text', true, 60), slot('image', 'image', true), slot('body', 'text', false, 280)], + imageRight: [slot('title', 'text', true, 60), slot('image', 'image', true), slot('body', 'text', false, 280)], + fullImage: [slot('image', 'image', true), slot('caption', 'text', false, 90)], + bigNumber: [slot('stat', 'text', true, 8), slot('statLabel', 'text', true, 60), slot('body', 'text', false, 160)], + threeCards: [slot('title', 'text', true, 60), slot('items', 'items', true, 80, 3)], + quote: [slot('quote', 'text', true, 220), slot('attribution', 'text', false, 60)], + comparison: [slot('title', 'text', true, 60), slot('table', 'table', true, 40, 7)], + timeline: [slot('title', 'text', true, 60), slot('items', 'items', true, 60, 6)] +} satisfies Record + +export type LayoutId = keyof typeof LAYOUTS + +export const layoutIds = Object.keys(LAYOUTS) as LayoutId[] + +export const isLayout = (id: string): id is LayoutId => Object.hasOwn(LAYOUTS, id) + +export function emptyDeck(theme = 'wollo'): Deck { + return { version: DECK_VERSION, theme, slides: [] } +} diff --git a/src/core/deck/ops.ts b/src/core/deck/ops.ts new file mode 100644 index 0000000..4da5435 --- /dev/null +++ b/src/core/deck/ops.ts @@ -0,0 +1,216 @@ +/** + * Every change to a deck is one of these operations, whoever makes it: an AI app through + * glance_deck_edit, the Ask AI sidebar, or the person dragging a slide. A list of them is + * applied all or nothing, so one AI turn is one undo step. + */ +import { LAYOUTS, isLayout, type Deck, type LayoutId, type Slide, type SlotKind, type SlotSpec, type SlotValue } from './model' + +export type DeckOp = + /** A new slide, at the end unless `after` names a slide (null puts it first). */ + | { op: 'add'; layout: LayoutId; slots?: Record; notes?: string; after?: string | null } + /** Moves slides, keeping their order, to just after `after` (null: to the start). */ + | { op: 'move'; ids: string[]; after: string | null } + | { op: 'delete'; ids: string[] } + | { op: 'duplicate'; id: string } + | { op: 'hide'; ids: string[]; hidden: boolean } + /** Keeps the slots the new layout also has; the others are reported as dropped. */ + | { op: 'setLayout'; id: string; layout: LayoutId } + | { op: 'setSlot'; id: string; slot: string; value: unknown } + | { op: 'clearSlot'; id: string; slot: string } + | { op: 'setNotes'; id: string; notes: string } + | { op: 'setTheme'; theme: string } + /** Moves the second half of the slide's list onto a new slide after it, same layout and title. */ + | { op: 'split'; id: string } + /** Appends the second slide's list to the first's and removes the second. Same layout only. */ + | { op: 'merge'; ids: [string, string] } + +export class DeckError extends Error {} + +export interface ApplyResult { + deck: Deck + /** Ids of the slides the operations made, in the order they were made. */ + added: string[] + /** Content a layout change left out, so the caller can say so or put it elsewhere. */ + dropped: { id: string; slots: string[] }[] +} + +/** Applies `ops` in order to a copy of `deck`. Throws DeckError, changing nothing, if any op is invalid. */ +export function applyOps(deck: Deck, ops: DeckOp[], newId: () => string): ApplyResult { + const next = structuredClone(deck) + const added: string[] = [] + const dropped: ApplyResult['dropped'] = [] + const fresh = (): string => { + let id = newId() + while (next.slides.some((s) => s.id === id)) id = newId() + added.push(id) + return id + } + ops.forEach((op, i) => { + try { + apply(next, op, fresh, dropped) + } catch (e) { + if (e instanceof DeckError) throw new DeckError(`Operation ${i + 1} (${op.op}): ${e.message}`) + throw e + } + }) + return { deck: next, added, dropped } +} + +function apply(deck: Deck, op: DeckOp, fresh: () => string, dropped: ApplyResult['dropped']): void { + const slides = deck.slides + switch (op.op) { + case 'add': { + const slide: Slide = { id: fresh(), layout: layoutOf(op.layout), slots: {}, notes: op.notes ?? '', hidden: false } + for (const [name, value] of Object.entries(op.slots ?? {})) slide.slots[name] = slotValue(slide.layout, name, value) + slides.splice(op.after === undefined ? slides.length : op.after === null ? 0 : index(deck, op.after) + 1, 0, slide) + return + } + case 'move': { + const moving = unique(op.ids).map((id) => slides[index(deck, id)]) + if (op.after !== null && op.ids.includes(op.after)) throw new DeckError(`can't move ${op.after} after itself`) + const rest = slides.filter((s) => !moving.includes(s)) + const at = op.after === null ? 0 : rest.findIndex((s) => s.id === op.after) + 1 + if (op.after !== null && at === 0) throw new DeckError(`no slide ${op.after}`) + // Keep the moved slides in deck order, whatever order the ids came in. + moving.sort((a, b) => slides.indexOf(a) - slides.indexOf(b)) + rest.splice(at, 0, ...moving) + deck.slides = rest + return + } + case 'delete': { + const gone = new Set(unique(op.ids).map((id) => slides[index(deck, id)].id)) + deck.slides = slides.filter((s) => !gone.has(s.id)) + return + } + case 'duplicate': { + const i = index(deck, op.id) + slides.splice(i + 1, 0, { ...structuredClone(slides[i]), id: fresh() }) + return + } + case 'hide': + for (const id of unique(op.ids)) slides[index(deck, id)].hidden = op.hidden + return + case 'setLayout': { + const slide = slides[index(deck, op.id)] + const layout = layoutOf(op.layout) + const lost: string[] = [] + for (const [name, value] of Object.entries(slide.slots)) { + const spec = specOf(layout, name) + if (!spec || spec.kind !== value.kind) { + lost.push(name) + delete slide.slots[name] + } + } + slide.layout = layout + if (lost.length) dropped.push({ id: slide.id, slots: lost }) + return + } + case 'setSlot': { + const slide = slides[index(deck, op.id)] + slide.slots[op.slot] = slotValue(slide.layout, op.slot, op.value) + return + } + case 'clearSlot': { + const slide = slides[index(deck, op.id)] + if (!specOf(slide.layout, op.slot)) throw new DeckError(`layout ${slide.layout} has no slot ${op.slot}`) + delete slide.slots[op.slot] + return + } + case 'setNotes': + if (typeof op.notes !== 'string') throw new DeckError('notes must be text') + slides[index(deck, op.id)].notes = op.notes + return + case 'setTheme': + if (typeof op.theme !== 'string' || !op.theme) throw new DeckError('theme must be a theme id') + deck.theme = op.theme + return + case 'split': { + const i = index(deck, op.id) + const slide = slides[i] + const name = listSlot(slide) + const value = slide.slots[name] as Extract + if (value.value.length < 2) throw new DeckError(`${slide.id} has fewer than 2 entries in ${name}`) + const half = Math.ceil(value.value.length / 2) + const second = structuredClone(slide) + second.id = fresh() + ;(second.slots[name] as typeof value).value = value.value.slice(half) as never + value.value = value.value.slice(0, half) as never + slides.splice(i + 1, 0, second) + return + } + case 'merge': { + const [a, b] = op.ids.map((id) => slides[index(deck, id)]) + if (a === b) throw new DeckError('merge needs two different slides') + if (a.layout !== b.layout) throw new DeckError(`${a.id} is ${a.layout} and ${b.id} is ${b.layout}; merge needs the same layout`) + const name = listSlot(a) + const into = a.slots[name] as Extract + const from = b.slots[name] as typeof into | undefined + if (from) into.value = [...into.value, ...from.value] as never + deck.slides = slides.filter((s) => s !== b) + return + } + default: + throw new DeckError(`unknown operation ${(op as { op: unknown }).op}`) + } +} + +function index(deck: Deck, id: string): number { + const i = deck.slides.findIndex((s) => s.id === id) + if (i < 0) throw new DeckError(`no slide ${id}`) + return i +} + +function unique(ids: string[]): string[] { + if (!Array.isArray(ids) || !ids.length) throw new DeckError('ids must list at least one slide') + return [...new Set(ids)] +} + +function layoutOf(id: string): LayoutId { + if (!isLayout(id)) throw new DeckError(`no layout ${id}; layouts are ${Object.keys(LAYOUTS).join(', ')}`) + return id +} + +function specOf(layout: LayoutId, name: string): SlotSpec | undefined { + return (LAYOUTS[layout] as SlotSpec[]).find((s) => s.name === name) +} + +/** The slot split and merge work on: the layout's list or items slot that has content. */ +function listSlot(slide: Slide): string { + const spec = (LAYOUTS[slide.layout] as SlotSpec[]).find((s) => (s.kind === 'list' || s.kind === 'items') && slide.slots[s.name]) + if (!spec) throw new DeckError(`${slide.id} has no list to split or merge`) + return spec.name +} + +const isText = (v: unknown): v is string => typeof v === 'string' + +/** Checks a value against the slot's kind, so a bad AI call fails with a reason instead of saving junk. */ +function slotValue(layout: LayoutId, name: string, value: unknown): SlotValue { + const spec = specOf(layout, name) + if (!spec) throw new DeckError(`layout ${layout} has no slot ${name}; it has ${(LAYOUTS[layout] as SlotSpec[]).map((s) => s.name).join(', ')}`) + const bad = (shape: string) => new DeckError(`slot ${name} takes ${shape}`) + const kind: SlotKind = spec.kind + switch (kind) { + case 'text': + if (!isText(value)) throw bad('text') + return { kind, value } + case 'list': + if (!Array.isArray(value) || !value.every(isText)) throw bad('a list of text') + return { kind, value: [...value] } + case 'image': { + const v = value as { src?: unknown; alt?: unknown; focus?: { x?: unknown; y?: unknown } } + if (!v || !isText(v.src) || !v.src) throw bad('an image: { src, alt }') + const focus = v.focus && typeof v.focus.x === 'number' && typeof v.focus.y === 'number' + ? { x: clamp01(v.focus.x), y: clamp01(v.focus.y) } + : undefined + return { kind, value: { src: v.src, alt: isText(v.alt) ? v.alt : '', ...(focus ? { focus } : {}) } } + } + case 'items': + if (!Array.isArray(value) || !value.every((it) => it && isText(it.heading) && isText(it.body ?? ''))) throw bad('a list of { heading, body }') + return { kind, value: value.map((it) => ({ heading: it.heading, body: it.body ?? '' })) } + case 'table': + if (!Array.isArray(value) || !value.every((row) => Array.isArray(row) && row.every(isText))) throw bad('rows of text cells') + return { kind, value: value.map((row: string[]) => [...row]) } + } +} + +const clamp01 = (n: number) => Math.min(1, Math.max(0, n)) diff --git a/tests/deck.test.ts b/tests/deck.test.ts new file mode 100644 index 0000000..cf5db48 --- /dev/null +++ b/tests/deck.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' +import { emptyDeck, type Deck } from '../src/core/deck/model' +import { applyOps, DeckError, type DeckOp } from '../src/core/deck/ops' +import { checkDeck } from '../src/core/deck/check' + +function ids() { + let n = 0 + return () => `s${++n}` +} + +/** A deck of `n` bullets slides s1..sn, titled by their id. */ +function deckOf(n: number): Deck { + const ops: DeckOp[] = Array.from({ length: n }, (_, i) => ({ op: 'add', layout: 'bullets', slots: { title: `s${i + 1}`, bullets: ['a'] } })) + return applyOps(emptyDeck(), ops, ids()).deck +} + +const order = (deck: Deck) => deck.slides.map((s) => s.id) + +describe('applyOps', () => { + it('adds slides at the end, first, or after a slide', () => { + const next = ids() + let deck = applyOps(emptyDeck(), [{ op: 'add', layout: 'title', slots: { title: 'Bake Sale' } }], next).deck + deck = applyOps(deck, [{ op: 'add', layout: 'quote', slots: { quote: 'Yum' } }], next).deck + deck = applyOps(deck, [{ op: 'add', layout: 'section', slots: { title: 'Why' }, after: null }], next).deck + const r = applyOps(deck, [{ op: 'add', layout: 'bullets', after: 's1' }], next) + expect(order(r.deck)).toEqual(['s3', 's1', 's4', 's2']) + expect(r.added).toEqual(['s4']) + }) + + it('moves slides, keeping their deck order whatever order the ids come in', () => { + const deck = deckOf(5) + expect(order(applyOps(deck, [{ op: 'move', ids: ['s4', 's2'], after: 's5' }], ids()).deck)).toEqual(['s1', 's3', 's5', 's2', 's4']) + expect(order(applyOps(deck, [{ op: 'move', ids: ['s3'], after: null }], ids()).deck)).toEqual(['s3', 's1', 's2', 's4', 's5']) + }) + + it('refuses to move a slide after itself', () => { + expect(() => applyOps(deckOf(3), [{ op: 'move', ids: ['s2'], after: 's2' }], ids())).toThrow(DeckError) + }) + + it('deletes, duplicates and hides', () => { + const next = ids() + const base = applyOps(emptyDeck(), [{ op: 'add', layout: 'bullets', slots: { title: 'A', bullets: ['x'] } }, { op: 'add', layout: 'quote', slots: { quote: 'Q' } }], next).deck + const r = applyOps(base, [{ op: 'duplicate', id: 's1' }, { op: 'delete', ids: ['s2'] }, { op: 'hide', ids: ['s1'], hidden: true }], next) + expect(order(r.deck)).toEqual(['s1', 's3']) + expect(r.deck.slides[1].slots.title).toEqual({ kind: 'text', value: 'A' }) + expect(r.deck.slides[0].hidden).toBe(true) + }) + + it('is all or nothing: a bad op leaves the deck as it was', () => { + const deck = deckOf(2) + const before = structuredClone(deck) + expect(() => applyOps(deck, [{ op: 'delete', ids: ['s1'] }, { op: 'delete', ids: ['s9'] }], ids())).toThrow(/Operation 2 \(delete\): no slide s9/) + expect(deck).toEqual(before) + }) + + it('checks slot values against the layout', () => { + const deck = deckOf(1) + expect(() => applyOps(deck, [{ op: 'setSlot', id: 's1', slot: 'bullets', value: 'not a list' }], ids())).toThrow(/takes a list of text/) + expect(() => applyOps(deck, [{ op: 'setSlot', id: 's1', slot: 'image', value: { src: 'a.png' } }], ids())).toThrow(/has no slot image/) + expect(() => applyOps(deck, [{ op: 'add', layout: 'poster' as never }], ids())).toThrow(/no layout poster/) + }) + + it('keeps matching slots on a layout change and reports the rest', () => { + const r = applyOps(deckOf(1), [{ op: 'setLayout', id: 's1', layout: 'section' }], ids()) + expect(r.deck.slides[0]).toMatchObject({ layout: 'section', slots: { title: { kind: 'text', value: 's1' } } }) + expect(r.dropped).toEqual([{ id: 's1', slots: ['bullets'] }]) + }) + + it('splits a list onto a new slide and merges it back', () => { + const next = ids() + const base = applyOps(emptyDeck(), [{ op: 'add', layout: 'bullets', slots: { title: 'T', bullets: ['1', '2', '3', '4', '5'] } }], next).deck + const split = applyOps(base, [{ op: 'split', id: 's1' }], next) + expect(split.deck.slides.map((s) => s.slots.bullets.value)).toEqual([['1', '2', '3'], ['4', '5']]) + const merged = applyOps(split.deck, [{ op: 'merge', ids: ['s1', split.added[0]] }], next) + expect(merged.deck.slides).toHaveLength(1) + expect(merged.deck.slides[0].slots.bullets.value).toEqual(['1', '2', '3', '4', '5']) + }) + + it('clamps an image focus point into the picture', () => { + const r = applyOps(emptyDeck(), [{ op: 'add', layout: 'fullImage', slots: { image: { src: 'media/cake.jpg', alt: 'A cake', focus: { x: 1.4, y: -1 } } } }], ids()) + expect(r.deck.slides[0].slots.image.value).toEqual({ src: 'media/cake.jpg', alt: 'A cake', focus: { x: 1, y: 0 } }) + }) +}) + +describe('checkDeck', () => { + it('asks for a title slide first and flags three same layouts in a row', () => { + const codes = checkDeck(deckOf(3)).map((f) => [f.code, f.id]) + expect(codes).toEqual([['noTitleSlide', 's1'], ['repeatLayout', 's3']]) + }) + + it('flags missing, too long and too many', () => { + const deck = applyOps(emptyDeck(), [ + { op: 'add', layout: 'title' }, + { op: 'add', layout: 'bullets', slots: { title: 'x'.repeat(61), bullets: Array(7).fill('ok') } }, + { op: 'add', layout: 'imageLeft', slots: { title: 'Cake', image: { src: 'media/c.jpg', alt: '' } } } + ], ids()).deck + expect(checkDeck(deck).map((f) => [f.code, f.id, f.slot])).toEqual([ + ['missing', 's1', 'title'], + ['overflow', 's2', 'title'], + ['tooMany', 's2', 'bullets'], + ['missingAlt', 's3', 'image'] + ]) + }) + + it('skips hidden slides and passes a clean deck', () => { + const deck = applyOps(emptyDeck(), [ + { op: 'add', layout: 'title', slots: { title: 'Bake Sale' } }, + { op: 'add', layout: 'threeCards', slots: { title: 'Three Ways to Help', items: [{ heading: 'Bake', body: 'Anything sweet' }, { heading: 'Bring', body: 'A friend' }, { heading: 'Buy', body: 'A treat' }] } }, + { op: 'add', layout: 'bullets' } + ], ids()).deck + deck.slides[2].hidden = true + expect(checkDeck(deck)).toEqual([]) + expect(checkDeck(emptyDeck()).map((f) => f.code)).toEqual(['empty']) + }) +}) From fb70ea672d384ba101215a37e6cf92f363704193 Mon Sep 17 00:00:00 2001 From: RedtRocks <56254751+RedtRocks@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:18:04 +0000 Subject: [PATCH 04/53] Lay out and draw slides with the Wollo theme Each layout turns a slide's slots into boxes on a 1920 by 1080 slide. The HTML view draws them, and the PowerPoint export will draw the same boxes. Text is fitted by estimating how it wraps, so layout stays pure and testable. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JXvo1FTmUxCdQr2fZL9uqr --- src/core/deck/html.ts | 73 ++++++++++++ src/core/deck/layout.ts | 230 ++++++++++++++++++++++++++++++++++++++ src/core/deck/themes.ts | 64 +++++++++++ tests/deck-layout.test.ts | 99 ++++++++++++++++ 4 files changed, 466 insertions(+) create mode 100644 src/core/deck/html.ts create mode 100644 src/core/deck/layout.ts create mode 100644 src/core/deck/themes.ts create mode 100644 tests/deck-layout.test.ts diff --git a/src/core/deck/html.ts b/src/core/deck/html.ts new file mode 100644 index 0000000..f6a812d --- /dev/null +++ b/src/core/deck/html.ts @@ -0,0 +1,73 @@ +/** + * Draws a slide's layout boxes as HTML: the editor's slides, the slide sorter's + * thumbnails, presenting, and the HTML export all use this. Sizes are in slide units + * (1920 by 1080); the caller scales the slide to fit with a CSS transform. + * + * Text and picture paths come from the deck, which an AI app may have written, so all of + * it is escaped, and pictures only show when `resolveSrc` maps them to a URL. + */ +import { SLIDE_H, SLIDE_W, layoutSlide, type Box, type SlotRef } from './layout' +import { type Slide } from './model' +import { type DeckTheme } from './themes' + +export interface HtmlOptions { + /** The URL to show a deck media path with, or null when the deck has no such file. */ + resolveSrc: (src: string) => string | null + /** Hint text for empty slots; leave it out to draw nothing there (exports). */ + placeholder?: (ref: SlotRef) => string +} + +const ESC: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } +export const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => ESC[c]) + +const px = (n: number) => `${Math.round(n * 100) / 100}px` + +/** The `data-ref` the editor finds a box's slot by: slot, then index, part and column if any. */ +export function refKey(ref: SlotRef): string { + return [ref.slot, ref.index, ref.part, ref.col].filter((v) => v !== undefined).join('.') +} + +export function slideHtml(slide: Slide, theme: DeckTheme, opts: HtmlOptions): string { + const boxes = layoutSlide(slide, theme).map((b) => boxHtml(b, theme, opts)).join('') + const style = `position:relative;overflow:hidden;width:${SLIDE_W}px;height:${SLIDE_H}px;background:${theme.bg};color:${theme.text};font-family:${theme.bodyFont}` + return `
${boxes}
` +} + +function boxHtml(box: Box, theme: DeckTheme, opts: HtmlOptions): string { + const at = `position:absolute;left:${px(box.x)};top:${px(box.y)};width:${px(box.w)};height:${px(box.h)};` + switch (box.type) { + case 'rect': + return `
` + case 'image': { + const key = escapeHtml(refKey(box.ref)) + const url = box.image ? opts.resolveSrc(box.image.src) : null + const round = `border-radius:${px(box.radius)};overflow:hidden;` + if (!url || !box.image) { + const hint = opts.placeholder ? `${escapeHtml(opts.placeholder(box.ref))}` : '' + const look = `${at}${round}display:flex;align-items:center;justify-content:center;background:${theme.surface};color:${theme.muted};font-size:32px` + return `
${hint}
` + } + const f = box.image.focus ?? { x: 0.5, y: 0.5 } + const img = `width:100%;height:100%;object-fit:cover;object-position:${Math.round(f.x * 100)}% ${Math.round(f.y * 100)}%;display:block` + return `
${escapeHtml(box.image.alt)}
` + } + case 'text': { + if (box.placeholder && !opts.placeholder) return '' + const justify = { top: 'flex-start', middle: 'center', bottom: 'flex-end' }[box.valign] + const font = box.font === 'heading' ? `font-family:${theme.headingFont};font-weight:${theme.headingWeight};` : 'font-weight:400;' + const look = `${at}display:flex;flex-direction:column;justify-content:${justify};text-align:${box.align};font-size:${px(box.size)};line-height:${box.lineHeight};color:${box.colour};${font}gap:0.3em;overflow-wrap:anywhere` + const key = escapeHtml(refKey(box.ref)) + const cls = `deck-box deck-text${box.placeholder ? ' empty' : ''}` + if (box.placeholder) { + return `

${escapeHtml(opts.placeholder!(box.ref))}

` + } + if (box.bullets) { + const dot = `position:absolute;left:0;top:${box.lineHeight / 2}em;width:0.32em;height:0.32em;margin-top:-0.16em;border-radius:50%;background:${theme.accent}` + const items = box.paragraphs.map((p) => `
  • ${escapeHtml(p)}
  • `).join('') + return `
      ${items}
    ` + } + const paras = box.paragraphs.map((p) => `

    ${escapeHtml(p)}

    `).join('') + return `
    ${paras}
    ` + } + } +} diff --git a/src/core/deck/layout.ts b/src/core/deck/layout.ts new file mode 100644 index 0000000..b8aae04 --- /dev/null +++ b/src/core/deck/layout.ts @@ -0,0 +1,230 @@ +/** + * Where everything on a slide goes. A layout turns a slide's slots into boxes on a + * 1920 by 1080 slide, and both the HTML view and the PowerPoint export draw those same + * boxes, so an exported deck looks like the one in Glance. + * + * Text is fitted without a DOM: an estimate of how many lines it wraps to at each size, + * shrinking until it fits. That keeps layout pure and testable, and the check + * (check.ts) keeps text short enough that the estimate rarely has to work hard. + */ +import { type ImageRef, type Item, type Slide, type SlotValue } from './model' +import { type DeckTheme } from './themes' + +export const SLIDE_W = 1920 +export const SLIDE_H = 1080 +const M = 120 + +/** Which slot (and which entry of it) a box shows, so the editor can edit it in place. */ +export interface SlotRef { + slot: string + index?: number + part?: 'heading' | 'body' + col?: number +} + +export type Box = + | { type: 'rect'; x: number; y: number; w: number; h: number; fill: string; radius: number } + | { + type: 'text' + x: number + y: number + w: number + h: number + ref: SlotRef + paragraphs: string[] + /** Font size in slide units. */ + size: number + lineHeight: number + colour: string + font: 'heading' | 'body' + align: 'left' | 'center' + valign: 'top' | 'middle' | 'bottom' + bullets: boolean + /** The slot is empty; the editor shows a hint here, exports leave it out. */ + placeholder: boolean + } + | { type: 'image'; x: number; y: number; w: number; h: number; ref: SlotRef; image: ImageRef | null; radius: number } + +export type TextBox = Extract + +/** Average glyph width as a share of the font size; Urbanist runs a little narrow. */ +const CHAR_W = 0.5 + +/** Lines `paragraphs` wrap to in a box `w` wide at `size`, wrapping at spaces like a browser. */ +export function wrappedLines(paragraphs: string[], w: number, size: number, indent = 0): number { + const perLine = Math.max(1, Math.floor((w - indent) / (size * CHAR_W))) + let lines = 0 + for (const p of paragraphs) { + let line = 0 + lines++ + for (const word of p.split(/\s+/).filter(Boolean)) { + if (!line) line = word.length + else if (line + 1 + word.length <= perLine) line += 1 + word.length + else { + lines++ + line = word.length + } + // A word longer than a line breaks across lines. + if (line > perLine) { + lines += Math.ceil(line / perLine) - 1 + line = line % perLine || perLine + } + } + } + return lines +} + +/** The largest size from `max` down to `min` at which the text fits the box. */ +export function fitSize(paragraphs: string[], w: number, h: number, max: number, min: number, lineHeight: number, gap = 0, bullets = false): number { + for (let size = max; size > min; size -= 2) { + const lines = wrappedLines(paragraphs, w, size, bullets ? size * 1.1 : 0) + if (lines * size * lineHeight + Math.max(0, paragraphs.length - 1) * gap * size <= h) return size + } + return min +} + +interface TextOpts { + max: number + min?: number + colour?: string + font?: 'heading' | 'body' + align?: 'left' | 'center' + valign?: 'top' | 'middle' | 'bottom' + bullets?: boolean + lineHeight?: number +} + +/** Builds the layout boxes for one slide. */ +export function layoutSlide(slide: Slide, theme: DeckTheme): Box[] { + const s = slide.slots + const boxes: Box[] = [] + const text = (ref: SlotRef, paragraphs: string[], x: number, y: number, w: number, h: number, o: TextOpts) => { + const lineHeight = o.lineHeight ?? (o.font === 'heading' ? 1.1 : 1.3) + const placeholder = !paragraphs.some((p) => p.trim()) + const size = placeholder ? o.max : fitSize(paragraphs, w, h, o.max, o.min ?? Math.round(o.max * 0.55), lineHeight, o.bullets ? 0.5 : 0.3, o.bullets) + boxes.push({ + type: 'text', x, y, w, h, ref, paragraphs: placeholder ? [] : paragraphs, size, lineHeight, + colour: o.colour ?? theme.text, font: o.font ?? 'body', align: o.align ?? 'left', valign: o.valign ?? 'top', + bullets: o.bullets ?? false, placeholder + }) + } + const image = (ref: SlotRef, x: number, y: number, w: number, h: number, radius = theme.radius) => + boxes.push({ type: 'image', x, y, w, h, ref, image: imageOf(s[ref.slot]), radius }) + const rect = (x: number, y: number, w: number, h: number, fill: string, radius = 0) => boxes.push({ type: 'rect', x, y, w, h, fill, radius }) + const title = (y = 96, h = 150) => text({ slot: 'title' }, str(s.title), M, y, SLIDE_W - 2 * M, h, { max: 76, font: 'heading', valign: 'bottom' }) + const body = { y: 300, h: SLIDE_H - 300 - 110 } + + switch (slide.layout) { + case 'title': { + const pic = !!s.image + const w = pic ? 840 : SLIDE_W - 2 * M + rect(M, 330, 96, 12, theme.accent, 6) + text({ slot: 'title' }, str(s.title), M, 380, w, 330, { max: pic ? 110 : 132, font: 'heading', valign: 'top' }) + text({ slot: 'subtitle' }, str(s.subtitle), M, 730, w, 200, { max: 44, colour: theme.muted }) + if (pic) image({ slot: 'image' }, 1080, M, SLIDE_W - 1080 - M, SLIDE_H - 2 * M) + break + } + case 'section': + rect(0, 0, SLIDE_W, SLIDE_H, theme.accent) + text({ slot: 'title' }, str(s.title), M, 300, SLIDE_W - 2 * M, 360, { max: 120, font: 'heading', colour: theme.onAccent, valign: 'bottom' }) + text({ slot: 'subtitle' }, str(s.subtitle), M, 700, SLIDE_W - 2 * M, 220, { max: 44, colour: theme.onAccent }) + break + case 'bullets': + title() + text({ slot: 'bullets' }, list(s.bullets), M, body.y, SLIDE_W - 2 * M, body.h, { max: 52, min: 28, bullets: true }) + break + case 'twoColumns': { + const w = (SLIDE_W - 2 * M - 96) / 2 + title() + text({ slot: 'left' }, list(s.left), M, body.y, w, body.h, { max: 46, min: 26, bullets: true }) + text({ slot: 'right' }, list(s.right), M + w + 96, body.y, w, body.h, { max: 46, min: 26, bullets: true }) + break + } + case 'imageLeft': + case 'imageRight': { + const left = slide.layout === 'imageLeft' + const pw = 860 + const tx = left ? pw + 120 : M + const tw = SLIDE_W - pw - 120 - M + image({ slot: 'image' }, left ? 0 : SLIDE_W - pw, 0, pw, SLIDE_H, 0) + text({ slot: 'title' }, str(s.title), tx, 200, tw, 300, { max: 76, font: 'heading', valign: 'bottom' }) + text({ slot: 'body' }, str(s.body), tx, 540, tw, 400, { max: 46, min: 28, colour: theme.muted }) + break + } + case 'fullImage': { + image({ slot: 'image' }, 0, 0, SLIDE_W, SLIDE_H, 0) + const caption = str(s.caption) + if (caption.some((c) => c.trim())) rect(M - 40, SLIDE_H - 250, SLIDE_W - 2 * M + 80, 150, theme.bg, theme.radius) + text({ slot: 'caption' }, caption, M, SLIDE_H - 230, SLIDE_W - 2 * M, 110, { max: 44, min: 30, valign: 'middle' }) + break + } + case 'bigNumber': + text({ slot: 'stat' }, str(s.stat), M, 150, SLIDE_W - 2 * M, 400, { max: 320, min: 160, font: 'heading', colour: theme.accent, valign: 'bottom', lineHeight: 1 }) + text({ slot: 'statLabel' }, str(s.statLabel), M, 590, SLIDE_W - 2 * M, 150, { max: 60, font: 'heading' }) + text({ slot: 'body' }, str(s.body), M, 770, 1300, 200, { max: 36, min: 26, colour: theme.muted }) + break + case 'threeCards': { + title() + const items = itemsOf(s.items) + const n = Math.max(3, items.length) + const gap = 48 + const w = (SLIDE_W - 2 * M - gap * (n - 1)) / n + for (let i = 0; i < n; i++) { + const x = M + i * (w + gap) + rect(x, body.y, w, body.h, theme.surface, theme.radius) + rect(x + 56, body.y + 64, 56, 56, theme.accent, 28) + text({ slot: 'items', index: i, part: 'heading' }, str(items[i]?.heading), x + 56, body.y + 160, w - 112, 150, { max: 54, min: 32, font: 'heading' }) + text({ slot: 'items', index: i, part: 'body' }, str(items[i]?.body), x + 56, body.y + 330, w - 112, body.h - 390, { max: 38, min: 24, colour: theme.muted }) + } + break + } + case 'quote': + text({ slot: 'quote' }, str(s.quote), 260, 220, SLIDE_W - 260 - M, 560, { max: 84, min: 44, font: 'heading', lineHeight: 1.2 }) + rect(M, 230, 12, 160, theme.accent, 6) + text({ slot: 'attribution' }, str(s.attribution), 260, 820, SLIDE_W - 260 - M, 100, { max: 36, colour: theme.muted }) + break + case 'comparison': { + title() + const rows = tableOf(s.table) + const cols = Math.max(1, ...rows.map((r) => r.length)) + const shown = rows.length ? rows : [[]] + const rh = Math.min(120, body.h / shown.length) + const cw = (SLIDE_W - 2 * M) / cols + rect(M, body.y, SLIDE_W - 2 * M, rh, theme.surface, theme.radius / 2) + shown.forEach((row, r) => { + if (r > 0) rect(M, body.y + r * rh, SLIDE_W - 2 * M, 2, theme.stroke) + for (let c = 0; c < cols; c++) { + text({ slot: 'table', index: r, col: c }, str(row[c]), M + c * cw + 32, body.y + r * rh, cw - 64, rh, { + max: 40, min: 22, valign: 'middle', font: r === 0 ? 'heading' : 'body', colour: r === 0 ? theme.accent : theme.text + }) + } + }) + break + } + case 'timeline': { + title() + const items = itemsOf(s.items) + const n = Math.max(3, items.length) + const w = (SLIDE_W - 2 * M) / n + const line = body.y + 120 + rect(M, line - 2, SLIDE_W - 2 * M, 4, theme.stroke, 2) + for (let i = 0; i < n; i++) { + const x = M + i * w + rect(x, line - 18, 36, 36, theme.accent, 18) + text({ slot: 'items', index: i, part: 'heading' }, str(items[i]?.heading), x, line + 70, w - 48, 120, { max: 48, min: 28, font: 'heading' }) + text({ slot: 'items', index: i, part: 'body' }, str(items[i]?.body), x, line + 200, w - 48, SLIDE_H - line - 300, { max: 36, min: 22, colour: theme.muted }) + } + break + } + } + return boxes +} + +const str = (v: SlotValue | string | undefined): string[] => { + if (typeof v === 'string') return [v] + return v?.kind === 'text' ? [v.value] : [] +} +const list = (v: SlotValue | undefined): string[] => (v?.kind === 'list' ? v.value : []) +const itemsOf = (v: SlotValue | undefined): Item[] => (v?.kind === 'items' ? v.value : []) +const tableOf = (v: SlotValue | undefined): string[][] => (v?.kind === 'table' ? v.value : []) +const imageOf = (v: SlotValue | undefined): ImageRef | null => (v?.kind === 'image' ? v.value : null) diff --git a/src/core/deck/themes.ts b/src/core/deck/themes.ts new file mode 100644 index 0000000..0ba0784 --- /dev/null +++ b/src/core/deck/themes.ts @@ -0,0 +1,64 @@ +/** + * Deck themes: the colours and type a deck's layouts are drawn with. A theme changes the + * whole deck at once and never moves anything, so swapping one is always safe. + */ + +export interface DeckTheme { + id: string + /** Font stacks, first choice first. PowerPoint export uses the first name. */ + headingFont: string + bodyFont: string + /** Wollo uses one weight everywhere; other themes may set a heavier heading. */ + headingWeight: number + bg: string + text: string + muted: string + accent: string + onAccent: string + /** Cards, table headers and caption backgrounds. */ + surface: string + stroke: string + /** Corner radius of cards and pictures, in slide units (a slide is 1920 by 1080). */ + radius: number +} + +const URBANIST = "Urbanist, 'Helvetica Neue', Arial, sans-serif" + +/** The website's look, as in src/styles/web.css. */ +export const THEMES: Record = { + wollo: { + id: 'wollo', + headingFont: URBANIST, + bodyFont: URBANIST, + headingWeight: 400, + bg: '#ffffff', + text: '#0a0a0a', + muted: '#6b6b6b', + accent: '#6312f6', + onAccent: '#ffffff', + surface: '#f4f3f7', + stroke: '#dcdcdc', + radius: 28 + }, + wolloDark: { + id: 'wolloDark', + headingFont: URBANIST, + bodyFont: URBANIST, + headingWeight: 400, + bg: '#0e0d12', + text: '#f4f3f7', + muted: '#a9a6b3', + accent: '#b49bff', + onAccent: '#0e0d12', + surface: '#1a1920', + stroke: '#34323d', + radius: 28 + } +} + +export const DEFAULT_THEME = 'wollo' + +/** The deck's theme, or Wollo when the deck names one this version doesn't have. */ +export function themeOf(id: string): DeckTheme { + return Object.hasOwn(THEMES, id) ? THEMES[id] : THEMES[DEFAULT_THEME] +} diff --git a/tests/deck-layout.test.ts b/tests/deck-layout.test.ts new file mode 100644 index 0000000..2ba3f88 --- /dev/null +++ b/tests/deck-layout.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { emptyDeck, layoutIds } from '../src/core/deck/model' +import { applyOps } from '../src/core/deck/ops' +import { SLIDE_H, SLIDE_W, fitSize, layoutSlide, wrappedLines, type TextBox } from '../src/core/deck/layout' +import { escapeHtml, refKey, slideHtml } from '../src/core/deck/html' +import { THEMES, themeOf } from '../src/core/deck/themes' + +const theme = THEMES.wollo +let n = 0 +const slideOf = (layout: (typeof layoutIds)[number], slots: Record = {}) => + applyOps(emptyDeck(), [{ op: 'add', layout, slots }], () => `s${++n}`).deck.slides[0] + +const texts = (boxes: ReturnType) => boxes.filter((b): b is TextBox => b.type === 'text') + +describe('wrappedLines and fitSize', () => { + it('wraps at spaces and breaks words longer than a line', () => { + expect(wrappedLines(['aaa bbb ccc'], 70, 20)).toBe(2) // 7 characters a line + expect(wrappedLines(['abcdefghijklmnop'], 50, 20)).toBe(4) // 5 a line + expect(wrappedLines(['one', 'two'], 1000, 20)).toBe(2) + }) + + it('shrinks text until it fits, but not below the minimum', () => { + const long = ['word '.repeat(60)] + const size = fitSize(long, 800, 300, 80, 20, 1.2) + expect(size).toBeLessThan(80) + expect(wrappedLines(long, 800, size) * size * 1.2).toBeLessThanOrEqual(300) + expect(fitSize(['short'], 800, 300, 80, 20, 1.2)).toBe(80) + expect(fitSize(['word '.repeat(5000)], 800, 300, 80, 20, 1.2)).toBe(20) + }) +}) + +describe('layoutSlide', () => { + it('keeps every box of every layout on the slide', () => { + for (const layout of layoutIds) { + for (const b of layoutSlide(slideOf(layout), theme)) { + expect(b.x, `${layout} x`).toBeGreaterThanOrEqual(0) + expect(b.y, `${layout} y`).toBeGreaterThanOrEqual(0) + expect(b.x + b.w, `${layout} right`).toBeLessThanOrEqual(SLIDE_W) + expect(b.y + b.h, `${layout} bottom`).toBeLessThanOrEqual(SLIDE_H) + } + } + }) + + it('marks empty slots as placeholders and fills the others', () => { + const boxes = texts(layoutSlide(slideOf('title', { title: 'Bake Sale' }), theme)) + expect(boxes.map((b) => [b.ref.slot, b.placeholder])).toEqual([['title', false], ['subtitle', true]]) + }) + + it('makes room for a picture on a title slide', () => { + const plain = layoutSlide(slideOf('title', { title: 'T' }), theme) + const pic = layoutSlide(slideOf('title', { title: 'T', image: { src: 'media/a.jpg', alt: 'A' } }), theme) + expect(plain.some((b) => b.type === 'image')).toBe(false) + expect(texts(pic)[0].w).toBeLessThan(texts(plain)[0].w) + }) + + it('gives each card its heading and body with a ref back to the slot', () => { + const boxes = texts(layoutSlide(slideOf('threeCards', { title: 'Ways', items: [{ heading: 'Bake', body: 'Cakes' }, { heading: 'Bring', body: 'Friends' }, { heading: 'Buy', body: 'Treats' }] }), theme)) + expect(boxes.slice(1).map((b) => `${refKey(b.ref)}=${b.paragraphs[0]}`)).toEqual([ + 'items.0.heading=Bake', 'items.0.body=Cakes', 'items.1.heading=Bring', 'items.1.body=Friends', 'items.2.heading=Buy', 'items.2.body=Treats' + ]) + }) + + it('shrinks a long title rather than letting it overflow', () => { + const short = texts(layoutSlide(slideOf('bullets', { title: 'Hi', bullets: ['a'] }), theme))[0] + const long = texts(layoutSlide(slideOf('bullets', { title: 'A much longer title that runs across the slide twice over', bullets: ['a'] }), theme))[0] + expect(long.size).toBeLessThan(short.size) + }) +}) + +describe('slideHtml', () => { + it('escapes text, alt text and picture paths', () => { + const slide = slideOf('imageLeft', { title: 'Hi & "you"', image: { src: 'media/"x".jpg', alt: '' } }) + const html = slideHtml(slide, theme, { resolveSrc: (src) => `blob:${src}` }) + expect(html).toContain('<b>Hi</b> & "you"') + expect(html).toContain('alt="<img onerror=1>"') + expect(html).toContain('src="blob:media/"x".jpg"') + expect(html).not.toContain('') + }) + + it('shows hints for empty slots only when asked, and no picture it cannot find', () => { + const slide = slideOf('imageRight', { title: 'T', image: { src: 'media/gone.jpg', alt: 'Gone' } }) + const edit = slideHtml(slide, theme, { resolveSrc: () => null, placeholder: (ref) => `Add ${ref.slot}` }) + expect(edit).toContain('Add body') + expect(edit).toContain('Add image') + expect(edit).not.toContain(' null }) + expect(exported).not.toContain('Add ') + }) + + it('escapes the characters HTML cares about', () => { + expect(escapeHtml(`&`)).toBe('<a href='x'>&</a>') + }) + + it('falls back to Wollo for a theme it does not know', () => { + expect(themeOf('neon').id).toBe('wollo') + expect(themeOf('wolloDark').id).toBe('wolloDark') + expect(themeOf('constructor').id).toBe('wollo') + }) +}) From 03d129e3b1bb5914161e8ab95746eaf4542e3697 Mon Sep 17 00:00:00 2001 From: RedtRocks <56254751+RedtRocks@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:19:48 +0000 Subject: [PATCH 05/53] Read and write .gdeck files A zip with deck.json and the pictures under media/. Reading checks every slide and keeps what it can, with a warning for anything left out. Pictures must sit in media/, so a deck can't point outside itself. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JXvo1FTmUxCdQr2fZL9uqr --- src/core/deck/file.ts | 101 ++++++++++++++++++++++++++++++++++++++ src/core/deck/model.ts | 11 +++++ src/core/deck/ops.ts | 5 +- tests/deck-file.test.ts | 79 +++++++++++++++++++++++++++++ tests/deck-layout.test.ts | 6 +-- 5 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 src/core/deck/file.ts create mode 100644 tests/deck-file.test.ts diff --git a/src/core/deck/file.ts b/src/core/deck/file.ts new file mode 100644 index 0000000..7221c56 --- /dev/null +++ b/src/core/deck/file.ts @@ -0,0 +1,101 @@ +/** + * The .gdeck file (ADR 0018): a zip with deck.json and the pictures under media/. + * A deck may come from anyone, so reading checks every slide and keeps what it can: + * a bad slot or a missing picture costs that slot, not the whole deck. + */ +import JSZip from 'jszip' +import { DECK_VERSION, isLayout, type Deck, type Slide } from './model' +import { DeckError, slotValue } from './ops' + +export const DECK_EXTENSION = 'gdeck' +const DECK_JSON = 'deck.json' +export interface DeckFile { + deck: Deck + /** media/… path to the file's bytes. */ + media: Map +} + +export interface ReadResult extends DeckFile { + /** What was left out, in words the person (or an AI app) can act on. */ + warnings: string[] +} + +export async function writeDeck({ deck, media }: DeckFile): Promise { + const zip = new JSZip() + zip.file(DECK_JSON, JSON.stringify(deck, null, 1)) + // Only pictures a slide uses are kept, so deleting a slide also drops its pictures. + const used = usedMedia(deck) + for (const [path, bytes] of media) if (used.has(path)) zip.file(path, bytes, { compression: 'STORE' }) + return zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE', mimeType: 'application/vnd.glance.deck+zip' }) +} + +export async function readDeck(bytes: Uint8Array): Promise { + let zip: JSZip + try { + zip = await JSZip.loadAsync(bytes) + } catch { + throw new DeckError('not a Glance deck (not a zip file)') + } + const entry = zip.file(DECK_JSON) + if (!entry) throw new DeckError(`not a Glance deck (no ${DECK_JSON})`) + let raw: unknown + try { + raw = JSON.parse(await entry.async('string')) + } catch { + throw new DeckError(`${DECK_JSON} is not valid JSON`) + } + const { deck, warnings } = parseDeck(raw) + const media = new Map() + for (const path of usedMedia(deck)) { + const file = zip.file(path) + if (file) media.set(path, await file.async('uint8array')) + else warnings.push(`The picture ${path} is missing from the file.`) + } + return { deck, media, warnings } +} + +/** Checks a deck.json that came from outside, keeping every slide and slot that is sound. */ +export function parseDeck(raw: unknown): { deck: Deck; warnings: string[] } { + const warnings: string[] = [] + const obj = (raw ?? {}) as { version?: unknown; theme?: unknown; slides?: unknown } + if (typeof obj !== 'object' || !Array.isArray(obj.slides)) throw new DeckError('not a Glance deck (no slides list)') + const version = typeof obj.version === 'number' ? obj.version : DECK_VERSION + if (version > DECK_VERSION) warnings.push('This deck was made by a newer Glance; some of it may not show.') + const seen = new Set() + const slides: Slide[] = [] + obj.slides.forEach((s: unknown, i: number) => { + const raw = (s ?? {}) as Partial> + if (typeof raw.layout !== 'string' || !isLayout(raw.layout)) { + warnings.push(`Slide ${i + 1} has a layout this Glance doesn't know (${String(raw.layout)}) and was left out.`) + return + } + let id = typeof raw.id === 'string' && /^[\w-]{1,40}$/.test(raw.id) ? raw.id : '' + if (!id || seen.has(id)) id = freshId(seen) + seen.add(id) + const slide: Slide = { id, layout: raw.layout, slots: {}, notes: typeof raw.notes === 'string' ? raw.notes : '', hidden: raw.hidden === true } + for (const [name, value] of Object.entries((raw.slots ?? {}) as Record)) { + // Slots are saved as { kind, value }; unwrap them so slotValue checks the value. + const inner = value && typeof value === 'object' && 'value' in value ? (value as { value: unknown }).value : value + try { + slide.slots[name] = slotValue(slide.layout, name, inner) + } catch (e) { + if (!(e instanceof DeckError)) throw e + warnings.push(`Slide ${i + 1}: left out ${name} (${e.message}).`) + } + } + slides.push(slide) + }) + return { deck: { version: DECK_VERSION, theme: typeof obj.theme === 'string' && obj.theme ? obj.theme : 'wollo', slides }, warnings } +} + +export function usedMedia(deck: Deck): Set { + const used = new Set() + for (const slide of deck.slides) for (const v of Object.values(slide.slots)) if (v.kind === 'image') used.add(v.value.src) + return used +} + +function freshId(seen: Set): string { + let n = seen.size + 1 + while (seen.has(`s${n}`)) n++ + return `s${n}` +} diff --git a/src/core/deck/model.ts b/src/core/deck/model.ts index 2bc36ff..57a47f3 100644 --- a/src/core/deck/model.ts +++ b/src/core/deck/model.ts @@ -10,6 +10,11 @@ export const DECK_VERSION = 1 export type SlotKind = 'text' | 'list' | 'image' | 'items' | 'table' +/** Pictures a deck can name: media/ then a plain file name, no folders. */ +const MEDIA_PATH = /^media\/\w[\w .()-]{0,120}\.(png|jpe?g|webp|gif|avif|svg)$/i + +export const isMediaPath = (src: string) => MEDIA_PATH.test(src) && !src.includes('..') + export interface ImageRef { /** A path inside the deck's media/ folder. */ src: string @@ -83,3 +88,9 @@ export const isLayout = (id: string): id is LayoutId => Object.hasOwn(LAYOUTS, i export function emptyDeck(theme = 'wollo'): Deck { return { version: DECK_VERSION, theme, slides: [] } } + +/** A short id for a new slide ("s7"), so AI apps can name slides back easily. */ +export function slideIdMaker(deck: Deck): () => string { + let n = Math.max(0, ...deck.slides.map((s) => Number(/^s(\d+)$/.exec(s.id)?.[1] ?? 0))) + return () => `s${++n}` +} diff --git a/src/core/deck/ops.ts b/src/core/deck/ops.ts index 4da5435..a5d7089 100644 --- a/src/core/deck/ops.ts +++ b/src/core/deck/ops.ts @@ -3,7 +3,7 @@ * glance_deck_edit, the Ask AI sidebar, or the person dragging a slide. A list of them is * applied all or nothing, so one AI turn is one undo step. */ -import { LAYOUTS, isLayout, type Deck, type LayoutId, type Slide, type SlotKind, type SlotSpec, type SlotValue } from './model' +import { LAYOUTS, isLayout, isMediaPath, type Deck, type LayoutId, type Slide, type SlotKind, type SlotSpec, type SlotValue } from './model' export type DeckOp = /** A new slide, at the end unless `after` names a slide (null puts it first). */ @@ -184,7 +184,7 @@ function listSlot(slide: Slide): string { const isText = (v: unknown): v is string => typeof v === 'string' /** Checks a value against the slot's kind, so a bad AI call fails with a reason instead of saving junk. */ -function slotValue(layout: LayoutId, name: string, value: unknown): SlotValue { +export function slotValue(layout: LayoutId, name: string, value: unknown): SlotValue { const spec = specOf(layout, name) if (!spec) throw new DeckError(`layout ${layout} has no slot ${name}; it has ${(LAYOUTS[layout] as SlotSpec[]).map((s) => s.name).join(', ')}`) const bad = (shape: string) => new DeckError(`slot ${name} takes ${shape}`) @@ -199,6 +199,7 @@ function slotValue(layout: LayoutId, name: string, value: unknown): SlotValue { case 'image': { const v = value as { src?: unknown; alt?: unknown; focus?: { x?: unknown; y?: unknown } } if (!v || !isText(v.src) || !v.src) throw bad('an image: { src, alt }') + if (!isMediaPath(v.src)) throw new DeckError(`picture ${v.src} must be a file in the deck's media/ folder, like media/photo.jpg`) const focus = v.focus && typeof v.focus.x === 'number' && typeof v.focus.y === 'number' ? { x: clamp01(v.focus.x), y: clamp01(v.focus.y) } : undefined diff --git a/tests/deck-file.test.ts b/tests/deck-file.test.ts new file mode 100644 index 0000000..5bb5f11 --- /dev/null +++ b/tests/deck-file.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import JSZip from 'jszip' +import { emptyDeck, isMediaPath, slideIdMaker } from '../src/core/deck/model' +import { applyOps, DeckError } from '../src/core/deck/ops' +import { parseDeck, readDeck, writeDeck } from '../src/core/deck/file' + +const png = new Uint8Array([137, 80, 78, 71, 1, 2, 3]) + +function sample() { + const deck = emptyDeck() + return applyOps(deck, [ + { op: 'add', layout: 'title', slots: { title: 'Bake Sale' } }, + { op: 'add', layout: 'fullImage', slots: { image: { src: 'media/cake.png', alt: 'A cake' } } } + ], slideIdMaker(deck)).deck +} + +describe('.gdeck files', () => { + it('round-trips a deck and the pictures it uses, dropping unused ones', async () => { + const deck = sample() + const bytes = await writeDeck({ deck, media: new Map([['media/cake.png', png], ['media/old.png', png]]) }) + const back = await readDeck(bytes) + expect(back.deck).toEqual(deck) + expect([...back.media.keys()]).toEqual(['media/cake.png']) + expect(back.media.get('media/cake.png')).toEqual(png) + expect(back.warnings).toEqual([]) + }) + + it('warns about a missing picture instead of failing', async () => { + const back = await readDeck(await writeDeck({ deck: sample(), media: new Map() })) + expect(back.warnings).toEqual(['The picture media/cake.png is missing from the file.']) + }) + + it('refuses files that are not decks', async () => { + await expect(readDeck(new Uint8Array([1, 2, 3]))).rejects.toThrow(DeckError) + const zip = new JSZip() + zip.file('other.txt', 'hi') + await expect(readDeck(await zip.generateAsync({ type: 'uint8array' }))).rejects.toThrow(/no deck.json/) + }) +}) + +describe('parseDeck', () => { + it('keeps what is sound and says what it left out', () => { + const { deck, warnings } = parseDeck({ + version: 1, + theme: 'wollo', + slides: [ + { id: 's1', layout: 'title', slots: { title: { kind: 'text', value: 'Hi' }, bogus: { kind: 'text', value: 'x' } } }, + { id: 's1', layout: 'bullets', slots: { bullets: { kind: 'list', value: ['a', 2] } } }, + { id: 's3', layout: 'poster', slots: {} }, + { layout: 'fullImage', slots: { image: { kind: 'image', value: { src: '../../secret.png', alt: '' } } } } + ] + }) + expect(deck.slides.map((s) => [s.id, s.layout, Object.keys(s.slots)])).toEqual([ + ['s1', 'title', ['title']], + ['s2', 'bullets', []], + ['s3', 'fullImage', []] + ]) + expect(warnings).toHaveLength(4) + expect(warnings[2]).toMatch(/Slide 3 has a layout/) + }) + + it('warns when a newer Glance made the deck', () => { + expect(parseDeck({ version: 99, slides: [] }).warnings[0]).toMatch(/newer Glance/) + expect(() => parseDeck({ slides: 'no' })).toThrow(DeckError) + }) +}) + +describe('media paths and ids', () => { + it('allows only plain picture names inside media/', () => { + expect(isMediaPath('media/cake photo (1).jpg')).toBe(true) + for (const bad of ['cake.jpg', 'media/../x.png', 'media/a/b.png', 'C:\\x.png', 'media/x.exe', 'media/.png']) expect(isMediaPath(bad), bad).toBe(false) + }) + + it('numbers new slides after the highest id', () => { + const deck = sample() + deck.slides[0].id = 's9' + expect(slideIdMaker(deck)()).toBe('s10') + }) +}) diff --git a/tests/deck-layout.test.ts b/tests/deck-layout.test.ts index 2ba3f88..ba0ad42 100644 --- a/tests/deck-layout.test.ts +++ b/tests/deck-layout.test.ts @@ -69,11 +69,11 @@ describe('layoutSlide', () => { describe('slideHtml', () => { it('escapes text, alt text and picture paths', () => { - const slide = slideOf('imageLeft', { title: 'Hi & "you"', image: { src: 'media/"x".jpg', alt: '' } }) - const html = slideHtml(slide, theme, { resolveSrc: (src) => `blob:${src}` }) + const slide = slideOf('imageLeft', { title: 'Hi & "you"', image: { src: 'media/x.jpg', alt: '' } }) + const html = slideHtml(slide, theme, { resolveSrc: (src) => `blob:"${src}"` }) expect(html).toContain('<b>Hi</b> & "you"') expect(html).toContain('alt="<img onerror=1>"') - expect(html).toContain('src="blob:media/"x".jpg"') + expect(html).toContain('src="blob:"media/x.jpg""') expect(html).not.toContain('') }) From ef29e8bd6d7936e1de7180bfa8bf473b109b147f Mon Sep 17 00:00:00 2001 From: RedtRocks <56254751+RedtRocks@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:31:41 +0000 Subject: [PATCH 06/53] Open, edit and save decks in a tab .gdeck files open in their own tab with the slides down the sidebar. Click any text on a slide to type over it, click a picture to replace it, drag slides to reorder them, and use the Slide menu or right-click to add, duplicate, hide or delete them. Every change is one undo step, and decks save and autosave like PDFs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JXvo1FTmUxCdQr2fZL9uqr --- src-tauri/src/decode/formats.rs | 4 + src/core/deck/edit.ts | 73 ++++++++++++++ src/platform/index.ts | 3 +- src/state/actions.ts | 10 +- src/state/autosave.ts | 5 +- src/state/commands.ts | 35 +++++-- src/state/deck.ts | 168 +++++++++++++++++++++++++++++++ src/state/deckMedia.ts | 25 +++++ src/state/documents.ts | 72 +++++++++++++- src/state/print.ts | 3 +- src/styles/app.css | 130 ++++++++++++++++++++++++ src/ui/App.tsx | 2 + src/ui/MenuBar.tsx | 12 ++- src/ui/deck/DeckView.tsx | 171 ++++++++++++++++++++++++++++++++ src/ui/deck/SlideFrame.tsx | 56 +++++++++++ src/ui/deck/SlideList.tsx | 118 ++++++++++++++++++++++ src/ui/deck/labels.ts | 32 ++++++ src/ui/sidebar/Sidebar.tsx | 2 + src/ui/views/Slideshow.tsx | 25 ++++- tests/deck-edit.test.ts | 50 ++++++++++ 20 files changed, 971 insertions(+), 25 deletions(-) create mode 100644 src/core/deck/edit.ts create mode 100644 src/state/deck.ts create mode 100644 src/state/deckMedia.ts create mode 100644 src/ui/deck/DeckView.tsx create mode 100644 src/ui/deck/SlideFrame.tsx create mode 100644 src/ui/deck/SlideList.tsx create mode 100644 src/ui/deck/labels.ts create mode 100644 tests/deck-edit.test.ts diff --git a/src-tauri/src/decode/formats.rs b/src-tauri/src/decode/formats.rs index 778a436..943934e 100644 --- a/src-tauri/src/decode/formats.rs +++ b/src-tauri/src/decode/formats.rs @@ -13,6 +13,8 @@ pub enum Kind { Xps, Archive, Preview, + /// A Glance deck (.gdeck), read and drawn by the frontend. + Deck, Unsupported, } @@ -73,6 +75,7 @@ pub fn classify(path: &Path, head: &[u8]) -> Kind { "ps" | "eps" | "epsf" | "epsi" => Kind::Postscript, "xps" | "oxps" => Kind::Xps, "cbz" => Kind::Archive, + "gdeck" => Kind::Deck, _ if PREVIEWS.contains(&e) => Kind::Preview, _ if BROWSER_IMAGES.contains(&e) || BACKEND_IMAGES.contains(&e) || RAW_IMAGES.contains(&e) => { Kind::Image @@ -133,6 +136,7 @@ mod tests { assert_eq!(classify(Path::new("Old.doc"), b"\xD0\xCF\x11\xE0"), Kind::Preview); assert_eq!(classify(Path::new("a.oxps"), b""), Kind::Xps); assert_eq!(classify(Path::new("a.cbz"), b""), Kind::Archive); + assert_eq!(classify(Path::new("Bake Sale.GDECK"), b"PK\x03\x04"), Kind::Deck); assert_eq!(classify(Path::new("a.xyz"), b"nothing"), Kind::Unsupported); assert_eq!(classify(Path::new("noext"), b"\x89PNG\r\n\x1a\n"), Kind::Image); } diff --git a/src/core/deck/edit.ts b/src/core/deck/edit.ts new file mode 100644 index 0000000..c0f9178 --- /dev/null +++ b/src/core/deck/edit.ts @@ -0,0 +1,73 @@ +/** + * Typing on a slide: the text a box shows, and the operation that puts edited text back. + * Lists are one entry per line, so editing a bullet list feels like editing text. + */ +import { LAYOUTS, type Item, type Slide, type SlotSpec } from './model' +import type { DeckOp } from './ops' +import type { SlotRef } from './layout' + +/** The ref for a box's `data-ref` (see refKey in html.ts). */ +export function parseRefKey(key: string): SlotRef { + const [slot, a, b] = key.split('.') + const ref: SlotRef = { slot } + if (a !== undefined) ref.index = Number(a) + if (b === 'heading' || b === 'body') ref.part = b + else if (b !== undefined) ref.col = Number(b) + return ref +} + +const specOf = (slide: Slide, slot: string): SlotSpec | undefined => (LAYOUTS[slide.layout] as SlotSpec[]).find((s) => s.name === slot) + +/** What the editor starts with for this box. */ +export function slotText(slide: Slide, ref: SlotRef): string { + const v = slide.slots[ref.slot] + if (!v) return '' + switch (v.kind) { + case 'text': + return v.value + case 'list': + return v.value.join('\n') + case 'items': + return v.value[ref.index ?? 0]?.[ref.part ?? 'heading'] ?? '' + case 'table': + return v.value[ref.index ?? 0]?.[ref.col ?? 0] ?? '' + case 'image': + return v.value.alt + } +} + +/** The operations that store `text` in the box, or none when nothing changed. */ +export function editOps(slide: Slide, ref: SlotRef, text: string): DeckOp[] { + const spec = specOf(slide, ref.slot) + if (!spec || slotText(slide, ref) === text) return [] + const id = slide.id + const v = slide.slots[ref.slot] + switch (spec.kind) { + case 'text': + return text.trim() ? [{ op: 'setSlot', id, slot: ref.slot, value: text }] : [{ op: 'clearSlot', id, slot: ref.slot }] + case 'list': { + const lines = text.split('\n').map((l) => l.trim()).filter(Boolean) + return lines.length ? [{ op: 'setSlot', id, slot: ref.slot, value: lines }] : [{ op: 'clearSlot', id, slot: ref.slot }] + } + case 'items': { + const items: Item[] = v?.kind === 'items' ? v.value.map((it) => ({ ...it })) : [] + const i = ref.index ?? 0 + while (items.length <= i) items.push({ heading: '', body: '' }) + items[i][ref.part ?? 'heading'] = text + // Trailing cards left empty are dropped, so clearing the last card removes it. + while (items.length && !items.at(-1)!.heading.trim() && !items.at(-1)!.body.trim()) items.pop() + return items.length ? [{ op: 'setSlot', id, slot: ref.slot, value: items }] : [{ op: 'clearSlot', id, slot: ref.slot }] + } + case 'table': { + const rows: string[][] = v?.kind === 'table' ? v.value.map((r) => [...r]) : [] + const r = ref.index ?? 0 + const c = ref.col ?? 0 + while (rows.length <= r) rows.push([]) + while (rows[r].length <= c) rows[r].push('') + rows[r][c] = text + return [{ op: 'setSlot', id, slot: ref.slot, value: rows }] + } + case 'image': + return v?.kind === 'image' ? [{ op: 'setSlot', id, slot: ref.slot, value: { ...v.value, alt: text } }] : [] + } +} diff --git a/src/platform/index.ts b/src/platform/index.ts index 686f0dc..a3fa715 100644 --- a/src/platform/index.ts +++ b/src/platform/index.ts @@ -12,7 +12,7 @@ import { archivePage, archivePageCount, cachedUrl, cachedUrlAsync, decodeImage, import { HEIF } from './heif' import { t } from '../i18n' -export type Kind = 'pdf' | 'image' | 'model' | 'postscript' | 'xps' | 'archive' | 'preview' | 'unsupported' +export type Kind = 'pdf' | 'image' | 'model' | 'postscript' | 'xps' | 'archive' | 'preview' | 'deck' | 'unsupported' export interface Probe { path: string @@ -204,6 +204,7 @@ function classifyExt(ext: string): Kind { if (BROWSER_NATIVE.includes(ext) || WASM_IMAGES.includes(ext) || RAW_IMAGES.includes(ext)) return 'image' if (MODELS.includes(ext)) return 'model' if (ext === 'cbz') return 'archive' + if (ext === 'gdeck') return 'deck' if (PREVIEWS.includes(ext)) return 'preview' if (['ps', 'eps', 'epsf', 'epsi'].includes(ext)) return 'postscript' if (ext === 'xps' || ext === 'oxps') return 'xps' diff --git a/src/state/actions.ts b/src/state/actions.ts index 546d26f..b132077 100644 --- a/src/state/actions.ts +++ b/src/state/actions.ts @@ -10,6 +10,7 @@ import { activeDoc, activeId, addDoc, + DeckDoc, docs, findByPath, ImageDoc, @@ -22,6 +23,7 @@ import { } from './documents' import { alertDialog, opening, promptText, showDialog, toast, withBusy } from './ui' import { applyImageRedactions, editableImage, rotateImage, saveImage, saveImageAs } from './imageActions' +import { openDeck, saveDeck, saveDeckAs } from './deck' import * as versions from './versions' import { flushAutosave } from './autosave' import { msg, t } from '../i18n' @@ -173,6 +175,8 @@ async function openFresh(path: string): Promise { addDoc(doc) return doc } + case 'deck': + return openDeck(probe) default: return notice(probe, t('Glance can’t open this file'), t('This file type isn’t supported. If you think it should be, please open an issue on GitHub.')) } @@ -267,6 +271,7 @@ async function resolvePendingRedactions(doc: PdfDoc): Promise { export async function save(doc: Doc | null = activeDoc.value, opts: { auto?: boolean } = {}): Promise { if (doc instanceof ImageDoc) return saveImage(doc, opts) + if (doc instanceof DeckDoc) return saveDeck(doc, opts) if (!doc || doc.kind !== 'pdf') return const path = doc.path.value if (!path) return opts.auto ? undefined : saveAs(doc) @@ -315,6 +320,7 @@ export async function offerToDeleteRedactedVersions(doc: Doc, path: string): Pro export async function saveAs(doc: Doc | null = activeDoc.value): Promise { if (doc instanceof ImageDoc) return saveImageAs(doc) + if (doc instanceof DeckDoc) return saveDeckAs(doc) if (!doc || doc.kind !== 'pdf') return const base = doc.name.value.replace(/\.[^.]+$/, '') const target = await platform.saveDialog(`${base}.pdf`, [{ name: msg('PDF document'), extensions: ['pdf'] }]) @@ -701,10 +707,10 @@ export function selectAllPages(doc = activeDoc.value): void { } export async function undo(doc = activeDoc.value): Promise { - if (doc?.kind === 'pdf' || doc?.kind === 'image') await doc.undo() + if (doc?.kind === 'pdf' || doc?.kind === 'image' || doc?.kind === 'deck') await doc.undo() } export async function redo(doc = activeDoc.value): Promise { - if (doc?.kind === 'pdf' || doc?.kind === 'image') await doc.redo() + if (doc?.kind === 'pdf' || doc?.kind === 'image' || doc?.kind === 'deck') await doc.redo() } export async function showAbout(): Promise { diff --git a/src/state/autosave.ts b/src/state/autosave.ts index 1e2469c..22de63a 100644 --- a/src/state/autosave.ts +++ b/src/state/autosave.ts @@ -6,7 +6,7 @@ * path), and for pending redactions (save() checks). */ import { effect } from '@preact/signals' -import { activeId, docs, ImageDoc, type Doc } from './documents' +import { activeId, DeckDoc, docs, ImageDoc, type Doc } from './documents' import { settings } from './settings' import { dialog, toast } from './ui' import { save } from './actions' @@ -20,6 +20,7 @@ type Editable = Extract /** What a save would write, by reference; unknown while PDF form fields have unsaved input. */ function fingerprint(d: Editable): Fingerprint { if (d instanceof ImageDoc) return [d.raster.peek(), d.markup.peek(), d.redactions.peek()] + if (d instanceof DeckDoc) return [d.deck.peek()] return d.formsEdited ? null : [d.bytes, d.markup.peek(), d.redactions.peek()] } @@ -45,7 +46,7 @@ const editorOpen = (): boolean => focused('.text-box.editing, .note-editor') let running: Autosaver | null = null -const editable = (d: Doc): d is Editable => d.kind === 'pdf' || d.kind === 'image' +const editable = (d: Doc): d is Editable => d.kind === 'pdf' || d.kind === 'image' || d.kind === 'deck' /** * Saves edited documents now rather than when their countdown ends (all of them, diff --git a/src/state/commands.ts b/src/state/commands.ts index 2adc633..4468894 100644 --- a/src/state/commands.ts +++ b/src/state/commands.ts @@ -22,6 +22,7 @@ import { markupBar, restyle, selectedId, setTool, SHAPE_TOOLS, signatureDialog, import { bookmarksFor, setBookmarks } from './bookmarks' import { adjustColorOpen, adjustSizeOpen, exportOpen, imageSelection, straighten } from './imageState' import * as img from './imageActions' +import * as deckActions from './deck' export interface Command { id: string @@ -46,6 +47,7 @@ export function stepZoom(current: number, dir: 1 | -1): number { } const pdf = () => (activeDoc.value?.kind === 'pdf' ? activeDoc.value : null) +const deck = () => (activeDoc.value?.kind === 'deck' ? activeDoc.value : null) const model = () => (activeDoc.value?.kind === 'model' ? activeDoc.value : null) /** The sidebar pane actually on screen, or null when hidden. */ const sidebarShown = () => { @@ -72,7 +74,7 @@ function zoom(dir: 1 | -1): void { const d = activeDoc.value if (!d || d.kind === 'notice') return if (d.kind === 'model') return void (d.viewRequest.value = { kind: 'zoom', dir }) - if (d.kind === 'preview') return void (d.zoom.value = stepZoom(d.zoom.value, dir)) + if (d.kind === 'preview' || d.kind === 'deck') return void (d.zoom.value = stepZoom(d.zoom.value, dir)) const next = stepZoom(d.effectiveScale.value, dir) if (d.kind === 'pdf') d.zoom.value = next else d.zoom.value = next @@ -82,7 +84,7 @@ function setZoom(mode: 'actual' | 'fit'): void { const d = activeDoc.value if (!d || d.kind === 'notice') return if (d.kind === 'model') return void (d.viewRequest.value = { kind: 'reset' }) - if (d.kind === 'preview') return void (d.zoom.value = 1) + if (d.kind === 'preview' || d.kind === 'deck') return void (d.zoom.value = 1) if (d.kind === 'pdf') d.zoom.value = mode === 'actual' ? 1 : 'fit-page' else d.zoom.value = mode === 'actual' ? 1 : 'fit' } @@ -121,11 +123,12 @@ function cycleTab(dir: 1 | -1): void { export const COMMANDS: Command[] = [ // File { id: 'file.open', label: msg('Open…'), keys: ['Ctrl+O'], run: actions.openWithDialog }, + { id: 'file.newDeck', label: msg('New Deck'), run: () => void deckActions.newDeck() }, { id: 'file.newFromClipboard', label: msg('New from Clipboard'), keys: ['Ctrl+N'], run: () => img.newFromClipboard(), enabled: () => platform.isTauri }, { id: 'file.scan', label: msg('Import from Scanner…'), run: () => void (scanOpen.value = true), enabled: () => platform.scanAvailable }, { id: 'file.newWindow', label: msg('New Window'), keys: ['Ctrl+Shift+N'], run: () => platform.openNewWindow([]) }, - { id: 'file.save', label: msg('Save'), keys: ['Ctrl+S'], run: () => actions.save(), enabled: () => isPdf() || isImage() }, - { id: 'file.saveAs', label: msg('Save As…'), keys: ['Ctrl+Shift+S'], run: () => actions.saveAs(), enabled: () => isPdf() || isImage() }, + { id: 'file.save', label: msg('Save'), keys: ['Ctrl+S'], run: () => actions.save(), enabled: () => isPdf() || isImage() || !!deck() }, + { id: 'file.saveAs', label: msg('Save As…'), keys: ['Ctrl+Shift+S'], run: () => actions.saveAs(), enabled: () => isPdf() || isImage() || !!deck() }, { id: 'file.export', label: msg('Export…'), @@ -154,8 +157,8 @@ export const COMMANDS: Command[] = [ }, { id: 'file.settings', label: msg('Settings'), keys: ['Ctrl+,'], run: () => void (settingsOpen.value = true) }, // Edit - { id: 'edit.undo', label: msg('Undo'), keys: ['Ctrl+Z'], run: () => actions.undo(), enabled: () => !!(pdf() ?? image())?.history.canUndo }, - { id: 'edit.redo', label: msg('Redo'), keys: ['Ctrl+Y', 'Ctrl+Shift+Z'], run: () => actions.redo(), enabled: () => !!(pdf() ?? image())?.history.canRedo }, + { id: 'edit.undo', label: msg('Undo'), keys: ['Ctrl+Z'], run: () => actions.undo(), enabled: () => !!(pdf() ?? image() ?? deck())?.history.canUndo }, + { id: 'edit.redo', label: msg('Redo'), keys: ['Ctrl+Y', 'Ctrl+Shift+Z'], run: () => actions.redo(), enabled: () => !!(pdf() ?? image() ?? deck())?.history.canRedo }, { id: 'edit.selectAll', label: msg('Select All Pages'), keys: ['Ctrl+A'], run: () => actions.selectAllPages(), enabled: isPdf }, { id: 'edit.find', label: msg('Find…'), keys: ['Ctrl+F'], run: () => void (findOpen.value = true), enabled: isPdf }, { id: 'edit.insertBlank', label: msg('Insert Blank Page'), run: () => actions.insertBlankPage(), enabled: isPdf }, @@ -180,8 +183,10 @@ export const COMMANDS: Command[] = [ if (im && imageSelection.peek()) return void img.deleteSelection(im) const d = pdf() if (d && d.selection.peek().length) return actions.deletePages() + const k = deck() + if (k) return deckActions.deleteSlides(k) }, - enabled: () => isPdf() || isImage() + enabled: () => isPdf() || isImage() || !!deck() }, { id: 'edit.invertSelection', label: msg('Invert Selection'), keys: ['Ctrl+Shift+I'], run: () => void img.invertSelection(image()!), enabled: () => isImage() && !!imageSelection.value }, { id: 'edit.deletePages', label: msg('Delete Selected Pages'), run: () => actions.deletePages(), enabled: isPdf }, @@ -246,6 +251,11 @@ export const COMMANDS: Command[] = [ { id: 'view.signatures', label: msg('Signatures'), run: () => void (signaturesOpen.value = !signaturesOpen.value), checked: () => signaturesOpen.value }, { id: 'view.customizeToolbar', label: msg('Customize Toolbar…'), run: () => void (customizeOpen.value = true) }, // Go + { id: 'deck.newSlide', label: msg('New Slide'), keys: ['Ctrl+M'], run: () => deckActions.addSlide(deck()!), enabled: () => !!deck() }, + { id: 'deck.duplicateSlide', label: msg('Duplicate Slide'), keys: ['Ctrl+Shift+D'], run: () => deckActions.duplicateSlides(deck()!), enabled: () => !!deck()?.pageCount.value }, + { id: 'deck.hideSlide', label: msg('Hide or Show Slide'), run: () => deckActions.toggleHidden(deck()!), enabled: () => !!deck()?.pageCount.value }, + { id: 'deck.moveUp', label: msg('Move Slide Up'), keys: ['Alt+Up'], run: () => deckActions.nudgeSlides(deck()!, -1), enabled: () => !!deck()?.pageCount.value }, + { id: 'deck.moveDown', label: msg('Move Slide Down'), keys: ['Alt+Down'], run: () => deckActions.nudgeSlides(deck()!, 1), enabled: () => !!deck()?.pageCount.value }, { id: 'go.previous', label: msg('Previous Page'), keys: ['Ctrl+Up', 'PageUp'], run: () => goPage(-1), enabled: multiPage }, { id: 'go.next', label: msg('Next Page'), keys: ['Ctrl+Down', 'PageDown'], run: () => goPage(1), enabled: multiPage }, { id: 'go.first', label: msg('First Page'), keys: ['Home'], run: () => goPage('first'), enabled: multiPage }, @@ -338,16 +348,19 @@ export const COMMANDS: Command[] = [ const ifPdf = () => activeDoc.value?.kind === 'pdf' const ifViewable = () => !!activeDoc.value && activeDoc.value.kind !== 'notice' const ifMarkup = () => ifPdf() || isImage() -const ifPaged = () => ifPdf() || multiPage() +const isDeck = () => activeDoc.value?.kind === 'deck' +const ifPaged = () => ifPdf() || multiPage() || isDeck() const VISIBILITY: [(() => boolean), string[]][] = [ [hasDoc, ['file.close', 'file.openWith', 'view.customizeToolbar']], [() => ifPdf() || anyImage() || activeDoc.value?.kind === 'preview', ['file.share']], [ifViewable, ['view.inspector']], - [() => ifPdf() || anyImage(), ['file.versions']], + [() => ifPdf() || anyImage() || isDeck(), ['file.versions']], [ifViewable, ['view.zoomIn', 'view.zoomOut', 'view.zoomToFit', 'view.fullscreen']], - [() => ifPdf() || anyImage(), ['file.print', 'view.actualSize', 'view.slideshow', 'tools.rotateLeft', 'tools.rotateRight']], + [() => ifPdf() || anyImage(), ['file.print', 'tools.rotateLeft', 'tools.rotateRight']], + [() => ifPdf() || anyImage() || isDeck(), ['view.actualSize', 'view.slideshow']], [() => activeDoc.value?.kind === 'model', ['model.wireframe', 'model.autoRotate', 'model.resetView', 'model.shadow', 'model.grid', 'model.view.front', 'model.view.back', 'model.view.left', 'model.view.right', 'model.view.top', 'model.view.bottom']], - [() => ifPdf() || isImage(), ['file.save', 'file.saveAs', 'edit.undo', 'edit.redo', 'edit.delete']], + [() => ifPdf() || isImage() || isDeck(), ['file.save', 'file.saveAs', 'edit.undo', 'edit.redo', 'edit.delete']], + [isDeck, ['deck.newSlide', 'deck.duplicateSlide', 'deck.hideSlide', 'deck.moveUp', 'deck.moveDown']], [() => ifPdf() || isImage() || !!model(), ['file.export']], [ifPdf, [ 'file.exportPages', 'file.split', 'edit.selectAll', 'edit.find', 'edit.insertBlank', 'edit.insertFile', 'edit.duplicatePages', 'edit.stamps', diff --git a/src/state/deck.ts b/src/state/deck.ts new file mode 100644 index 0000000..01b516c --- /dev/null +++ b/src/state/deck.ts @@ -0,0 +1,168 @@ +/** + * Opening, saving and creating decks (.gdeck, ADR 0018). The zip code loads only when a + * deck is opened or saved, so it stays off the startup path. + */ +import * as platform from '../platform' +import { addDoc, DeckDoc } from './documents' +import * as versions from './versions' +import { toast, withBusy } from './ui' +import { msg, t } from '../i18n' +import type { Deck, LayoutId } from '../core/deck/model' +import { DeckError, type ApplyResult, type DeckOp } from '../core/deck/ops' + +const deckFile = () => import('../core/deck/file') + +export async function openDeck(probe: platform.Probe): Promise { + const { readDeck } = await deckFile() + const { deck, media, warnings } = await readDeck(await platform.readFile(probe.path)) + const doc = new DeckDoc(probe.name, probe.path) + doc.deck.value = deck + for (const [path, bytes] of media) doc.media.set(path, bytes) + addDoc(doc) + // Say what was left out once, in one toast; the details are what an AI app would need. + if (warnings.length) toast(t('{count, plural, one {# part of this deck couldn’t be read and was left out.} other {# parts of this deck couldn’t be read and were left out.}}', { count: warnings.length }), 'error') + return doc +} + +/** A new, unsaved deck: from a starting deck (a template or an AI app's outline) or empty. */ +export function newDeck(name = t('Untitled deck'), start?: Deck, media?: Map): DeckDoc { + const doc = new DeckDoc(`${name}.gdeck`, null) + if (start) doc.deck.value = start + for (const [path, bytes] of media ?? []) doc.media.set(path, bytes) + addDoc(doc) + return doc +} + +export async function deckBytes(doc: DeckDoc): Promise { + const { writeDeck } = await deckFile() + return writeDeck({ deck: doc.deck.peek(), media: doc.media }) +} + +export async function saveDeck(doc: DeckDoc, opts: { auto?: boolean } = {}): Promise { + const path = doc.path.peek() + if (!path) return opts.auto ? undefined : saveDeckAs(doc) + const disk = await versions.checkDisk(doc, path, !!opts.auto) + if (disk === 'cancel') return + if (disk === 'copy') return saveDeckAs(doc) + await versions.beforeOverwrite(doc, path) + const run = async () => { + const bytes = await deckBytes(doc) + await platform.writeFile(path, bytes) + return bytes + } + const bytes = opts.auto ? await run() : await withBusy(t('Saving…'), run) + doc.dirty.value = false + await versions.rememberStamp(doc) + await versions.afterWrite(path, opts.auto ? msg('Autosaved') : msg('Saved'), bytes) + if (!opts.auto) toast(t('Saved')) +} + +export async function saveDeckAs(doc: DeckDoc): Promise { + const base = doc.name.peek().replace(/\.[^.]+$/, '') + const target = await platform.saveDialog(`${base}.gdeck`, [{ name: msg('Glance deck'), extensions: ['gdeck'] }]) + if (!target) return + await versions.afterWrite(target, msg('Before replacing')).catch(() => undefined) + const bytes = await withBusy(t('Saving…'), async () => { + const b = await deckBytes(doc) + await platform.writeFile(target, b) + return b + }) + const old = doc.path.peek() + doc.path.value = target + doc.name.value = platform.baseName(target) + doc.dirty.value = false + if (old) void platform.releaseFile(old) + void platform.claimFile(target) + await versions.rememberStamp(doc) + await versions.afterWrite(target, msg('Saved'), bytes) + toast(t('Saved')) +} + +// --------------------------------------------------------------------------- +// Editing slides: the slide list, menus and shortcuts all come through here. + +/** Applies `ops` as one undo step, saying why in a toast when they can't apply. */ +export function change(doc: DeckDoc, label: string, ops: DeckOp[]): ApplyResult | null { + try { + return doc.apply(label, ops) + } catch (e) { + if (!(e instanceof DeckError)) throw e + toast(t('Couldn’t change the deck: {error}', { error: e.message }), 'error') + return null + } +} + +/** The slides an action applies to: the picked ones, or else the one on screen. */ +export function targetIds(doc: DeckDoc): string[] { + const slides = doc.deck.peek().slides + const picked = doc.selection.peek().filter((id) => slides.some((s) => s.id === id)) + if (picked.length) return picked + const current = slides[doc.current.peek()] + return current ? [current.id] : [] +} + +const indexOf = (doc: DeckDoc, id: string) => doc.deck.peek().slides.findIndex((s) => s.id === id) + +export function goToSlide(doc: DeckDoc, id: string): void { + const i = indexOf(doc, id) + if (i >= 0) doc.current.value = i +} + +export function addSlide(doc: DeckDoc, layout: LayoutId = 'bullets'): void { + const current = doc.deck.peek().slides[doc.current.peek()] + const r = change(doc, msg('Add Slide'), [{ op: 'add', layout, after: current?.id }]) + if (r) { + doc.selection.value = [] + goToSlide(doc, r.added[0]) + } +} + +export function duplicateSlides(doc: DeckDoc): void { + const ids = targetIds(doc) + if (!ids.length) return + const r = change(doc, msg('Duplicate Slide'), ids.map((id) => ({ op: 'duplicate', id }))) + if (r) { + doc.selection.value = r.added + goToSlide(doc, r.added[0]) + } +} + +export function deleteSlides(doc: DeckDoc): void { + const ids = targetIds(doc) + if (!ids.length) return + if (change(doc, msg('Delete Slide'), [{ op: 'delete', ids }])) doc.selection.value = [] +} + +export function toggleHidden(doc: DeckDoc): void { + const ids = targetIds(doc) + const slides = doc.deck.peek().slides + const hide = ids.some((id) => !slides.find((s) => s.id === id)?.hidden) + if (ids.length) change(doc, hide ? msg('Hide Slide') : msg('Show Slide'), [{ op: 'hide', ids, hidden: hide }]) +} + +/** Moves slides to just after `after` (null: to the start), keeping them picked and on screen. */ +export function moveSlides(doc: DeckDoc, ids: string[], after: string | null): void { + if (after !== null && ids.includes(after)) return + if (change(doc, msg('Move Slide'), [{ op: 'move', ids, after }])) goToSlide(doc, ids[0]) +} + +/** One place up or down, as Alt+Up and Alt+Down do. */ +export function nudgeSlides(doc: DeckDoc, dir: -1 | 1): void { + const ids = targetIds(doc) + const slides = doc.deck.peek().slides + const at = ids.map((id) => indexOf(doc, id)).sort((a, b) => a - b) + if (!at.length || (dir < 0 && at[0] === 0) || (dir > 0 && at.at(-1) === slides.length - 1)) return + const rest = slides.filter((s) => !ids.includes(s.id)) + // The slide before the block, moved by one: up puts it after the one two back. + const first = at[0] - (dir < 0 ? 1 : 0) + const restBefore = slides.slice(0, first).filter((s) => !ids.includes(s.id)).length + const anchor = dir < 0 ? restBefore - 1 : restBefore + moveSlides(doc, ids, anchor < 0 ? null : rest[anchor].id) +} + +export function setSlideLayout(doc: DeckDoc, layout: LayoutId): void { + const ids = targetIds(doc) + const r = change(doc, msg('Change Layout'), ids.map((id) => ({ op: 'setLayout', id, layout }))) + const lost = r?.dropped.reduce((n, d) => n + d.slots.length, 0) ?? 0 + if (lost) toast(t('{count, plural, one {# part didn’t fit the new layout. Undo to get it back.} other {# parts didn’t fit the new layout. Undo to get them back.}}', { count: lost })) +} diff --git a/src/state/deckMedia.ts b/src/state/deckMedia.ts new file mode 100644 index 0000000..4a7521c --- /dev/null +++ b/src/state/deckMedia.ts @@ -0,0 +1,25 @@ +/** Object URLs for a deck's pictures, made once per picture and freed when the deck closes. */ +import type { DeckDoc } from './documents' + +const urls = new WeakMap>() + +const TYPES: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', avif: 'image/avif', svg: 'image/svg+xml' } + +export function mediaUrl(doc: DeckDoc, src: string): string | null { + const bytes = doc.media.get(src) + if (!bytes) return null + let map = urls.get(doc) + if (!map) urls.set(doc, (map = new Map())) + let url = map.get(src) + if (!url) { + const type = TYPES[src.slice(src.lastIndexOf('.') + 1).toLowerCase()] ?? 'application/octet-stream' + url = URL.createObjectURL(new Blob([bytes as Uint8Array], { type })) + map.set(src, url) + } + return url +} + +export function releaseMedia(doc: DeckDoc): void { + for (const url of urls.get(doc)?.values() ?? []) URL.revokeObjectURL(url) + urls.delete(doc) +} diff --git a/src/state/documents.ts b/src/state/documents.ts index 136e103..fcf9278 100644 --- a/src/state/documents.ts +++ b/src/state/documents.ts @@ -10,6 +10,9 @@ import { t } from '../i18n' import type { Affine } from '../core/image/transform' import { previewExt, previewFlavor, type PreviewFlavor } from '../core/previews' import { pathKey } from '../core/paths' +import { emptyDeck, slideIdMaker, type Deck } from '../core/deck/model' +import { applyOps, type ApplyResult, type DeckOp } from '../core/deck/ops' +import { releaseMedia } from './deckMedia' let seq = 0 const nextId = (): string => `doc${++seq}` @@ -372,7 +375,73 @@ export class PreviewDoc extends BaseDoc { } } -export type Doc = PdfDoc | ImageDoc | NoticeDoc | ModelDoc | PreviewDoc +/** + * A deck (.gdeck, ADR 0018). Every change, the person's or an AI app's, is a list of deck + * operations applied as one undo step. + */ +export class DeckDoc extends BaseDoc { + readonly kind = 'deck' as const + readonly deck = signal(emptyDeck()) + /** Pictures by media/ path. Only grows while open; saving keeps the ones slides use. */ + readonly media = new Map() + /** Bumped when media changes, so views re-read picture URLs. */ + readonly mediaVersion = signal(0) + readonly current = signal(0) + readonly pageCount = computed(() => this.deck.value.slides.length) + /** Times the fitted size, as in previews. */ + readonly zoom = signal(1) + /** Slides picked in the slide sorter, for moving or deleting several at once. */ + readonly selection = signal([]) + readonly history = new History(80) + readonly historyVersion = signal(0) + constructor(name: string, path: string | null) { + super(name, path) + this.sidebar.value = 'thumbnails' + } + + /** Applies `ops` as one undo step. Throws DeckError, changing nothing, if any is invalid. */ + apply(label: string, ops: DeckOp[]): ApplyResult { + const before = this.deck.peek() + const result = applyOps(before, ops, slideIdMaker(before)) + this.history.push(label, before) + this.deck.value = result.deck + this.dirty.value = true + this.historyVersion.value++ + this.current.value = Math.min(this.current.peek(), Math.max(0, result.deck.slides.length - 1)) + return result + } + + /** Stores a picture under a media/ name no other picture uses, and returns that path. */ + addMedia(fileName: string, bytes: Uint8Array): string { + const dot = fileName.lastIndexOf('.') + const ext = (dot > 0 ? fileName.slice(dot + 1) : 'png').toLowerCase() + const stem = (dot > 0 ? fileName.slice(0, dot) : fileName).replace(/[^\w .()-]/g, '').replace(/^[^\w]+/, '').slice(0, 60) || 'picture' + let path = `media/${stem}.${ext}` + for (let n = 2; this.media.has(path); n++) path = `media/${stem} (${n}).${ext}` + this.media.set(path, bytes) + this.mediaVersion.value++ + return path + } + + undo(): void { + const e = this.history.undo(this.deck.peek()) + if (e) this.restore(e.state) + } + + redo(): void { + const e = this.history.redo(this.deck.peek()) + if (e) this.restore(e.state) + } + + private restore(deck: Deck): void { + this.deck.value = deck + this.dirty.value = true + this.historyVersion.value++ + this.current.value = Math.min(this.current.peek(), Math.max(0, deck.slides.length - 1)) + } +} + +export type Doc = PdfDoc | ImageDoc | NoticeDoc | ModelDoc | PreviewDoc | DeckDoc export const docs = signal([]) export const activeId = signal(null) @@ -389,6 +458,7 @@ export function removeDoc(id: string): void { if (idx < 0) return const doc = list[idx] if (doc.kind === 'pdf') void doc.proxy.peek()?.loadingTask.destroy() + if (doc.kind === 'deck') releaseMedia(doc) const path = doc.path.peek() ?? (doc.kind === 'pdf' ? doc.convertedFrom : null) if (path) { void releaseFile(path) diff --git a/src/state/print.ts b/src/state/print.ts index 25bb728..116c782 100644 --- a/src/state/print.ts +++ b/src/state/print.ts @@ -34,7 +34,8 @@ async function pdfPages(doc: Extract, root: HTMLElement): } export async function printDoc(doc: Doc | null): Promise { - if (!doc || doc.kind === 'notice') return + // Decks print through their PDF export. + if (!doc || doc.kind === 'notice' || doc.kind === 'deck') return const root = document.getElementById('print-root') ?? Object.assign(document.createElement('div'), { id: 'print-root' }) document.body.appendChild(root) root.replaceChildren() diff --git a/src/styles/app.css b/src/styles/app.css index 7d0e0b0..54b1c36 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -4327,3 +4327,133 @@ input[type='range'] { border-bottom: 2px solid var(--accent); background: var(--layer-alt); } + +/* ---------- Decks (src/ui/deck) ---------- */ +.slide-frame { + position: relative; + overflow: hidden; + flex: none; +} +.slide-frame-inner { + position: absolute; + top: 0; + left: 0; + width: 1920px; + height: 1080px; + transform-origin: 0 0; +} +.deck-view { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; +} +.deck-bar { + display: flex; + align-items: center; + gap: 2px; + padding: 6px 12px; + border-bottom: 1px solid var(--stroke); + background: var(--chrome); +} +.deck-bar-gap { + width: 12px; +} +.deck-bar-button { + display: inline-flex; + align-items: center; + gap: 6px; + height: 30px; + padding: 0 10px; + border: 0; + border-radius: var(--radius); + background: transparent; + color: var(--text); + font: inherit; +} +.deck-bar-button:hover:not(:disabled) { + background: var(--subtle-hover); +} +.deck-bar-button:disabled { + color: var(--text-3); +} +.deck-stage { + position: relative; + flex: 1; + min-height: 0; + overflow: auto; + display: flex; + background: var(--canvas); +} +.deck-current, +.deck-empty { + margin: auto; +} +.deck-current { + padding: 32px; +} +.deck-current .slide-frame { + box-shadow: var(--page-shadow); + border-radius: 4px; +} +.deck-current .deck-box { + cursor: text; +} +.deck-current .deck-image { + cursor: pointer; +} +.deck-current .deck-box:hover { + outline: calc(2px / var(--deck-scale, 1)) dashed var(--accent); + outline-offset: calc(4px / var(--deck-scale, 1)); +} +.deck-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + color: var(--text-2); +} +.deck-typing { + position: absolute; + z-index: 3; + margin: 0; + padding: 0; + border: 0; + outline: 2px solid var(--accent); + outline-offset: 4px; + background: transparent; + resize: none; + overflow: hidden; +} +.hidden-slide .slide-frame { + opacity: 0.45; +} +.slide-thumb .thumb-label { + display: inline-flex; + align-items: center; + gap: 4px; +} +.slide-thumb .slide-frame { + border-radius: 4px; + box-shadow: var(--page-shadow); +} +.slide-thumb.current .slide-frame { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.slide-add { + display: flex; + align-items: center; + justify-content: center; + width: calc(100% - 16px); + height: 40px; + margin: 4px 8px 12px; + border: 1px dashed var(--stroke-strong); + border-radius: var(--radius); + background: transparent; + color: var(--text-2); +} +.slide-add:hover { + background: var(--subtle-hover); + color: var(--text); +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 367e26a..466eabf 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -67,6 +67,7 @@ import { ScanDialog } from './dialogs/ScanDialog' import { PdfExportDialog } from './dialogs/PdfExportDialog' import { AdjustColorPanel } from './image/AdjustColorPanel' import { AdjustSizeDialog } from './image/AdjustSizeDialog' +import { DeckView } from './deck/DeckView' import { ExportDialog } from './image/ExportDialog' function Viewer() { @@ -89,6 +90,7 @@ function Viewer() { {doc.kind === 'notice' && } {doc.kind === 'model' && } {doc.kind === 'preview' && } + {doc.kind === 'deck' && } {aiOpen.value && selectingArea.value && } {phone && } diff --git a/src/ui/MenuBar.tsx b/src/ui/MenuBar.tsx index e133f5d..88605b2 100644 --- a/src/ui/MenuBar.tsx +++ b/src/ui/MenuBar.tsx @@ -20,9 +20,9 @@ const HELP: Entry[] = ['help.feedback', 'help.github', '-', 'help.updates', 'hel const ZOOM: Entry[] = ['view.zoomIn', 'view.zoomOut', 'view.actualSize', 'view.zoomToFit'] /** Each kind of file gets its own menu bar, like separate apps would. */ -export const MENUS: Record<'none' | 'pdf' | 'image' | 'model' | 'preview' | 'notice', Record> = { +export const MENUS: Record<'none' | 'pdf' | 'image' | 'model' | 'preview' | 'deck' | 'notice', Record> = { none: { - File: ['file.open', 'file.newFromClipboard', 'file.scan', 'file.newWindow', '-', 'file.batch', 'file.collage', '-', 'file.makeDefault', 'file.settings'], + File: ['file.open', 'file.newDeck', 'file.newFromClipboard', 'file.scan', 'file.newWindow', '-', 'file.batch', 'file.collage', '-', 'file.makeDefault', 'file.settings'], Help: HELP }, pdf: { @@ -71,6 +71,14 @@ export const MENUS: Record<'none' | 'pdf' | 'image' | 'model' | 'preview' | 'not Go: ['go.previous', 'go.next', 'go.first', 'go.last', '-', 'go.nextTab', 'go.previousTab'], Help: HELP }, + deck: { + File: ['file.open', 'file.openWith', 'file.newDeck', 'file.newWindow', '-', 'file.save', 'file.saveAs', 'file.versions', '-', 'file.close', '-', 'file.makeDefault', 'file.settings'], + Edit: ['edit.undo', 'edit.redo', '-', 'edit.delete'], + Slide: ['deck.newSlide', 'deck.duplicateSlide', 'deck.hideSlide', '-', 'deck.moveUp', 'deck.moveDown'], + View: ['view.hideSidebar', 'view.thumbnails', '-', ...ZOOM, '-', 'view.collapseTabs', 'view.fullscreen', 'view.slideshow', '-', 'view.askAi'], + Go: ['go.previous', 'go.next', 'go.first', 'go.last', '-', 'go.nextTab', 'go.previousTab'], + Help: HELP + }, notice: { File: ['file.open', 'file.openWith', 'file.newFromClipboard', 'file.scan', 'file.newWindow', '-', 'file.close', '-', 'file.makeDefault', 'file.settings'], Go: ['go.nextTab', 'go.previousTab'], diff --git a/src/ui/deck/DeckView.tsx b/src/ui/deck/DeckView.tsx new file mode 100644 index 0000000..51d5a6d --- /dev/null +++ b/src/ui/deck/DeckView.tsx @@ -0,0 +1,171 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks' +import type { DeckDoc } from '../../state/documents' +import { contextMenu } from '../../state/ui' +import { addSlide, change, setSlideLayout } from '../../state/deck' +import { layoutIds, LAYOUTS, type LayoutId, type SlotSpec } from '../../core/deck/model' +import { editOps, parseRefKey, slotText } from '../../core/deck/edit' +import type { SlotRef } from '../../core/deck/layout' +import { SLIDE_H, SLIDE_W } from '../../core/deck/layout' +import * as platform from '../../platform' +import { msg, t } from '../../i18n' +import { SlideFrame } from './SlideFrame' +import { layoutLabel } from './labels' +import { Icon } from '../Icon' + +const PAD = 32 +const PICTURES = ['png', 'jpg', 'jpeg', 'webp', 'gif', 'avif', 'svg'] + +/** A text box being typed in: where it sits over the slide and how its text looks. */ +interface Editing { + slideId: string + ref: SlotRef + multiline: boolean + key: string + rect: { left: number; top: number; width: number; height: number } + style: { fontSize: string; fontFamily: string; color: string; lineHeight: string; textAlign: string; paddingTop: string } + text: string +} + +function layoutMenu(e: MouseEvent, run: (id: LayoutId) => void): void { + const r = (e.currentTarget as HTMLElement).getBoundingClientRect() + contextMenu.value = { x: r.left, y: r.bottom + 4, items: layoutIds.map((id) => ({ label: layoutLabel(id), run: () => run(id) })) } +} + +async function pickPicture(doc: DeckDoc, slideId: string, slot: string): Promise { + const [path] = await platform.openDialog({ multiple: false, filters: [{ name: msg('Pictures'), extensions: PICTURES }] }) + if (!path) return + const src = doc.addMedia(platform.baseName(path), await platform.readFile(path)) + const slide = doc.deck.peek().slides.find((s) => s.id === slideId) + const alt = slide?.slots[slot]?.kind === 'image' ? (slide.slots[slot].value as { alt: string }).alt : '' + change(doc, msg('Add Picture'), [{ op: 'setSlot', id: slideId, slot, value: { src, alt } }]) +} + +/** The deck editor: the slide on screen, edited in place. Slides are listed in the sidebar. */ +export function DeckView({ doc }: { doc: DeckDoc }) { + const stage = useRef(null) + const [size, setSize] = useState({ w: 0, h: 0 }) + const [editing, setEditingState] = useState(null) + /** The edit still open, so a blur after Escape or a slide change doesn't save it. */ + const open = useRef(null) + const setEditing = (e: Editing | null) => { + open.current = e + setEditingState(e) + } + const box = useRef(null) + const slides = doc.deck.value.slides + const slide = slides[doc.current.value] + const dark = doc.deck.value.theme === 'wolloDark' + + useLayoutEffect(() => { + const el = stage.current + if (!el) return + const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight })) + ro.observe(el) + return () => ro.disconnect() + }, []) + + // Moving to another slide (or undo) ends typing; what was typed is kept. + useEffect(() => setEditing(null), [slide?.id, doc.historyVersion.value]) + useEffect(() => { + box.current?.focus() + box.current?.select() + }, [editing?.slideId, editing && editing.ref]) + + const fit = Math.max(0, Math.min((size.w - 2 * PAD) / SLIDE_W, (size.h - 2 * PAD) / SLIDE_H)) + const width = Math.round(SLIDE_W * fit * doc.zoom.value) + + const commit = (e: Editing | null, text: string) => { + if (!e || open.current !== e) return + setEditing(null) + const target = doc.deck.peek().slides.find((s) => s.id === e.slideId) + if (!target) return + const ops = editOps(target, e.ref, text) + if (ops.length) change(doc, msg('Typing'), ops) + } + + const onSlideClick = (ev: MouseEvent) => { + if (!slide) return + const el = (ev.target as HTMLElement).closest('[data-ref]') + if (!el || !stage.current) return + const ref = parseRefKey(el.dataset.ref!) + if (el.classList.contains('deck-image')) return void pickPicture(doc, slide.id, ref.slot) + const spec = (LAYOUTS[slide.layout] as SlotSpec[]).find((s) => s.name === ref.slot) + const r = el.getBoundingClientRect() + const s = stage.current.getBoundingClientRect() + const cs = getComputedStyle(el) + const scale = r.width / el.offsetWidth + // Start the typing where the text sits, so a bottom-aligned title doesn't jump. + const first = el.firstElementChild as HTMLElement | null + const offset = first && el.dataset.ref && !el.classList.contains('empty') ? first.offsetTop * scale : 0 + setEditing({ + slideId: slide.id, + ref, + key: el.dataset.ref!, + multiline: spec?.kind === 'list' || (spec?.maxChars ?? 0) > 100, + rect: { left: r.left - s.left + stage.current.scrollLeft, top: r.top - s.top + stage.current.scrollTop, width: r.width, height: r.height }, + style: { fontSize: `${parseFloat(cs.fontSize) * scale}px`, fontFamily: cs.fontFamily, color: cs.color, lineHeight: cs.lineHeight, textAlign: cs.textAlign, paddingTop: `${offset}px` }, + text: slotText(slide, ref) + }) + } + + return ( +
    + +
    + {slide ? ( +
    + {width > 0 && } +
    + ) : ( +
    +

    {t('This deck has no slides yet.')}

    + +
    + )} + {/* The box being typed in is hidden underneath, so its old text doesn't show through. */} + {editing && } + {editing && ( +