From 93ac1a69fef0de5777a778dc440de5882e667849 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 6 Oct 2026 19:36:39 +1100 Subject: [PATCH 1/7] Let Writer store figures in the collection Backends can write a file (Connect uploads it, the demo keeps it in memory); the workspace stores an image beside the manuscript's other figures, tells the compiler at once, and hands the editor object URLs for images it has loaded, so figures can be drawn where they are written. --- apps/writer/src/backend/connect.ts | 14 ++++++ apps/writer/src/backend/demo.ts | 8 ++++ apps/writer/src/backend/types.ts | 6 ++- apps/writer/src/workspace/workspace.ts | 66 ++++++++++++++++++++++++-- 4 files changed, 89 insertions(+), 5 deletions(-) diff --git a/apps/writer/src/backend/connect.ts b/apps/writer/src/backend/connect.ts index fe59ffd..0620d82 100644 --- a/apps/writer/src/backend/connect.ts +++ b/apps/writer/src/backend/connect.ts @@ -435,6 +435,20 @@ export class ConnectBackend implements WriterBackend { } } + async writeFile(path: string, bytes: Uint8Array, mediaType?: string): Promise> { + let target = path; + for (let n = 2; this.files.has(target) && n <= 50; n++) target = numberedPath(path, n); + try { + const stored = await this.connection.files.upload(target, bytes, { ...(mediaType ? { mediaType } : {}), signal: this.lifetime.signal }); + this.files.set(stored.path, stored); + // The index learns the file now; the watch's file.put only confirms it. + this.acceptDelta(this.store.files([stored.path])); + return ok(stored.path); + } catch (e) { + return fail(errorMessage(e)); + } + } + onCollectionChange(listener: (delta: CollectionDelta) => void): () => void { this.dataListeners.add(listener); return () => this.dataListeners.delete(listener); diff --git a/apps/writer/src/backend/demo.ts b/apps/writer/src/backend/demo.ts index 1384bd4..9f38026 100644 --- a/apps/writer/src/backend/demo.ts +++ b/apps/writer/src/backend/demo.ts @@ -180,6 +180,14 @@ export async function createDemoBackend(): Promise { if (!url) return fail(`No file at ${path}.`); return ok(new Uint8Array(await (await fetch(url)).arrayBuffer())); }, + async writeFile(path: string, bytes: Uint8Array, mediaType?: string) { + let target = path; + for (let n = 2; files.has(target) && n <= 50; n++) target = numberedPath(path, n); + files.set(target, URL.createObjectURL(new Blob([bytes as BlobPart], mediaType ? { type: mediaType } : {}))); + const delta = store.files([target]); + for (const listener of dataListeners) listener({ ...delta, paths: [target] }); + return ok(target); + }, async comments(scope) { const out: CommentRecord[] = []; for (const path of store.commentScope(scope)) { diff --git a/apps/writer/src/backend/types.ts b/apps/writer/src/backend/types.ts index dcf8c16..68922c5 100644 --- a/apps/writer/src/backend/types.ts +++ b/apps/writer/src/backend/types.ts @@ -103,6 +103,8 @@ export interface WriterBackend { /** Only this source's Reader annotations (including relative and bare source links). */ annotationsForSource(path: string): Promise>; readFile(path: string): Promise>; + /** Stores a file (an image for a figure) at `path`, or at `path` numbered when that is taken; resolves to its path. */ + writeFile?(path: string, bytes: Uint8Array, mediaType?: string): Promise>; /** A Markdown record's body, read once (no record session). */ readBody(path: string): Promise>; /** Metadata selects threads for these manuscript records; only their bodies are read. No scope means all. */ @@ -150,8 +152,8 @@ export function libraryEntry(path: string, frontmatter: JsonObject | undefined): return { key, item: { ...item, id: key }, title, path }; } -/** `chapters/x.md` numbered for the nth try: `chapters/x-2.md`. */ -export const numberedPath = (path: string, n: number) => (n > 1 ? path.replace(/(\.md)?$/i, `-${n}$1`) : path); +/** `chapters/x.md` numbered for the nth try: `chapters/x-2.md` (and `figures/x.png` to `figures/x-2.png`). */ +export const numberedPath = (path: string, n: number) => (n > 1 ? path.replace(/(\.[a-z0-9]{1,5})?$/i, `-${n}$1`) : path); export function manuscriptSlug(title: string): string { const slug = title diff --git a/apps/writer/src/workspace/workspace.ts b/apps/writer/src/workspace/workspace.ts index 72e5105..e11d207 100644 --- a/apps/writer/src/workspace/workspace.ts +++ b/apps/writer/src/workspace/workspace.ts @@ -10,7 +10,7 @@ import { ManuscriptAssembler, translateRecord, type WriterRecord, type AssemblyI import { applySuggestion, bodyHash, linkPath, targetFor, type CommentRecord, type CommentThread } from "@mdbase-writer/core/comments"; import { BUNDLE_README, materialize } from "@mdbase-writer/core/materialize"; import { isAnnotation } from "@mdbase-writer/core/annotations"; -import { PathIndex, pathIndex, resolveLinkTarget } from "@mdbase-writer/core/records"; +import { IMAGE_EXTENSION, PathIndex, pathIndex, resolveLinkTarget } from "@mdbase-writer/core/records"; import { LOCALES, STYLES } from "@mdbase-writer/core/styles"; import { NO_PEOPLE, type CommentChange, type People } from "../backend/comments.js"; @@ -89,6 +89,8 @@ export interface WorkspaceSnapshot { /** Records the collection lists as Reader annotations (an embedded one is a quotation). */ readonly annotationPaths: ReadonlySet; readonly annotationVersion: number; + /** Increases as images and other files load, so views drawing them redraw. */ + readonly assetVersion: number; readonly compiling: boolean; readonly pendingSettings: boolean; /** Every comment in the collection; the UI keeps those on this manuscript's records. */ @@ -118,6 +120,8 @@ export class ManuscriptWorkspace { private readonly drafts: DraftStore; private readonly stagedSettings = new Map(); private readonly assetBytes = new Map(); + /** Object URLs of images shown in the editor, revoked when the manuscript closes. */ + private readonly imageUrls = new Map(); private readonly assetJobs = new Map>(); private readonly assetAttempts = new Map(); private readonly retryTimers = new Set>(); @@ -151,7 +155,7 @@ export class ManuscriptWorkspace { readonly main: string, ) { this.drafts = new DraftStore(backend.draftNamespace ?? `${backend.kind}:${backend.collectionName}`); - this.current = { main, phase: "loading", indexLoad: LOADING, libraryLoad: LOADING, annotationsLoad: LOADING, commentsLoad: LOADING, records: new Map(), library: [], recordPaths: [], filePaths: [], recordIndex: new PathIndex([]), fileIndex: new PathIndex([]), annotationPaths: new Set(), annotationVersion: 0, compiling: true, pendingSettings: false, comments: [], people: NO_PEOPLE, recoveredDrafts: new Map(), assetProblems: new Map(), recordProblems: new Map() }; + this.current = { main, phase: "loading", indexLoad: LOADING, libraryLoad: LOADING, annotationsLoad: LOADING, commentsLoad: LOADING, records: new Map(), library: [], recordPaths: [], filePaths: [], recordIndex: new PathIndex([]), fileIndex: new PathIndex([]), annotationPaths: new Set(), annotationVersion: 0, assetVersion: 0, compiling: true, pendingSettings: false, comments: [], people: NO_PEOPLE, recoveredDrafts: new Map(), assetProblems: new Map(), recordProblems: new Map() }; this.followCompiler(); this.cleanups.push(backend.onExternalChange((paths) => this.onExternalChange(paths))); if (backend.onCollectionChange) this.cleanups.push(backend.onCollectionChange((delta) => this.onCollectionChange(delta))); @@ -585,10 +589,12 @@ export class ManuscriptWorkspace { if (this.disposed) return; this.assetAttempts.delete(path); this.assetBytes.set(path, read.value); + const shown = this.imageUrls.get(path); + if (shown) { URL.revokeObjectURL(shown); this.imageUrls.delete(path); } if (/\.(csl|typ)$/i.test(path)) this.texts.set(path, new TextDecoder().decode(read.value)); const assetProblems = new Map(this.current.assetProblems); assetProblems.delete(path); - this.update({ assetProblems }); + this.update({ assetProblems, assetVersion: this.current.assetVersion + 1 }); this.sendAssets([[path, read.value]]); } catch (error) { if (this.disposed) return; @@ -742,6 +748,53 @@ export class ManuscriptWorkspace { return ok(path); } + /** Whether the collection accepts files, so a figure can be stored from the editor. */ + get canStoreFiles(): boolean { + return typeof this.backend.writeFile === "function"; + } + + /** + * Stores an image in the collection, next to the manuscript's other figures + * (its `figures` folder unless one is already used), and resolves to its + * path. The index learns the file at once, so the figure typesets without a + * fresh listing. + */ + async addFigure(file: { name: string; bytes: Uint8Array; type?: string }): Promise> { + if (!this.backend.writeFile) return fail("This collection does not accept files from Writer."); + const extension = /\.[a-z0-9]{1,5}$/i.exec(file.name)?.[0]?.toLowerCase() ?? ""; + if (!IMAGE_EXTENSION.test(extension)) return fail("Figures must be PNG, JPEG, GIF, SVG or WebP images."); + const stem = manuscriptSlug(file.name.slice(0, file.name.length - extension.length)) || "figure"; + const used = [...this.current.fileIndex].find((p) => IMAGE_EXTENSION.test(p) && p.includes("/")); + const folder = used ? used.slice(0, used.lastIndexOf("/")) : "figures"; + const stored = await this.backend.writeFile(`${folder}/${stem}${extension}`, file.bytes, file.type); + if (!stored.ok) return stored; + const path = stored.value; + this.assetBytes.set(path, file.bytes); + if (!this.current.fileIndex.has(path)) { + const filePaths = [...this.current.filePaths, path]; + this.update({ filePaths }); + this.compile.send({ type: "collection", recordPaths: this.current.recordPaths, filePaths }); + } + return ok(path); + } + + /** An object URL for an image the manuscript uses, once its bytes are loaded for the preview; null until then. */ + imageUrl(target: string, from: string): string | null { + const path = resolveLinkTarget(target, from, this.current.fileIndex, ""); + if (!path) return null; + const known = this.imageUrls.get(path); + if (known) return known; + const bytes = this.assetBytes.get(path); + if (!bytes) { + // Not needed by the compiler yet (a figure just written); load it for the card. + if (!this.requestedAssets.has(path)) void this.loadAssets([path]); + return null; + } + const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type: imageType(path) })); + this.imageUrls.set(path, url); + return url; + } + /** Focused setting drafts are backed up immediately, without typesetting half-parsed fields. */ stageFrontmatter(path: string, patch: JsonObject): void { if (this.current.recoveredDrafts.has(path)) return; @@ -984,6 +1037,8 @@ export class ManuscriptWorkspace { if (this.disposed) return; this.commitSettings(); this.disposed = true; + for (const url of this.imageUrls.values()) URL.revokeObjectURL(url); + this.imageUrls.clear(); clearTimeout(this.refreshTimer); clearTimeout(this.commentsTimer); for (const timer of this.retryTimers) clearTimeout(timer); @@ -998,3 +1053,8 @@ export class ManuscriptWorkspace { this.leases.clear(); } } + +const IMAGE_TYPES: Record = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", svg: "image/svg+xml", webp: "image/webp" }; +function imageType(path: string): string { + return IMAGE_TYPES[path.slice(path.lastIndexOf(".") + 1).toLowerCase()] ?? "application/octet-stream"; +} From 20bc77ad607c5f395889b8c03044faf377308b32 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 6 Oct 2026 19:36:39 +1100 Subject: [PATCH 2/7] Read the editor as the manuscript, not its source Citations show who they cite, cross-references what they point to, footnote markers their numbers, labels as small tags; headings, emphasis and links lose their marks; figures, tables, code fences and display math draw as blocks. The line the cursor is on, and any block the selection touches, shows the Markdown as written. Hover and Mod-click resolve a chip's key through the syntax tree, since a chip has no positions of its own. Off, the editor shows the source everywhere. --- apps/writer/src/editor/Editor.tsx | 96 +++- apps/writer/src/editor/insight.ts | 47 +- apps/writer/src/editor/live-preview.test.ts | 28 ++ apps/writer/src/editor/live-preview.ts | 486 ++++++++++++++++++++ apps/writer/src/ui/layout.ts | 5 +- 5 files changed, 641 insertions(+), 21 deletions(-) create mode 100644 apps/writer/src/editor/live-preview.test.ts create mode 100644 apps/writer/src/editor/live-preview.ts diff --git a/apps/writer/src/editor/Editor.tsx b/apps/writer/src/editor/Editor.tsx index 779a9ef..f463e4d 100644 --- a/apps/writer/src/editor/Editor.tsx +++ b/apps/writer/src/editor/Editor.tsx @@ -1,12 +1,12 @@ // The Markdown editor for one record. The record session owns the text; the // editor reports edits and adopts external changes as remote transactions. import { mdbasePopupTheme } from "@mdbase-dev/ui/codemirror"; -import { autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap } from "@codemirror/autocomplete"; +import { autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap, startCompletion } from "@codemirror/autocomplete"; import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands"; import { bracketMatching, indentOnInput } from "@codemirror/language"; import { lintGutter, setDiagnostics, type Diagnostic as CmDiagnostic } from "@codemirror/lint"; import { highlightSelectionMatches, search, searchKeymap } from "@codemirror/search"; -import { Annotation, Compartment, EditorState, StateEffect, Transaction } from "@codemirror/state"; +import { Annotation, Compartment, EditorSelection, EditorState, StateEffect, Transaction } from "@codemirror/state"; import { drawSelection, EditorView, highlightActiveLine, keymap, placeholder } from "@codemirror/view"; import { memo, useEffect, useLayoutEffect, useRef } from "react"; @@ -20,14 +20,20 @@ import { collapseToEnd, selectionBar, type SelectionAction } from "./selection-b import { joinLines as joinLinesExtension } from "./join-lines.js"; import { writerInsight, type EditorInsight, type FollowTarget } from "./insight.js"; import { writerLanguage } from "./language.js"; +import { livePreview as livePreviewExtension, refreshLivePreview } from "./live-preview.js"; +import { nextFootnoteId, type Snippet } from "./snippets.js"; const remote = Annotation.define(); export interface EditorHandle { /** Scrolls to `offset` (selecting up to `to`, when given) and focuses the editor unless `focus` is false. */ reveal(offset: number, options?: { to?: number; focus?: boolean }): void; - /** Replaces the selection with text (a citation, a quotation) and leaves the cursor after it. */ - insert(text: string): void; + /** Replaces the selection with text (a citation, a quotation) and leaves the cursor after it; with `complete`, opens completions there. */ + insert(text: string, options?: { complete?: boolean }): void; + /** Inserts a block (a figure, a table) on lines of its own at the cursor, selecting the snippet's placeholder. */ + insertBlock(snippet: Snippet): void; + /** Puts a footnote marker at the cursor and its definition at the end of the record, ready to write. */ + insertFootnote(): void; /** The main selection (UTF-16 offsets) and the text it is in. */ selection(): { from: number; to: number; text: string }; /** Formats the selection as Markdown (toggling bold, italic or code), or makes it a link. */ @@ -47,6 +53,10 @@ export interface EditorProps { readOnly: boolean; /** Draws a paragraph's soft line breaks as spaces, so hard-wrapped text flows. */ joinLines?: boolean; + /** Draws citations, figures, tables and marks as the manuscript reads, rather than as Markdown source. */ + livePreview?: boolean; + /** Images pasted or dropped into the text, to store in the collection as figures. */ + onImageFiles?(files: readonly File[]): void; diagnostics: readonly WriterDiagnostic[]; completion: CompletionData; insight: EditorInsight; @@ -69,7 +79,7 @@ export interface EditorProps { onSelectionAction?(action: SelectionAction, selected: string): void; } -export const Editor = memo(function Editor({ states, path, text, readOnly, joinLines = false, diagnostics, completion, insight, onChange, onReady, onCursor, onFollow, chapters, onFindSource, anchors, activeComment = null, onAnchor, onSelectionAction }: EditorProps) { +export const Editor = memo(function Editor({ states, path, text, readOnly, joinLines = false, livePreview = true, onImageFiles, diagnostics, completion, insight, onChange, onReady, onCursor, onFollow, chapters, onFindSource, anchors, activeComment = null, onAnchor, onSelectionAction }: EditorProps) { const host = useRef(null); const view = useRef(null); // Texts this editor reported, newest last. The session echoes them back @@ -80,14 +90,40 @@ export const Editor = memo(function Editor({ states, path, text, readOnly, joinL // replaces the line's DOM, which would cancel a hover in progress. const shownDiagnostics = useRef(""); const joining = useRef(new Compartment()); - const latest = useRef({ onChange, completion, insight, onCursor, onFollow, chapters, onFindSource, onAnchor, onSelectionAction }); - latest.current = { onChange, completion, insight, onCursor, onFollow, chapters, onFindSource, onAnchor, onSelectionAction }; + const preview = useRef | null>(null); + const latest = useRef({ onChange, completion, insight, onCursor, onFollow, chapters, onFindSource, onAnchor, onSelectionAction, onImageFiles }); + latest.current = { onChange, completion, insight, onCursor, onFollow, chapters, onFindSource, onAnchor, onSelectionAction, onImageFiles }; // Layout cleanup snapshots the viewport before React detaches its DOM (which // would reset scrollTop), including development's setup/cleanup/setup cycle. useLayoutEffect(() => { if (!host.current) return; + preview.current = livePreviewExtension(() => latest.current.insight, livePreview); + const imageFiles = (list: FileList | null | undefined) => { + const files = [...(list ?? [])].filter((f) => /^image\//.test(f.type)); + return files.length ? files : null; + }; const extensions = [ + preview.current.extension, + EditorView.domEventHandlers({ + paste(event, view) { + const files = imageFiles(event.clipboardData?.files); + if (!files || view.state.readOnly || !latest.current.onImageFiles) return false; + event.preventDefault(); + latest.current.onImageFiles(files); + return true; + }, + drop(event, view) { + const files = imageFiles(event.dataTransfer?.files); + if (!files || view.state.readOnly || !latest.current.onImageFiles) return false; + event.preventDefault(); + const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }); + if (pos !== null) view.dispatch({ selection: EditorSelection.cursor(pos) }); + view.focus(); + latest.current.onImageFiles(files); + return true; + }, + }), history(), drawSelection(), indentOnInput(), @@ -153,10 +189,45 @@ export const Editor = memo(function Editor({ states, path, text, readOnly, joinL command(v); v.focus(); }, - insert(insertion) { + insert(insertion, options) { if (v.state.readOnly) return; v.dispatch(v.state.replaceSelection(insertion), { scrollIntoView: true, userEvent: "input" }); v.focus(); + if (options?.complete) startCompletion(v); + }, + insertBlock(snippet) { + if (v.state.readOnly) return; + // A block goes after whatever is selected; it never replaces text. + const from = v.state.selection.main.to; + const to = from; + const line = v.state.doc.lineAt(from); + const after = line; + // On lines of its own: blank lines open before and after as needed. + const before = line.from === from ? (line.number > 1 && v.state.doc.line(line.number - 1).text.trim() ? "\n" : "") : (line.text.slice(0, from - line.from).trim() ? "\n\n" : "\n"); + const rest = after.text.slice(to - after.from); + const next = after.number < v.state.doc.lines ? v.state.doc.line(after.number + 1).text : ""; + const trailing = rest.trim() ? "\n\n" : next.trim() ? "\n" : ""; + const text = `${before}${snippet.text}${trailing}`; + const start = from + before.length + snippet.select[0]; + const end = from + before.length + snippet.select[1]; + v.dispatch({ changes: { from, to, insert: text }, selection: EditorSelection.range(start, end), scrollIntoView: true, userEvent: "input" }); + v.focus(); + }, + insertFootnote() { + if (v.state.readOnly) return; + const id = nextFootnoteId(v.state.doc.toString()); + const at = v.state.selection.main.to; + const end = v.state.doc.length; + const tail = v.state.doc.sliceString(Math.max(0, end - 2), end); + const gap = end === 0 ? "" : tail.endsWith("\n\n") ? "" : tail.endsWith("\n") ? "\n" : "\n\n"; + const definition = `${gap}[^${id}]: `; + v.dispatch({ + changes: [{ from: at, insert: `[^${id}]` }, { from: end, insert: definition }], + selection: EditorSelection.cursor(end + `[^${id}]`.length + definition.length), + scrollIntoView: true, + userEvent: "input", + }); + v.focus(); }, }); return () => { @@ -199,6 +270,15 @@ export const Editor = memo(function Editor({ states, path, text, readOnly, joinL view.current?.dispatch({ effects: joining.current.reconfigure(joinLines ? joinLinesExtension() : []) }); }, [joinLines]); + useEffect(() => { + if (view.current) preview.current?.set(view.current, livePreview); + }, [livePreview]); + + // Chips and figures follow the sources, labels and images they draw. + useEffect(() => { + view.current?.dispatch({ effects: refreshLivePreview.of(null) }); + }, [insight]); + // Chapter cards follow their records' titles, words and problems. useEffect(() => { view.current?.dispatch({ effects: refreshChapterCards.of(null) }); diff --git a/apps/writer/src/editor/insight.ts b/apps/writer/src/editor/insight.ts index 1e13164..8c5e7ab 100644 --- a/apps/writer/src/editor/insight.ts +++ b/apps/writer/src/editor/insight.ts @@ -2,7 +2,9 @@ // `@key` to see the source's bibliography entry or the labelled block it // points to; Mod-click it to go there. Label attributes (`{#sec-intro}`) and // footnote markers are drawn quieter than the prose around them. -import { Decoration, EditorView, hoverTooltip, MatchDecorator, ViewPlugin, type DecorationSet, type ViewUpdate } from "@codemirror/view"; +import { syntaxTree } from "@codemirror/language"; +import type { EditorState } from "@codemirror/state"; +import { Decoration, EditorView, hoverTooltip, MatchDecorator, ViewPlugin, type DecorationSet, type Tooltip, type ViewUpdate } from "@codemirror/view"; import type { LibraryEntry } from "../backend/types.js"; import { authorYear } from "./library-search.js"; @@ -20,6 +22,8 @@ export interface EditorInsight { /** Bibliography entries (Typst markup) of the sources the manuscript cites. */ readonly references: ReadonlyMap; readonly labels: ReadonlyMap; + /** A URL for an image the record refers to, once it is loaded (for drawing figures in the editor). */ + readonly image?: (target: string) => string | null; } export type FollowTarget = { readonly kind: "source"; readonly key: string } | { readonly kind: "label"; readonly target: LabelTarget }; @@ -37,6 +41,25 @@ export function referenceAt(line: string, offset: number): { key: string; from: return null; } +/** + * The reference at or beside a document position: the `@key` the position is + * in, or, when the position is at the edge of a citation or cross-reference + * drawn as a chip (which has no positions inside it), that element's first key. + */ +export function referenceNear(state: EditorState, pos: number): { key: string; from: number; to: number } | null { + const line = state.doc.lineAt(pos); + const inLine = referenceAt(line.text, pos - line.from); + if (inLine) return { key: inLine.key, from: line.from + inLine.from, to: line.from + inLine.to }; + for (const side of [1, -1] as const) { + let node: { name: string; from: number; to: number; parent: { name: string; from: number; to: number; parent: unknown } | null } | null = syntaxTree(state).resolveInner(pos, side); + while (node && node.name !== "Citation" && node.name !== "AtReference") node = node.parent as typeof node; + if (!node) continue; + const key = referenceKeys(state.sliceDoc(node.from, node.to))[0]; + if (key) return { key, from: node.from, to: node.to }; + } + return null; +} + /** Every `@key` in a text, citations and cross-references alike. */ export function referenceKeys(text: string): string[] { return [...text.matchAll(KEY)].map((m) => (m[3] ?? "").replace(/[.:,;?~/#$%&+<>-]+$/u, "")).filter(Boolean); @@ -151,7 +174,7 @@ function tip(kind: string, body: (el: HTMLElement) => void, hint?: string): HTML return dom; } -const LABEL_KINDS: Record = { sec: "Section", fig: "Figure", tbl: "Table", eq: "Equation", lst: "Listing" }; +export const LABEL_KINDS: Record = { sec: "Section", fig: "Figure", tbl: "Table", eq: "Equation", lst: "Listing" }; function resolve(insight: EditorInsight, key: string): FollowTarget | null { const label = insight.labels.get(key); @@ -160,16 +183,14 @@ function resolve(insight: EditorInsight, key: string): FollowTarget | null { return null; } -export function writerInsight(insight: () => EditorInsight, follow: (target: FollowTarget) => void) { - const hover = hoverTooltip((view, pos) => { - const line = view.state.doc.lineAt(pos); - const ref = referenceAt(line.text, pos - line.from); +/** The card for the citation or cross-reference at `pos`: its bibliography entry, or what it labels. */ +export function referenceTooltip(view: EditorView, pos: number, data: EditorInsight): Tooltip | null { + const ref = referenceNear(view.state, pos); if (!ref) return null; - const data = insight(); const target = resolve(data, ref.key); return { - pos: line.from + ref.from, - end: line.from + ref.to, + pos: ref.from, + end: ref.to, above: true, create: () => { if (target?.kind === "label") { @@ -196,15 +217,17 @@ export function writerInsight(insight: () => EditorInsight, follow: (target: Fol return { dom: tip("Unknown", (el) => (el.textContent = `No source or label is called “${ref.key}”.`)) }; }, }; - }); +} + +export function writerInsight(insight: () => EditorInsight, follow: (target: FollowTarget) => void) { + const hover = hoverTooltip((view, pos) => referenceTooltip(view, pos, insight())); const click = EditorView.domEventHandlers({ mousedown(event, view) { if (!(isMac ? event.metaKey : event.ctrlKey) || event.button !== 0) return false; const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }); if (pos === null) return false; - const line = view.state.doc.lineAt(pos); - const ref = referenceAt(line.text, pos - line.from); + const ref = referenceNear(view.state, pos); const target = ref ? resolve(insight(), ref.key) : null; if (!target) return false; event.preventDefault(); diff --git a/apps/writer/src/editor/live-preview.test.ts b/apps/writer/src/editor/live-preview.test.ts new file mode 100644 index 0000000..53a7c1a --- /dev/null +++ b/apps/writer/src/editor/live-preview.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import type { LibraryEntry } from "../backend/types.js"; +import { citationText } from "./live-preview.js"; + +const entry = (key: string, family: string, year: number): LibraryEntry => ({ key, path: `${key}.md`, title: key, item: { id: key, author: [{ family }], issued: { "date-parts": [[year]] } } }); +const insight = { + library: new Map([entry("darwin42", "Darwin", 1842), entry("lyell30", "Lyell", 1830)].map((e) => [e.key, e])), + labels: new Map([["sec-intro", { record: "a.md", offset: 0, text: "Introduction" }]]), +}; + +describe("citation chips", () => { + it("reads a citation as its authors and years, with prefixes and locators", () => { + expect(citationText("@darwin42", insight)).toBe("Darwin, 1842"); + expect(citationText("see @darwin42, p. 12; also @lyell30, chap. 1", insight)).toBe("see Darwin, 1842, p. 12; also Lyell, 1830, chap. 1"); + }); + it("keeps a locator written without a comma, and the year alone for a suppressed author", () => { + expect(citationText("@darwin42 490", insight)).toBe("Darwin, 1842, 490"); + expect(citationText("-@darwin42", insight)).toBe("1842"); + }); + it("does not stand in for a key it cannot resolve, so the problem stays visible", () => { + expect(citationText("@nobody99", insight)).toBeNull(); + expect(citationText("@darwin42; @nobody99", insight)).toBeNull(); + }); + it("names a labelled target cited in brackets", () => { + expect(citationText("@sec-intro", insight)).toBe("Introduction"); + }); +}); diff --git a/apps/writer/src/editor/live-preview.ts b/apps/writer/src/editor/live-preview.ts new file mode 100644 index 0000000..b3cbac7 --- /dev/null +++ b/apps/writer/src/editor/live-preview.ts @@ -0,0 +1,486 @@ +// The editor reads like the manuscript rather than its source: citations +// show as the author and year they cite, cross-references as what they point +// to, footnote markers as their numbers, labels as small tags, and headings, +// emphasis and links without their marks. Figures, tables, code fences and +// display math draw as blocks. The Markdown is never changed: any line the +// cursor is on (and any block the selection touches) shows exactly what is +// written, so there is nothing hidden where you are typing. +import { syntaxTree } from "@codemirror/language"; +import { Compartment, StateEffect, StateField, type EditorState, type Extension, type Range } from "@codemirror/state"; +import { Decoration, EditorView, ViewPlugin, WidgetType, type DecorationSet, type ViewUpdate } from "@codemirror/view"; + +import { authorYear } from "./library-search.js"; +import { LABEL_KINDS, parseReference, type EditorInsight, type MarkupNode } from "./insight.js"; + +type Tree = ReturnType; +type SyntaxNode = Tree["topNode"]; + +/** Asks the preview to redraw (sources, labels or images changed). */ +export const refreshLivePreview = StateEffect.define(); + +type Span = { readonly from: number; readonly to: number }; + +/** Line ranges that hold a selection: they show their source as written. */ +function activeSpans(state: EditorState): Span[] { + return state.selection.ranges.map((r) => ({ from: state.doc.lineAt(r.from).from, to: state.doc.lineAt(r.to).to })); +} +const touches = (spans: readonly Span[], from: number, to: number) => spans.some((s) => s.from <= to && s.to >= from); + +const CITEKEY_CHARS = "[\\p{L}\\p{N}_][\\p{L}\\p{N}_:.#$%&\\-+?<>~/]*"; +const CITE_ITEM = new RegExp(`^(.*?)(-?@)(${CITEKEY_CHARS})([\\s\\S]*)$`, "u"); +const TRAILING = /[.:,;?~/#$%&+<>-]+$/u; +const FOOTNOTE_REF = /\[\^([^\]\s]+)\](?!:)/g; +const FOOTNOTE_DEF = /^\[\^([^\]\s]+)\]:[ \t]*(.*)$/gm; +const ATTRIBUTE = /\{[#.][^}\n]*\}/g; +const FIGURE = /^!\[([\s\S]*?)\]\(([^)\n]*)\)(\{[^}\n]*\})?\s*$/; +const TABLE_CAPTION = /^(?::|Table:)\s*([\s\S]*?)\s*(\{[^}]*\})?\s*$/; + +/** Plain text of a bibliography entry's Typst markup. */ +function plainReference(typst: string): string { + const text = (node: MarkupNode): string => node.children.map((c) => (typeof c === "string" ? c : text(c))).join(""); + return text(parseReference(typst)); +} + +/** What a citation reads as: "see Darwin, 1842, p. 12; Lyell, 1830", or null when a key is unknown. */ +export function citationText(inner: string, insight: Pick): string | null { + const parts: string[] = []; + for (const item of inner.split(";")) { + const m = CITE_ITEM.exec(item); + if (!m) return null; + const rawKey = m[3] ?? ""; + const key = rawKey.replace(TRAILING, ""); + const spill = rawKey.slice(key.length); + const entry = insight.library.get(key); + const label = entry ? null : insight.labels.get(key); + if (!entry && !label) return null; + let name = entry ? authorYear(entry) || key : (label?.text ?? key); + if (entry && m[2] === "-@") name = name.slice(name.lastIndexOf(", ") + 2); + const prefix = (m[1] ?? "").trim(); + const suffix = `${spill}${m[4] ?? ""}`.replace(/^[\s,]+/, "").trim(); + parts.push([prefix, [name, suffix].filter(Boolean).join(", ")].filter(Boolean).join(" ")); + } + return parts.join("; "); +} + +class ChipWidget extends WidgetType { + constructor(readonly text: string, readonly kind: "cite" | "ref" | "label", readonly title: string) { + super(); + } + override eq(other: ChipWidget) { + return other.text === this.text && other.kind === this.kind && other.title === this.title; + } + toDOM() { + const el = document.createElement("span"); + el.className = `cm-lp-chip is-${this.kind}`; + el.textContent = this.text; + // Citations and cross-references get the editor's hover card (insight.ts); a label's title says how to refer to it. + if (this.title && this.kind === "label") el.title = this.title; + return el; + } + override ignoreEvent() { + return false; + } +} + +class SupWidget extends WidgetType { + constructor(readonly number: number, readonly title: string, readonly definition: boolean) { + super(); + } + override eq(other: SupWidget) { + return other.number === this.number && other.title === this.title && other.definition === this.definition; + } + toDOM() { + const el = document.createElement(this.definition ? "span" : "sup"); + el.className = this.definition ? "cm-lp-fn-number" : "cm-lp-fn-ref"; + el.textContent = this.definition ? `${this.number}.` : String(this.number); + if (this.title) el.title = this.title; + return el; + } + override ignoreEvent() { + return false; + } +} + +function block(className: string): HTMLDivElement { + const el = document.createElement("div"); + el.className = `cm-lp-block ${className}`; + return el; +} + +class FigureWidget extends WidgetType { + constructor(readonly caption: string, readonly src: string, readonly label: string, readonly url: string | null) { + super(); + } + override eq(other: FigureWidget) { + return other.caption === this.caption && other.src === this.src && other.label === this.label && other.url === this.url; + } + toDOM() { + const el = block("cm-lp-figure"); + if (this.url) { + const img = document.createElement("img"); + img.src = this.url; + img.alt = this.caption; + img.className = "cm-lp-figure-image"; + el.append(img); + } else { + const missing = document.createElement("div"); + missing.className = "cm-lp-figure-missing"; + missing.textContent = this.src; + el.append(missing); + } + const caption = document.createElement("div"); + caption.className = "cm-lp-caption"; + caption.textContent = this.caption || "Untitled figure"; + if (this.label) { + const tag = document.createElement("span"); + tag.className = "cm-lp-chip is-label"; + tag.textContent = this.label; + caption.append(" ", tag); + } + el.append(caption); + return el; + } + override ignoreEvent() { + return false; + } +} + +class TableWidget extends WidgetType { + constructor(readonly header: readonly string[], readonly rows: readonly (readonly string[])[], readonly caption: string, readonly label: string) { + super(); + } + override eq(other: TableWidget) { + return JSON.stringify([other.header, other.rows, other.caption, other.label]) === JSON.stringify([this.header, this.rows, this.caption, this.label]); + } + toDOM() { + const el = block("cm-lp-table"); + const table = document.createElement("table"); + const thead = document.createElement("thead"); + const headRow = document.createElement("tr"); + for (const cell of this.header) { + const th = document.createElement("th"); + th.textContent = cell; + headRow.append(th); + } + thead.append(headRow); + const tbody = document.createElement("tbody"); + for (const row of this.rows) { + const tr = document.createElement("tr"); + for (let i = 0; i < Math.max(row.length, this.header.length); i++) { + const td = document.createElement("td"); + td.textContent = row[i] ?? ""; + tr.append(td); + } + tbody.append(tr); + } + table.append(thead, tbody); + el.append(table); + if (this.caption || this.label) { + const caption = document.createElement("div"); + caption.className = "cm-lp-caption"; + caption.textContent = this.caption; + if (this.label) { + const tag = document.createElement("span"); + tag.className = "cm-lp-chip is-label"; + tag.textContent = this.label; + caption.append(this.caption ? " " : "", tag); + } + el.append(caption); + } + return el; + } + override ignoreEvent() { + return false; + } +} + +class CodeWidget extends WidgetType { + constructor(readonly language: string, readonly code: string, readonly math: boolean, readonly label: string) { + super(); + } + override eq(other: CodeWidget) { + return other.language === this.language && other.code === this.code && other.math === this.math && other.label === this.label; + } + toDOM() { + const el = block(this.math ? "cm-lp-math" : "cm-lp-code"); + const head = document.createElement("div"); + head.className = "cm-lp-block-kind"; + head.textContent = this.language; + const pre = document.createElement("pre"); + pre.textContent = this.code; + el.append(head, pre); + if (this.label) { + const tag = document.createElement("span"); + tag.className = "cm-lp-chip is-label"; + tag.textContent = this.label; + head.append(" ", tag); + } + return el; + } + override ignoreEvent() { + return false; + } +} + +/** Footnote numbers by first reference, and each definition's text, for one document (computed once per document). */ +const footnoteCache = new WeakMap; definitions: Map }>(); +function footnotes(state: EditorState): { numbers: Map; definitions: Map } { + const known = footnoteCache.get(state.doc); + if (known) return known; + const text = state.doc.toString(); + const numbers = new Map(); + const definitions = new Map(); + for (const m of text.matchAll(FOOTNOTE_REF)) if (!numbers.has(m[1] ?? "")) numbers.set(m[1] ?? "", numbers.size + 1); + for (const m of text.matchAll(FOOTNOTE_DEF)) { + const id = m[1] ?? ""; + if (!numbers.has(id)) numbers.set(id, numbers.size + 1); + definitions.set(id, (m[2] ?? "").trim()); + } + const out = { numbers, definitions }; + footnoteCache.set(state.doc, out); + return out; +} + +/** Whether a transaction moved the selection onto other lines (what the preview cares about). */ +function linesChanged(before: EditorState, after: EditorState): boolean { + return JSON.stringify(activeSpans(before)) !== JSON.stringify(activeSpans(after)); +} + +const labelOf = (attrs: string | undefined) => /\{#([^\s}]+)/.exec(attrs ?? "")?.[1] ?? ""; +const kindOf = (key: string) => LABEL_KINDS[key.split("-")[0] ?? ""] ?? "Label"; +const shorten = (s: string, max: number) => (s.length > max ? `${s.slice(0, max - 1).trimEnd()}…` : s); + +function inlineDecorations(view: EditorView, insight: () => EditorInsight): DecorationSet { + const { state } = view; + const data = insight(); + const spans = activeSpans(state); + const notes = footnotes(state); + const ranges: Range[] = []; + const hide = (from: number, to: number) => { + if (to > from) ranges.push(Decoration.replace({}).range(from, to)); + }; + const tree = syntaxTree(state); + for (const { from, to } of view.visibleRanges) { + tree.iterate({ + from, + to, + enter(node) { + if (touches(spans, node.from, node.to) && node.name !== "FencedCode" && node.name !== "Table" && node.name !== "MathBlock") return; + switch (node.name) { + case "FencedCode": + case "Table": + case "MathBlock": + case "EmbedBlock": + // Drawn as blocks (or left as written); nothing inside them is touched. + return false; + case "HeaderMark": { + const parent = node.node.parent; + if (!parent?.name.startsWith("ATXHeading")) { + ranges.push(Decoration.mark({ class: "cm-lp-quiet" }).range(node.from, node.to)); + return; + } + hide(node.from, Math.min(node.to + (state.sliceDoc(node.to, node.to + 1) === " " ? 1 : 0), parent.to)); + return; + } + case "EmphasisMark": + case "StrikethroughMark": + hide(node.from, node.to); + return; + case "CodeMark": + if (node.node.parent?.name === "InlineCode") hide(node.from, node.to); + return; + case "Link": { + const link = node.node; + const marks: SyntaxNode[] = []; + let url: SyntaxNode | null = null; + for (let child = link.firstChild; child; child = child.nextSibling) { + if (child.name === "LinkMark") marks.push(child); + else if (child.name === "URL") url = child; + } + if (!url || marks.length < 2) return; + const open = marks[0] as SyntaxNode; + const close = marks[1] as SyntaxNode; + hide(open.from, open.to); + hide(close.from, link.to); + ranges.push(Decoration.mark({ class: "cm-lp-link", attributes: { title: state.sliceDoc(url.from, url.to) } }).range(open.to, close.from)); + return false; + } + case "Citation": { + const inner = state.sliceDoc(node.from + 1, node.to - 1); + const text = citationText(inner, data); + if (text === null) return; + const keys = [...inner.matchAll(/@([\p{L}\p{N}_][\p{L}\p{N}_:.#$%&\-+?<>~/]*)/gu)].map((m) => (m[1] ?? "").replace(TRAILING, "")); + const title = keys.map((k) => { const r = data.references.get(k); return r ? plainReference(r) : (data.library.get(k)?.title ?? ""); }).filter(Boolean).join("\n"); + ranges.push(Decoration.replace({ widget: new ChipWidget(text, "cite", title) }).range(node.from, node.to)); + return false; + } + case "AtReference": { + const key = state.sliceDoc(node.from + 1, node.to).replace(TRAILING, ""); + const to = node.from + 1 + key.length; + const label = data.labels.get(key); + if (label) { + ranges.push(Decoration.replace({ widget: new ChipWidget(`${kindOf(key)} · ${shorten(label.text || key, 36)}`, "ref", `@${key} · ${label.record}`) }).range(node.from, to)); + return false; + } + const entry = data.library.get(key); + if (entry) { + const reference = data.references.get(key); + ranges.push(Decoration.replace({ widget: new ChipWidget(authorYear(entry) || key, "cite", reference ? plainReference(reference) : entry.title) }).range(node.from, to)); + return false; + } + return; + } + case "FootnoteReference": { + const id = state.sliceDoc(node.from + 2, node.to - 1); + const number = notes.numbers.get(id); + if (!number) return; + ranges.push(Decoration.replace({ widget: new SupWidget(number, notes.definitions.get(id) ?? "", false) }).range(node.from, node.to)); + return false; + } + case "FootnoteDefinition": { + const first = state.doc.lineAt(node.from); + const marker = /^\[\^([^\]\s]+)\]:[ \t]*/.exec(first.text); + if (!marker) return; + const number = notes.numbers.get(marker[1] ?? ""); + for (let line = first; line.from <= node.to; line = state.doc.line(line.number + 1)) { + ranges.push(Decoration.line({ class: "cm-lp-footnote" }).range(line.from)); + if (line.number === state.doc.lines) break; + } + if (number) ranges.push(Decoration.replace({ widget: new SupWidget(number, "", true) }).range(node.from, node.from + marker[0].length)); + return; + } + case "Paragraph": + case "ATXHeading1": + case "ATXHeading2": + case "ATXHeading3": + case "ATXHeading4": + case "ATXHeading5": + case "ATXHeading6": { + // Attributes ({#sec-intro}, {.unnumbered}) read as small tags. + const text = state.sliceDoc(node.from, node.to); + if (node.name === "Paragraph" && FIGURE.test(text)) return false; + for (const m of text.matchAll(ATTRIBUTE)) { + const id = labelOf(m[0]); + const start = node.from + (m.index ?? 0); + // The space before the tag goes too, so a heading does not end in a gap. + const leading = start > node.from && state.sliceDoc(start - 1, start) === " " ? 1 : 0; + ranges.push(Decoration.replace({ widget: new ChipWidget(id || m[0].slice(1, -1), "label", id ? `Refer to this with @${id}` : m[0]) }).range(start - leading, start + m[0].length)); + } + return; + } + default: + return; + } + }, + }); + } + return Decoration.set(ranges, true); +} + +function blockDecorations(state: EditorState, insight: () => EditorInsight): DecorationSet { + const data = insight(); + const ranges: Range[] = []; + const selected = (from: number, to: number) => state.selection.ranges.some((r) => r.from <= to && r.to >= from); + const replace = (from: number, to: number, widget: WidgetType) => { + const start = state.doc.lineAt(from).from; + const end = state.doc.lineAt(to).to; + if (selected(start, end)) return; + ranges.push(Decoration.replace({ widget, block: true }).range(start, end)); + }; + const tree: Tree = syntaxTree(state); + tree.iterate({ + enter(node) { + switch (node.name) { + case "Paragraph": { + const text = state.sliceDoc(node.from, node.to); + const figure = FIGURE.exec(text); + if (!figure) return; + const src = (figure[2] ?? "").trim(); + replace(node.from, node.to, new FigureWidget((figure[1] ?? "").trim(), src, labelOf(figure[3]), data.image?.(src) ?? null)); + return false; + } + case "Table": { + const header: string[] = []; + const rows: string[][] = []; + for (let child = node.node.firstChild; child; child = child.nextSibling) { + if (child.name !== "TableHeader" && child.name !== "TableRow") continue; + const cells: string[] = []; + for (let cell = child.firstChild; cell; cell = cell.nextSibling) if (cell.name === "TableCell") cells.push(state.sliceDoc(cell.from, cell.to).trim()); + if (child.name === "TableHeader") header.push(...cells); + else rows.push(cells); + } + let to = node.to; + let caption = ""; + let label = ""; + const next = node.node.nextSibling; + if (next?.name === "Paragraph") { + const m = TABLE_CAPTION.exec(state.sliceDoc(next.from, next.to)); + if (m) { + caption = (m[1] ?? "").trim(); + label = labelOf(m[2]); + to = next.to; + } + } + replace(node.from, to, new TableWidget(header, rows, caption, label)); + return false; + } + case "FencedCode": { + const info = node.node.getChild("CodeInfo"); + const code = node.node.getChild("CodeText"); + const language = info ? state.sliceDoc(info.from, info.to).trim() : ""; + const name = /^\{=typst\}$/.test(language) ? "Typst" : language.replace(/^\{\.?|\}$/g, "") || "Code"; + replace(node.from, node.to, new CodeWidget(name, code ? state.sliceDoc(code.from, code.to).replace(/\n$/, "") : "", false, "")); + return false; + } + case "MathBlock": { + const text = state.sliceDoc(node.from, node.to); + const m = /^\$\$([\s\S]*?)\$\$\s*(\{[^}]*\})?\s*$/.exec(text); + if (!m) return false; + replace(node.from, node.to, new CodeWidget("Equation", (m[1] ?? "").trim(), true, labelOf(m[2]))); + return false; + } + default: + return; + } + }, + }); + return Decoration.set(ranges, true); +} + +function livePreviewExtension(insight: () => EditorInsight): Extension { + const inline = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + constructor(view: EditorView) { + this.decorations = inlineDecorations(view, insight); + } + update(u: ViewUpdate) { + const refreshed = u.transactions.some((tr) => tr.effects.some((e) => e.is(refreshLivePreview))); + if (u.docChanged || u.viewportChanged || refreshed || syntaxTree(u.state) !== syntaxTree(u.startState) || (u.selectionSet && linesChanged(u.startState, u.state))) this.decorations = inlineDecorations(u.view, insight); + } + }, + { decorations: (v) => v.decorations }, + ); + const blocks = StateField.define({ + create: (state) => blockDecorations(state, insight), + update(value, tr) { + const refreshed = tr.effects.some((e) => e.is(refreshLivePreview)); + if (tr.docChanged || refreshed || syntaxTree(tr.state) !== syntaxTree(tr.startState) || (tr.selection && linesChanged(tr.startState, tr.state))) return blockDecorations(tr.state, insight); + return value; + }, + provide: (field) => EditorView.decorations.from(field), + }); + return [inline, blocks, EditorView.editorAttributes.of({ class: "is-live-preview" })]; +} + +/** The live preview in a compartment, so it can be switched off to show the Markdown as written. */ +export function livePreview(insight: () => EditorInsight, enabled: boolean): { extension: Extension; set(view: EditorView, enabled: boolean): void } { + const compartment = new Compartment(); + const configured = (on: boolean) => (on ? livePreviewExtension(insight) : []); + return { + extension: compartment.of(configured(enabled)), + set(view, on) { + view.dispatch({ effects: compartment.reconfigure(configured(on)) }); + }, + }; +} diff --git a/apps/writer/src/ui/layout.ts b/apps/writer/src/ui/layout.ts index bf03bdd..69f07aa 100644 --- a/apps/writer/src/ui/layout.ts +++ b/apps/writer/src/ui/layout.ts @@ -14,10 +14,12 @@ export interface Layout { readonly zoom: number | "fit"; /** Hard-wrapped paragraphs flow in the editor (their soft line breaks drawn as spaces). */ readonly joinLines: boolean; + /** The editor reads like the manuscript (citations, figures and tables drawn) rather than as Markdown source. */ + readonly livePreview: boolean; } const KEY = "mdbase-writer:layout"; -export const DEFAULT_LAYOUT: Layout = { sidebar: true, tab: "outline", sidebarWidth: 272, view: "both", split: 0.5, zoom: "fit", joinLines: true }; +export const DEFAULT_LAYOUT: Layout = { sidebar: true, tab: "outline", sidebarWidth: 272, view: "both", split: 0.5, zoom: "fit", joinLines: true, livePreview: true }; export const ZOOM_STEPS = [0.5, 0.67, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2] as const; export const SIDEBAR_MIN = 200; @@ -35,6 +37,7 @@ export function loadLayout(): Layout { view: raw.view === "write" || raw.view === "preview" || raw.view === "both" ? raw.view : DEFAULT_LAYOUT.view, split: typeof raw.split === "number" ? clampSplit(raw.split) : DEFAULT_LAYOUT.split, joinLines: typeof raw.joinLines === "boolean" ? raw.joinLines : DEFAULT_LAYOUT.joinLines, + livePreview: typeof raw.livePreview === "boolean" ? raw.livePreview : DEFAULT_LAYOUT.livePreview, zoom: raw.zoom === "fit" || (typeof raw.zoom === "number" && raw.zoom >= 0.25 && raw.zoom <= 4) ? raw.zoom : DEFAULT_LAYOUT.zoom, }; } catch { From fc58e2545297599d66abed2961f4a5bbac611377 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 6 Oct 2026 19:36:39 +1100 Subject: [PATCH 3/7] Insert figures, tables and equations; list problems under the editor The command palette inserts a figure from an image (stored in the collection, with a label and its caption selected), a table, an equation, a cross-reference (opening the label completion) and a footnote; pasting or dropping an image does the same. Problems list under the editor with their record and line and stay open while they are fixed; while a typed line is still being checked the bar says so rather than "No problems". Settings edit authors as rows of name and affiliation and keep the editor preferences; on a phone, export moves into the More menu. --- apps/writer/src/editor/snippets.test.ts | 20 +++ apps/writer/src/editor/snippets.ts | 45 ++++++ apps/writer/src/styles.css | 76 ++++++++- apps/writer/src/ui/ProblemsStrip.tsx | 83 ++++++++++ apps/writer/src/ui/Settings.tsx | 127 +++++++++++++-- apps/writer/src/ui/WorkspaceView.tsx | 197 +++++++++++++----------- apps/writer/src/ui/icons.tsx | 2 + apps/writer/src/ui/problems.test.ts | 19 +++ 8 files changed, 468 insertions(+), 101 deletions(-) create mode 100644 apps/writer/src/editor/snippets.test.ts create mode 100644 apps/writer/src/editor/snippets.ts create mode 100644 apps/writer/src/ui/ProblemsStrip.tsx create mode 100644 apps/writer/src/ui/problems.test.ts diff --git a/apps/writer/src/editor/snippets.test.ts b/apps/writer/src/editor/snippets.test.ts new file mode 100644 index 0000000..d949bd3 --- /dev/null +++ b/apps/writer/src/editor/snippets.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { equationSnippet, figureSnippet, nextFootnoteId, tableSnippet, uniqueLabel } from "./snippets.js"; + +describe("insert snippets", () => { + it("labels a figure after its file and selects the caption", () => { + const s = figureSnippet("figures/Reef Stages.png", new Set()); + expect(s.text).toBe("![Caption](figures/Reef Stages.png){#fig-reef-stages}"); + expect(s.text.slice(s.select[0], s.select[1])).toBe("Caption"); + }); + it("numbers a label that is already taken", () => { + expect(uniqueLabel("fig-reef", new Set(["fig-reef", "fig-reef-2"]))).toBe("fig-reef-3"); + expect(tableSnippet(new Set(["tbl-table"])).text).toContain("{#tbl-table-2}"); + expect(equationSnippet(new Set()).text).toMatch(/^\$\$\n.*\n\$\$ \{#eq-equation\}$/s); + }); + it("gives the next footnote the number after the highest in the text", () => { + expect(nextFootnoteId("")).toBe("1"); + expect(nextFootnoteId("a[^1] b[^3]\n\n[^1]: x\n[^3]: y\n[^note]: z")).toBe("4"); + }); +}); diff --git a/apps/writer/src/editor/snippets.ts b/apps/writer/src/editor/snippets.ts new file mode 100644 index 0000000..d8ec214 --- /dev/null +++ b/apps/writer/src/editor/snippets.ts @@ -0,0 +1,45 @@ +// What the editor inserts for a figure, a table, an equation or a footnote: +// the Markdown Writer's translator understands, with a label to refer to and +// the part to write next (a caption, a cell) selected. +import { manuscriptSlug } from "../backend/types.js"; + +export interface Snippet { + readonly text: string; + /** Offsets within `text` to select once inserted, so the placeholder is typed over. */ + readonly select: readonly [number, number]; +} + +/** `base` itself, or `base-2`, `base-3`… while `taken` has it. */ +export function uniqueLabel(base: string, taken: { has(key: string): boolean }): string { + if (!taken.has(base)) return base; + for (let n = 2; ; n++) if (!taken.has(`${base}-${n}`)) return `${base}-${n}`; +} + +const labelStem = (name: string) => manuscriptSlug(name.replace(/\.[a-z0-9]{1,5}$/i, "")).slice(0, 40) || "item"; + +const selecting = (before: string, placeholder: string, after: string): Snippet => ({ text: `${before}${placeholder}${after}`, select: [before.length, before.length + placeholder.length] }); + +/** A figure from an image in the collection: its caption is selected to write. */ +export function figureSnippet(path: string, taken: { has(key: string): boolean }): Snippet { + const label = uniqueLabel(`fig-${labelStem(path.split("/").pop() ?? path)}`, taken); + return selecting("![", "Caption", `](${path}){#${label}}`); +} + +/** A two-column table with its caption selected to write. */ +export function tableSnippet(taken: { has(key: string): boolean }): Snippet { + const label = uniqueLabel("tbl-table", taken); + return selecting("| Column | Column |\n|---|---|\n| Cell | Cell |\n| Cell | Cell |\n\n: ", "Caption", ` {#${label}}`); +} + +/** Display math with a label, its contents selected to write. */ +export function equationSnippet(taken: { has(key: string): boolean }): Snippet { + const label = uniqueLabel("eq-equation", taken); + return selecting("$$\n", "y = mx + b", `\n$$ {#${label}}`); +} + +/** The next free footnote id in a text: `1`, `2`… past any numbered note it has. */ +export function nextFootnoteId(text: string): string { + let max = 0; + for (const m of text.matchAll(/\[\^(\d+)\]/g)) max = Math.max(max, Number(m[1])); + return String(max + 1); +} diff --git a/apps/writer/src/styles.css b/apps/writer/src/styles.css index 17764c5..0720025 100644 --- a/apps/writer/src/styles.css +++ b/apps/writer/src/styles.css @@ -160,6 +160,22 @@ kbd { font-family: var(--mono); font-size: 11.5px; padding: 1px 5px; border: 1px .popover-note { margin: 4px 2px 0; padding: 8px 8px 2px; border-top: 1px solid var(--line); color: var(--muted); font-size: 11px; } /* Home */ +/* Picking up where writing stopped: the last manuscript opened, with its goal. */ +.continue-card { display: flex; align-items: center; justify-content: space-between; gap: 1.25rem; margin-top: 1.25rem; padding: 1rem 1.1rem; border: 1px solid var(--line); border-radius: var(--control-radius); background: var(--surface); } +.continue-text { display: grid; gap: 0.2rem; min-width: 0; } +.continue-title { margin: 0; font-size: 16px; text-transform: none; letter-spacing: 0; color: var(--ink); font-weight: 700; line-height: 1.3; } +.continue-progress { display: block; width: min(100%, 22rem); height: 4px; margin: 0.3rem 0 0.15rem; border-radius: 2px; background: var(--subtle); overflow: hidden; } +.continue-progress > span { display: block; height: 100%; border-radius: 2px; background: var(--app-writer); } +.continue-goal { justify-self: start; } +.goal-form { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.25rem; } +.goal-form .mdbase-field { width: 10rem; } +.continue-card > .mdbase-button { flex: none; } +.home-drafts { display: grid; gap: 0.5rem; margin-top: 1.25rem; padding: 0.8rem 1rem; border: 1px solid var(--line); border-radius: var(--control-radius); background: var(--surface); } +.home-drafts h2 { margin: 0; color: var(--color-warning-text); } +.home-drafts ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; } +.home-drafts li { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } +.home-draft-text { display: grid; gap: 0.05rem; min-width: 0; } +.home-drafts p { margin: 0; } .home { width: 100%; max-width: 50rem; margin: 0 auto; padding: 2rem 16px; display: grid; gap: 2rem; align-content: start; overflow: auto; } .home-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } .home-header p { margin: 0; } @@ -317,6 +333,19 @@ kbd { font-family: var(--mono); font-size: 11.5px; padding: 1px 5px; border: 1px .field-problem.severity-error { color: var(--danger); } .field-problem.severity-warning { color: var(--color-warning-text); } .has-problem :is(.mdbase-field, .mdbase-select) { border-color: var(--warning); } +/* Authors as rows: name, affiliation, and the row's order and removal. */ +.authors-field { display: grid; gap: 0.35rem; font-size: 13px; color: var(--ink-soft); } +.authors-label { margin-bottom: 0.05rem; } +.author-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr) auto auto; gap: 0.35rem; align-items: center; } +.author-row .mdbase-field { min-width: 0; } +.author-actions { display: flex; align-items: center; gap: 0.75rem; } +.author-actions .text-button { margin-left: -0.4rem; } +/* Editor preferences, kept in this browser, at the end of the settings sheet. */ +.editor-prefs { display: grid; gap: 0.6rem; padding-top: 1rem; border-top: 1px solid var(--line); } +.editor-prefs h2 { margin: 0; } +.pref-row { display: grid; grid-template-columns: auto 1fr; gap: 0.6rem; align-items: start; font-size: 13.5px; color: var(--ink); cursor: pointer; } +.pref-row input { margin: 0.2rem 0 0; } +.pref-row > span { display: grid; gap: 0.1rem; } .sources { display: grid; grid-template-columns: minmax(0, 1fr); } /* The search stays at the top while the list scrolls under it. */ @@ -429,7 +458,6 @@ blockquote.thread-quote::after { content: "”"; } .cm-suggest-insert { color: var(--connected); text-decoration: underline; text-decoration-thickness: 1.5px; background: color-mix(in oklch, var(--connected) 12%, transparent); cursor: pointer; } :is(.cm-suggest-delete, .cm-suggest-insert).is-active { outline: 1px solid var(--line-strong); border-radius: 2px; } -.problems { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.15rem; } .problem-row { display: grid; gap: 0.1rem; width: 100%; text-align: left; border: 0; background: none; padding: 0.4rem 0.55rem; border-radius: 5px; cursor: pointer; font-size: 13px; color: var(--ink); } /* A problem's severity is the dot before its message. */ .problem-row > span:first-child::before { content: ""; display: inline-block; width: 7px; height: 7px; margin-right: 0.5rem; border-radius: 50%; vertical-align: 0.08em; background: var(--line-strong); } @@ -438,6 +466,17 @@ blockquote.thread-quote::after { content: "”"; } .problem-row.severity-error > span:first-child::before { background: var(--danger); } .problem-row.severity-warning > span:first-child::before { background: var(--warning); } .problem-row .record-path { padding-left: calc(7px + 0.5rem); } +/* Problems under the editor (ProblemsStrip.tsx): they stay listed while they are fixed. */ +.problems-strip { flex: none; display: grid; grid-template-rows: auto minmax(0, 1fr); max-height: min(40%, 18rem); border-top: 1px solid var(--line); background: var(--canvas); } +.problems-strip-header { display: flex; align-items: center; gap: 0.5rem; min-height: 34px; padding: 0 0.5rem 0 1rem; font-size: 12.5px; } +.problems-strip-header > .icon { width: 15px; height: 15px; color: var(--danger); } +.problems-strip-hint { flex: 1; text-align: right; font-size: 11.5px; } +.problems-strip-list { list-style: none; margin: 0; padding: 0 0.5rem 0.5rem; overflow: auto; display: grid; gap: 1px; } +.problems-strip-list .problem-row { padding: 0.3rem 0.55rem; } +.problems-strip-list .problem-row .record-path { padding-left: calc(7px + 0.5rem); } +.problems-strip-list li.is-current .problem-row { background: color-mix(in oklch, var(--selected) 55%, transparent); } +.problems-strip-list li.is-current .problem-row:hover { background: var(--selected); } +.bar-problems.is-checking { color: var(--muted); } .write { grid-area: write; min-height: 0; min-width: 0; display: flex; flex-direction: column; } .write > .editor { flex: 1; } @@ -510,6 +549,34 @@ blockquote.thread-quote::after { content: "”"; } .cm-ref-body { color: var(--ink); } .cm-ref-hint { font-size: 11px; color: var(--faint); } +/* Live preview: the editor reads like the manuscript until the cursor is on a line (live-preview.ts). */ +.cm-lp-chip { display: inline-block; max-width: 24em; margin: 0 1px; padding: 0 0.35em; border-radius: 4px; vertical-align: baseline; font-size: 0.88em; line-height: 1.35; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: text; } +.cm-lp-chip.is-cite { color: var(--accent); background: color-mix(in oklch, var(--accent) 9%, transparent); } +.cm-lp-chip.is-ref { color: var(--ink-soft); background: var(--subtle); } +.cm-lp-chip.is-label { color: var(--muted); background: var(--subtle); font-family: var(--mono); font-size: 0.72em; letter-spacing: -0.01em; vertical-align: 0.1em; } +.cm-lp-link { color: var(--accent); text-decoration: underline; text-decoration-color: color-mix(in oklch, var(--accent) 45%, transparent); text-underline-offset: 2px; } +.cm-lp-quiet { color: var(--faint); } +.cm-lp-fn-ref { color: var(--accent); font-size: 0.72em; font-weight: 700; line-height: 0; cursor: text; } +.cm-lp-fn-number { color: var(--muted); font-size: 0.85em; font-variant-numeric: tabular-nums; margin-right: 0.35em; } +.editor .cm-lp-footnote { font-size: 0.92em; color: var(--ink-soft); } +.editor .cm-lp-block { margin: 0.35rem 0; font-family: var(--sans); cursor: text; } +.editor .cm-lp-block-kind { margin-bottom: 0.25rem; font-size: 10.5px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--muted); } +.editor .cm-lp-caption { margin-top: 0.35rem; font-size: 0.92em; color: var(--ink-soft); text-align: center; } +.editor .cm-lp-figure { display: grid; justify-items: center; padding: 0.6rem; border: 1px solid var(--line); border-radius: var(--control-radius); background: var(--surface); } +.editor .cm-lp-figure:hover, .editor .cm-lp-table:hover, .editor .cm-lp-code:hover, .editor .cm-lp-math:hover { border-color: var(--line-strong); } +.editor .cm-lp-figure-image { max-width: 100%; max-height: 22rem; object-fit: contain; } +.editor .cm-lp-figure-missing { display: grid; place-items: center; min-height: 4.5rem; width: 100%; border: 1px dashed var(--line-strong); border-radius: 4px; color: var(--muted); font-family: var(--mono); font-size: 12px; } +.editor .cm-lp-table { padding: 0.5rem 0.6rem; border: 1px solid var(--line); border-radius: var(--control-radius); background: var(--surface); overflow: auto; } +.editor .cm-lp-table table { border-collapse: collapse; width: 100%; font-size: 0.92em; } +.editor .cm-lp-table th, .editor .cm-lp-table td { padding: 0.3rem 0.6rem; text-align: left; border-bottom: 1px solid var(--line); vertical-align: top; } +.editor .cm-lp-table th { font-weight: 700; border-bottom-color: var(--line-strong); } +.editor .cm-lp-table tr:last-child td { border-bottom: 0; } +.editor .cm-lp-code, .editor .cm-lp-math { padding: 0.5rem 0.75rem; border: 1px solid var(--line); border-radius: var(--control-radius); background: var(--surface); } +.editor .cm-lp-code pre, .editor .cm-lp-math pre { margin: 0; font-family: var(--mono); font-size: 0.85em; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--ink-soft); } +.editor .cm-lp-math pre { text-align: center; color: var(--ink); } +/* A completion list shows whole rows: seven of them, never a row cut in half. */ +.editor .cm-tooltip.cm-tooltip-autocomplete > ul { max-height: min(312px, 45vh); } + .preview { position: relative; overflow: auto; min-height: 0; padding: 1rem; } .preview.is-stale .preview-pages { opacity: 0.55; transition: opacity var(--motion-base) var(--ease-out); } .preview-empty { color: var(--muted); text-align: center; margin-top: 4rem; max-width: none; } @@ -547,6 +614,9 @@ fieldset.settings { margin: 0; padding: 0; border: 0; min-width: 0; } .spinner { width: 14px; height: 14px; flex: none; border: 2px solid var(--line-strong); border-top-color: var(--accent); border-radius: 50%; animation: spin 800ms linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } +/* Shown only on phones (see the 900px rule below). */ +.phone-only { display: none; } + /* Narrower windows: fewer words in the bar. */ @media (max-width: 1180px) { .bar-words { display: none; } @@ -563,6 +633,8 @@ fieldset.settings { margin: 0; padding: 0; border: 0; min-width: 0; } .bar-back span, .bar-divider, .bar-layout, .bar-problems.is-clear { display: none; } /* Only a save state that needs attention, in few words. The title gives way first; if the bar is still too wide the notice does, rather than push the buttons off screen. */ .bar .mdbase-save-notice.is-long { display: none; } + /* Export is rarely what a phone is for; it moves into the More menu. */ + .bar .split-button { display: none; } .bar .mdbase-save-notice.is-short { display: block; flex: 0 0.001 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; } .bar .mdbase-button { padding: 0 0.55rem; min-height: 32px; } .workspace { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; grid-template-areas: "main" "tabs"; } @@ -581,9 +653,11 @@ fieldset.settings { margin: 0; padding: 0; border: 0; min-width: 0; } .workspace.pane-write .write { display: flex; } .workspace.pane-preview .preview-pane { display: grid; } .divider, .sidebar-resizer { display: none !important; } + .phone-only { display: grid; } .pane-path, .page-indicator { display: none; } .editor .cm-content { padding: 1rem 16px 40vh; } .new-manuscript { grid-template-columns: 1fr; } + .continue-card { flex-direction: column; align-items: stretch; } .shortcuts div { grid-template-columns: 1fr; gap: 0.2rem; } .toast { left: 16px; right: 16px; bottom: 70px; max-width: none; } } diff --git a/apps/writer/src/ui/ProblemsStrip.tsx b/apps/writer/src/ui/ProblemsStrip.tsx new file mode 100644 index 0000000..9a01867 --- /dev/null +++ b/apps/writer/src/ui/ProblemsStrip.tsx @@ -0,0 +1,83 @@ +// Problems listed under the editor, in reading order, each with where it is: +// the record and line, or the setting. It stays open while they are fixed; +// a row goes to the problem, F8 steps through them. +import { memo, useMemo } from "react"; + +import type { WriterDiagnostic } from "../compile/protocol.js"; +import { AlertIcon, CloseIcon } from "./icons.js"; +import { plural } from "./records.js"; + +export interface ProblemRow { + readonly diagnostic: WriterDiagnostic; + readonly where: string; +} + +/** Rows in reading order: settings first, then each record's problems by position. */ +export function problemRows(diagnostics: readonly WriterDiagnostic[], order: readonly string[], body: (record: string) => string | undefined, title: (record: string) => string): ProblemRow[] { + const lines = new Map(); + const lineOf = (record: string, offset: number) => { + let starts = lines.get(record); + if (!starts) { + starts = [0]; + const text = body(record) ?? ""; + for (let i = text.indexOf("\n"); i >= 0; i = text.indexOf("\n", i + 1)) starts.push(i + 1); + lines.set(record, starts); + } + let low = 0; + let high = starts.length - 1; + while (low < high) { + const mid = (low + high + 1) >> 1; + if ((starts[mid] ?? 0) <= offset) low = mid; + else high = mid - 1; + } + return low + 1; + }; + const rank = (d: WriterDiagnostic) => (d.field ? -1 : Math.max(0, order.indexOf(d.record))) * 1e9 + (d.field ? 0 : d.from); + return [...diagnostics] + .sort((a, b) => rank(a) - rank(b)) + .map((diagnostic) => ({ + diagnostic, + where: diagnostic.field + ? `Settings · ${diagnostic.field}` + : order.length > 1 + ? `${title(diagnostic.record)} · line ${lineOf(diagnostic.record, diagnostic.from)}` + : `Line ${lineOf(diagnostic.record, diagnostic.from)}`, + })); +} + +export const ProblemsStrip = memo(function ProblemsStrip({ diagnostics, order, body, title, current, onPick, onClose }: { + diagnostics: readonly WriterDiagnostic[]; + order: readonly string[]; + body(record: string): string | undefined; + title(record: string): string; + /** The record open in the editor; its problems are listed first. */ + current: string; + onPick(d: WriterDiagnostic): void; + onClose(): void; +}) { + const rows = useMemo(() => problemRows(diagnostics, order, body, title), [diagnostics, order, body, title]); + const errors = diagnostics.filter((d) => d.severity === "error").length; + return ( +
+
+ + {plural(diagnostics.length, "problem")} + {errors > 0 && errors < diagnostics.length && {plural(errors, "error")}, {plural(diagnostics.length - errors, "warning")}} + F8 goes to the next + +
+
    + {rows.map(({ diagnostic: d, where }, i) => ( +
  • + +
  • + ))} +
+
+ ); +}); diff --git a/apps/writer/src/ui/Settings.tsx b/apps/writer/src/ui/Settings.tsx index 77169aa..fe6926c 100644 --- a/apps/writer/src/ui/Settings.tsx +++ b/apps/writer/src/ui/Settings.tsx @@ -13,7 +13,7 @@ import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } f import type { WriterDiagnostic } from "../compile/protocol.js"; import type { ManuscriptWorkspace, RecordView } from "../workspace/workspace.js"; import { joinSoftBreaks } from "../editor/join-lines.js"; -import { CloseIcon } from "./icons.js"; +import { ChevronUp, CloseIcon, MinusIcon, PlusIcon } from "./icons.js"; import { templateName } from "./names.js"; type JsonValue = JsonObject[string]; @@ -45,6 +45,9 @@ export function Settings({ onClose, focus, joinLines = false, + livePreview = true, + onJoinLines, + onLivePreview, }: { workspace: ManuscriptWorkspace; view: RecordView | undefined; @@ -55,6 +58,9 @@ export function Settings({ focus: SettingsFocus | null; /** Shows the abstract's soft line breaks as spaces, as the editor does. */ joinLines?: boolean; + livePreview?: boolean; + onJoinLines?(): void; + onLivePreview?(): void; }) { const titleId = useId(); const sheet = useRef(null); @@ -120,14 +126,15 @@ export function Settings({
patch({ subtitle: v || null })} {...props("subtitle")} />
- patch({ [authorsKey]: parseAuthors(v) })} - {...props("authors")} - /> +
+ patch({ [authorsKey]: authors.length ? authors.map(toAuthor) : null })} + {...props("authors")} + onDraft={(draft) => workspace.stageFrontmatter(workspace.main, { [authorsKey]: parseAuthors(draft) })} + /> +
patch({ date: v || null })} {...props("date")} />
patch({ abstract: v || null })} {...props("abstract")} /> @@ -163,6 +170,24 @@ export function Settings({ />

Changes are saved to the manuscript’s frontmatter as you type, and the preview follows them.

+ {(onJoinLines || onLivePreview) && ( +
+

Editor

+ {onLivePreview && ( + + )} + {onJoinLines && ( + + )} +

These apply in this browser, to every manuscript.

+
+ )} ); } @@ -308,12 +333,88 @@ function SelectField(props: FieldProps & { value: string; onCommit(value: string ); } -function authorsText(raw: JsonValue | undefined): string { +export interface Author { + readonly name: string; + readonly affiliation: string; +} + +/** Authors as the record has them (strings or objects), as rows to edit. */ +export function authorList(raw: JsonValue | undefined): Author[] { const list = Array.isArray(raw) ? raw : raw ? [raw] : []; return list - .map((a) => (typeof a === "string" ? a : a && typeof a === "object" && !Array.isArray(a) ? [String(a["name"] ?? ""), a["affiliation"]].filter(Boolean).join("; ") : "")) - .filter(Boolean) - .join("\n"); + .map((a): Author | null => { + if (typeof a === "string") { const [name = "", ...rest] = a.split(";").map((p) => p.trim()); return { name, affiliation: rest.filter(Boolean).join("; ") }; } + if (a && typeof a === "object" && !Array.isArray(a)) return { name: String(a["name"] ?? ""), affiliation: typeof a["affiliation"] === "string" ? a["affiliation"] : "" }; + return null; + }) + .filter((a): a is Author => a !== null && (a.name !== "" || a.affiliation !== "")); +} + +const toAuthor = (a: Author): JsonObject => (a.affiliation.trim() ? { name: a.name.trim(), affiliation: a.affiliation.trim() } : { name: a.name.trim() }); +const authorsDraft = (authors: readonly Author[]) => authors.map((a) => [a.name, a.affiliation].filter((p) => p.trim()).join("; ")).join("\n"); + +/** Authors as rows of name and affiliation: add, remove or reorder them. */ +function AuthorsField(props: FieldProps & { value: readonly Author[]; onCommit(authors: readonly Author[]): void }) { + const { value, onCommit, onDraft, field, problems, focus, label } = props; + const [rows, setRows] = useState(() => [...value]); + const editing = useRef(false); + const timer = useRef | undefined>(undefined); + const element = useRef(null); + const problemId = useId(); + useFocusRequest(field, focus, element); + useEffect(() => { + if (!editing.current) setRows([...value]); + }, [value]); + const same = (a: readonly Author[], b: readonly Author[]) => JSON.stringify(a.map(toAuthor)) === JSON.stringify(b.map(toAuthor)); + const commit = (next: readonly Author[]) => { + clearTimeout(timer.current); + timer.current = undefined; + const kept = next.filter((a) => a.name.trim() || a.affiliation.trim()); + if (!same(kept, value)) onCommit(kept); + }; + const pending = useRef({ rows, value, onCommit }); + pending.current = { rows, value, onCommit }; + useEffect(() => () => { + if (timer.current === undefined) return; + clearTimeout(timer.current); + const { rows: last, value: saved, onCommit: save } = pending.current; + const kept = last.filter((a) => a.name.trim() || a.affiliation.trim()); + if (!same(kept, saved)) save(kept); + }, []); + const change = (next: Author[], immediate = false) => { + setRows(next); + onDraft?.(authorsDraft(next)); + clearTimeout(timer.current); + if (immediate) commit(next); + else timer.current = setTimeout(() => commit(next), COMMIT_AFTER_MS); + }; + const shown = rows.length ? rows : [{ name: "", affiliation: "" }]; + return ( +
+ {label} + {shown.map((author, i) => ( +
(editing.current = true)} onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { editing.current = false; commit(rows); } }}> + change(shown.map((a, j) => (j === i ? { ...a, name: e.target.value } : a)))} /> + change(shown.map((a, j) => (j === i ? { ...a, affiliation: e.target.value } : a)))} /> + + +
+ ))} +
+ +
+ {problems.map((p, i) => ( + {p.message} + ))} +
+ ); } export function parseAuthors(text: string): JsonObject[] { diff --git a/apps/writer/src/ui/WorkspaceView.tsx b/apps/writer/src/ui/WorkspaceView.tsx index 97277b3..2c9afef 100644 --- a/apps/writer/src/ui/WorkspaceView.tsx +++ b/apps/writer/src/ui/WorkspaceView.tsx @@ -10,6 +10,7 @@ import type { BlockPosition, SourceMark, WriterDiagnostic } from "../compile/pro import type { ChapterCards } from "../editor/chapter-cards.js"; import type { CommentAnchor } from "../editor/comments.js"; import { Editor, type EditorHandle, type RetainedEditor } from "../editor/Editor.js"; +import { equationSnippet, figureSnippet, tableSnippet } from "../editor/snippets.js"; import type { SelectionAction } from "../editor/selection-bar.js"; import { authorYear } from "../editor/library-search.js"; import { ALT_LABEL, MOD_LABEL, referenceAtOffset, referenceOffsets, type EditorInsight, type FollowTarget, type LabelTarget } from "../editor/insight.js"; @@ -45,6 +46,7 @@ import { anchorsFor, ThreadPlacement, type PlacedThread } from "./comments.js"; import { CommentsPanel, openCount, type CommentDrafts, type PendingComment } from "./CommentsPanel.js"; import { ManuscriptSearch, type SearchRequest } from "./ManuscriptSearch.js"; import { OutlinePanel } from "./OutlinePanel.js"; +import { ProblemsStrip } from "./ProblemsStrip.js"; import { CommandPalette } from "@mdbase-dev/ui/command-palette"; import { moveMenuFocus, useMenuPopover } from "@mdbase-dev/ui/popover"; import { ConnectLayout } from "@mdbase-dev/ui/screens"; @@ -60,7 +62,7 @@ import { signalFailure, signalResult } from "./mark.js"; /** Which part a phone shows. */ type Pane = "outline" | "write" | "preview"; -interface ExportStatus { +interface Notice { readonly text: string; readonly tone: "busy" | "ok" | "problem"; } @@ -136,9 +138,9 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor const [active, setActive] = useState(workspace.main); const [pane, setPane] = useState("write"); const [layout, setLayoutState] = useState(loadLayout); - const [exportStatus, setExportStatus] = useState(null); + const [notice, setNotice] = useState(null); // An export has no count to show, so the mark streams until it ends or is cancelled. - useMdbaseMarkBusy(exportStatus?.tone === "busy" && "stream"); + useMdbaseMarkBusy(notice?.tone === "busy" && "stream"); const [settingsOpen, setSettingsOpen] = useState(false); const [settingsFocus, setSettingsFocus] = useState(null); const [shortcutsOpen, setShortcutsOpen] = useState(false); @@ -181,6 +183,7 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor }); }, []); const toggleJoinLines = useCallback(() => setLayout((l) => ({ ...l, joinLines: !l.joinLines })), [setLayout]); + const toggleLivePreview = useCallback(() => setLayout((l) => ({ ...l, livePreview: !l.livePreview })), [setLayout]); const setSidebarTab = useCallback((tab: SidebarTab) => setLayout((l) => ({ ...l, tab })), [setLayout]); const order = useMemo(() => workspace.readingOrder(), [workspace, snap.records, snap.recordPaths, snap.annotationPaths]); @@ -200,6 +203,8 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor return shown.length === allDiagnostics.length ? allDiagnostics : shown; // eslint-disable-next-line react-hooks/exhaustive-deps -- the live cursor is a ref; the trailing cursor says when it moved }, [allDiagnostics, typing, snap.records, cursor]); + // Problems on the line being typed, held back until typing pauses. + const heldProblems = allDiagnostics.length - diagnostics.length; const byRecord = useMemo(() => { const map = new Map(); // Problems with a setting belong to the settings dialog, not to a line of the body. @@ -221,8 +226,10 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor library: new Map(snap.library.map((e) => [e.key, e])), references: new Map((snap.result?.references ?? []).map((r) => [r.key, r.text])), labels: stats.labels, + image: (target) => workspace.imageUrl(target, active), }), - [snap.library, snap.result?.references, stats.labels], + // eslint-disable-next-line react-hooks/exhaustive-deps -- images redraw as their bytes load (assetVersion) and resolve from the active record + [workspace, snap.library, snap.result?.references, stats.labels, snap.assetVersion, snap.fileIndex, active], ); // Threads on the manuscript's records, placed in their (slightly deferred) text. @@ -431,6 +438,29 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor const before = at && at.from === at.to ? at.text[at.from - 1] : undefined; editor.current?.insert(text.startsWith("[@") && before && /[^\s([{]/.test(before) ? ` ${text}` : text); }, []); + const figureInput = useRef(null); + const labelSet = useMemo(() => new Set(snap.result?.labels ?? []), [snap.result?.labels]); + const labels = useRef(labelSet); + labels.current = labelSet; + /** Stores each image in the collection and writes a figure for it at the cursor. */ + const addFigures = useCallback(async (files: readonly File[]) => { + for (const file of files) { + setNotice({ tone: "busy", text: `Storing ${file.name}…` }); + const stored = signalFailure(await workspace.addFigure({ name: file.name, bytes: new Uint8Array(await file.arrayBuffer()), type: file.type })); + if (!stored.ok) { setNotice({ tone: "problem", text: `${file.name} could not be stored: ${stored.message}` }); return; } + setPane("write"); + editor.current?.insertBlock(figureSnippet(stored.value, labels.current)); + setNotice({ tone: "ok", text: `Stored ${stored.value}. Write the caption.` }); + } + }, [workspace]); + const insertFigure = useCallback(() => { + if (!workspace.canStoreFiles) { setNotice({ tone: "problem", text: "This collection does not accept files from Writer. Add the image to the collection, then write ![Caption](path){#fig-name}." }); return; } + figureInput.current?.click(); + }, [workspace]); + const insertTable = useCallback(() => { setPane("write"); editor.current?.insertBlock(tableSnippet(labelSet)); }, [labelSet]); + const insertEquation = useCallback(() => { setPane("write"); editor.current?.insertBlock(equationSnippet(labelSet)); }, [labelSet]); + const insertReference = useCallback(() => { setPane("write"); editor.current?.insert("@", { complete: true }); }, []); + const insertFootnote = useCallback(() => { setPane("write"); editor.current?.insertFootnote(); }, []); const replyToThread = useCallback(async (thread: Parameters[0], text: string) => signalResult(await workspace.reply(thread, text)), [workspace]); const changeComment = useCallback(async (comment: Parameters[0], change: Parameters[1]) => signalResult(await workspace.changeComment(comment, change)), [workspace]); const acceptSuggestion = useCallback(async (p: PlacedThread) => signalFailure(await workspace.acceptSuggestion(p.record, p.thread.root)), [workspace]); @@ -460,6 +490,8 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor if (next) jump(next.record, next.from); }, [bodyProblems, cursor, active, jump, order]); + const bodyOf = useCallback((record: string) => snap.records.get(record)?.snapshot.body, [snap.records]); + const titleOf = useCallback((record: string) => recordTitle(snap.records.get(record), record), [snap.records]); const fileStem = manuscriptTitle.replace(/[^\p{L}\p{N} _-]+/gu, "").trim() || "manuscript"; const download = (bytes: Uint8Array, type: string, name: string) => { const url = URL.createObjectURL(new Blob([bytes as BlobPart], { type })); @@ -470,7 +502,7 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor setTimeout(() => URL.revokeObjectURL(url), 30_000); }; const exported = (name: string, problems: readonly string[]) => - setExportStatus( + setNotice( problems.length ? { tone: "problem", text: `Exported ${name} with ${plural(problems.length, "problem")}: ${problems[0]}` } : { tone: "ok", text: `Exported ${name}.` }, @@ -483,7 +515,7 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor saveExportFormat(format); const controller = new AbortController(); exportJob.current = controller; - setExportStatus({ tone: "busy", text: format === "docx" ? "Checking chapters and images, then making Word… (first use loads about 16 MB)" : `Checking chapters and images, then making ${EXPORT_NAME[format]}…` }); + setNotice({ tone: "busy", text: format === "docx" ? "Checking chapters and images, then making Word… (first use loads about 16 MB)" : `Checking chapters and images, then making ${EXPORT_NAME[format]}…` }); void (async () => { try { const out = await (format === "pdf" ? workspace.exportPdf(controller.signal) : format === "docx" ? workspace.exportDocx(controller.signal) : workspace.exportBundle(controller.signal)); @@ -493,7 +525,7 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor const name = format === "bundle" ? `${fileStem} (Pandoc).zip` : `${fileStem}.${format}`; if (out.problems.length) { setPendingExport({ bytes: out.bytes, type, name, problems: out.problems }); - setExportStatus(null); + setNotice(null); } else { download(out.bytes, type, name); exported(EXPORT_NAME[format], []); @@ -502,7 +534,7 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor } catch (error) { if (!controller.signal.aborted) { reportError({ code: "unknown_error" }); - setExportStatus({ tone: "problem", text: errorMessage(error) }); + setNotice({ tone: "problem", text: errorMessage(error) }); signalMdbaseMark("error"); } } finally { @@ -513,7 +545,7 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor const cancelExport = () => { exportJob.current?.abort(); exportJob.current = null; - setExportStatus(null); + setNotice(null); setPendingExport(null); }; const canExport: Record = { pdf: snap.phase === "ready", docx: snap.phase === "ready", bundle: snap.phase === "ready" }; @@ -540,11 +572,11 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor }, []); // A finished export's note goes after a few seconds; a problem stays until dismissed. useEffect(() => { - if (exportStatus?.tone !== "ok") return; + if (notice?.tone !== "ok") return; // A finished export settles away (.mdbase-settle), then leaves. - const t = setTimeout(() => setExportStatus(null), 2400); + const t = setTimeout(() => setNotice(null), 2400); return () => clearTimeout(t); - }, [exportStatus]); + }, [notice]); // Keyboard shortcuts (listed in the shortcuts dialog). const shortcuts = useRef<(e: KeyboardEvent) => void>(() => {}); @@ -616,10 +648,10 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor 0} diagnostics={diagnostics} open={problemsOpen} setOpen={setProblemsOpen} - onPick={(d) => (d.field ? showSetting(d.field) : jump(d.record, d.from))} />
@@ -652,7 +684,7 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor setPaletteOpen(true)} onShortcuts={() => setShortcutsOpen(true)} - joinLines={layout.joinLines} - onJoinLines={toggleJoinLines} + onExport={runExport} + canExport={snap.phase === "ready" && !(notice?.tone === "busy" || Boolean(pendingExport))} {...(themeChoice ? { theme: themeChoice.theme, onTheme: themeChoice.setTheme } : {})} />
@@ -814,6 +846,8 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor text={activeView.snapshot.body} readOnly={activeView.snapshot.state === "deleted" || snap.recoveredDrafts.has(active)} joinLines={layout.joinLines} + livePreview={layout.livePreview} + onImageFiles={(files) => void addFigures(files)} diagnostics={byRecord.get(active) ?? NO_DIAGNOSTICS} completion={completion} insight={insight} @@ -831,6 +865,18 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor ) : (

Opening…

)} + {problemsOpen && diagnostics.length > 0 && ( + (d.field ? showSetting(d.field) : jump(d.record, d.from))} + onClose={() => setProblemsOpen(false)} + /> + )} + { const files = [...(e.target.files ?? [])]; e.target.value = ""; if (files.length) void addFigures(files); }} /> editor.current?.format("italic") }, { id: "code", group: "Format", label: "Inline code", run: () => editor.current?.format("code") }, { id: "link", group: "Format", label: "Link", shortcut: "mod+shift+k", run: () => editor.current?.format("link") }, + { id: "insert-citation", group: "Insert", label: "Citation", keywords: "cite source reference", run: () => showSources() }, + { id: "insert-figure", group: "Insert", label: "Figure from an image…", keywords: "image picture photo upload", run: insertFigure }, + { id: "insert-table", group: "Insert", label: "Table", keywords: "grid columns rows", run: insertTable }, + { id: "insert-equation", group: "Insert", label: "Equation", keywords: "math formula display", run: insertEquation }, + { id: "insert-crossref", group: "Insert", label: "Cross-reference", keywords: "refer section figure table label", run: insertReference }, + { id: "insert-footnote", group: "Insert", label: "Footnote", keywords: "note", run: insertFootnote }, { id: "comment", group: "Comments", label: "Comment on the selection", shortcut: "mod+alt+m", run: () => startComment("comment") }, { id: "suggest", group: "Comments", label: "Suggest an edit to the selection", shortcut: "mod+alt+s", keywords: "track changes", run: () => startComment("suggest") }, { id: "comments", group: "Comments", label: "Show comments", run: showComments }, @@ -951,6 +1006,8 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor { id: "manuscripts", group: "Manuscript", label: "All manuscripts", keywords: "back home close", run: () => void requestClose() }, { id: "sidebar", group: "View", label: layout.sidebar ? "Hide the sidebar" : "Show the sidebar", shortcut: "mod+\\", run: () => setLayout((l) => ({ ...l, sidebar: !l.sidebar })) }, { id: "join-lines", group: "View", label: layout.joinLines ? "Show line breaks as written" : "Join hard-wrapped lines", keywords: "wrap reflow soft line breaks", run: toggleJoinLines }, + { id: "live-preview", group: "View", label: layout.livePreview ? "Show Markdown as written" : "Read as the manuscript", keywords: "source markup raw live preview chips", run: toggleLivePreview }, + { id: "problems", group: "Manuscript", label: problemsOpen ? "Hide problems" : "Show problems", keywords: "errors warnings list", run: () => setProblemsOpen(!problemsOpen) }, ...VIEW_ORDER.filter((view) => view !== layout.view).map((view) => ({ id: `view-${view}`, group: "View", label: VIEW_NAME[view], run: () => setLayout((l) => ({ ...l, view })) })), ...(snap.phase === "ready" ? [{ id: "export-pdf", group: "Export", label: "Export PDF", run: () => runExport("pdf") }] : []), { id: "export-docx", group: "Export", label: "Export Word (DOCX)", run: () => runExport("docx") }, @@ -989,14 +1046,14 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor }}>Export anyway
- {exportStatus && ( -
- {exportStatus.tone === "busy" ?
)} + {draftRows.length > 0 && ( +
+

Unsaved in this browser

+
    + {draftRows.map((d) => ( +
  • + + {d.title} + + {d.manuscript && d.manuscript.path !== d.path ? `in ${d.manuscript.title} · ` : ""}backed up {relativeTime(d.updated)} + + + {d.manuscript + ? + : Open its manuscript to review it} +
  • + ))} +
+

A draft is what was typed but not saved to the collection. Opening its manuscript offers it back.

+
+ )} + {continuing && manuscripts && manuscripts.length > 0 && ( + { setWordGoal(namespace, continuing.path, goal); setReload((v) => v + 1); }} + onOpen={() => onOpen(continuing.path)} + /> + )} {manuscripts && manuscripts.length >= SEARCH_FROM && (