diff --git a/README.md b/README.md index e83d1e3..0390dbc 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,20 @@ A sentence with a footnote.[^note] Replace `smith2024` with a citekey from your library and the image path with a file in your collection. +The editor reads like the manuscript: a citation shows who it cites, a cross-reference what it +points to, footnote markers their numbers, and figures, tables, equations and code draw as +blocks. The Markdown is unchanged; the line the cursor is on always shows it as written. Turn +this off under **Editor** at the end of **Settings** to see the Markdown everywhere. + +### Figures, tables and equations + +Type `/` in the text, or use **Insert** in the top bar (or the command palette, **Ctrl/⌘+K**), +to add a **Footnote** (also **Ctrl/⌘+Alt+F**), a **Figure from an image**, a **Table**, an +**Equation**, a **Cross-reference** or a **Citation**; each shows the Markdown it stands for. A figure's image is stored in the +collection (in a `figures` folder beside the manuscript's other images) and written with a +label to refer to; its caption is selected to type. Pasting or dropping an image into the text +does the same. + ### Sources and citations Open **Sources** to search your Reader library and insert a citation, optionally with a page @@ -110,8 +124,12 @@ Chapter cards in the editor show their title, word count, and problems, with a b ## Navigate and review Click a block in the preview to jump to the corresponding text. The preview also follows the -editor's cursor. Use **Problems** to find unknown citekeys, missing labels, unavailable images -or records, and typesetting errors. **F8** moves to the next problem. +editor's cursor. **Problems** in the top bar lists unknown citekeys, missing labels, unavailable +images or records, and typesetting errors under the editor, each with its record and line; the +list stays open while you fix them, and **F8** moves to the next problem. + +The home screen offers the manuscript you opened last, with an optional word goal kept in this +browser, and lists any text typed but not yet saved to the collection. The sidebar, editor/preview split, and zoom are remembered in your browser. On a phone, switch between writing, preview, and outline views. diff --git a/apps/writer/scripts/browser-test.mjs b/apps/writer/scripts/browser-test.mjs index 29c452f..fa312c2 100644 --- a/apps/writer/scripts/browser-test.mjs +++ b/apps/writer/scripts/browser-test.mjs @@ -88,6 +88,15 @@ const editorText = () => return (path && window.writer.workspace.getSnapshot().records.get(path)?.snapshot.body) ?? ""; }); +/** Lists the problems under the editor (the top bar's button toggles the list, which stays open while they are fixed). */ +async function openProblems() { + // Problems on the line being typed are held until typing pauses: wait for them to count. + await page.locator("button.bar-problems").waitFor({ timeout: 10_000 }); + if (await page.locator(".problems-strip").count()) return; + await page.locator("button.bar-problems").click(); + await page.locator(".problems-strip").waitFor({ timeout: 5_000 }); +} + async function typeAtEndOfParagraph(needle, text) { // Click at the end of the line holding `needle`, then type like a person. The click goes to // its last wrapped row: End alone only reaches the end of the row that was clicked. @@ -261,7 +270,7 @@ await step("a setting's problem opens the setting", async () => { const before = (await snapshot()).revision; await page.evaluate(() => window.writer.workspace.patchFrontmatter("manuscripts/slow-change.md", { lang: "tlh" })); await waitForRevisionAfter(before); - await page.locator("button.bar-problems").click(); + await openProblems(); await page.locator(".problem-row", { hasText: "no terms for" }).click(); await page.locator(".settings-sheet .field-problem", { hasText: "no terms for" }).waitFor(); assert.equal(await page.evaluate(() => document.activeElement?.value), "tlh"); @@ -280,7 +289,7 @@ await step("an unknown citekey is reported in Problems and in the editor", async await waitForRevisionAfter(before); const s = await snapshot(); assert.ok(s.diagnostics.some((d) => d.includes("No source in the library has the citekey nosuchsource2020")), s.diagnostics.join("\n")); - await page.locator("button.bar-problems").click(); + await openProblems(); await page.locator(".problem-row", { hasText: "nosuchsource2020" }).waitFor(); await page.keyboard.press("Escape"); await page.locator(".cm-lintRange-error").first().waitFor({ timeout: 5_000 }); @@ -309,14 +318,22 @@ await step("hovering a citation shows its bibliography entry; Mod-click on a cro await page.mouse.move(point.x, point.y, { steps: 4 }); }; await page.getByRole("tab", { name: "Outline" }).click(); - await page.evaluate(() => document.querySelector(".cm-scroller").scrollTo(0, 0)); - await page.waitForTimeout(100); - const cite = await at("lyellPrinciples30"); - assert.ok(cite, "citation on screen"); + // With the cursor on the heading, the paragraph's citations and cross-references are drawn as chips. + await page.locator(".heading-row", { hasText: "Introduction" }).click(); + await page.waitForTimeout(300); + const chip = async (text) => { + const box = await page.locator(".cm-lp-chip", { hasText: text }).first().boundingBox(); + assert.ok(box, `${text} chip on screen`); + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + }; + const cite = await chip("Lyell, 1830"); await hover(cite); await page.locator(".cm-ref-tip", { hasText: "John Murray" }).waitFor({ timeout: 5_000 }); assert.equal(await page.locator(".cm-ref-tip em").first().textContent(), "Principles of Geology"); - const ref = await at("@sec-worms", 4); + // On the cursor's line the source shows as written, and the hover still works on the key itself. + const raw = await at("lyellPrinciples30"); + if (raw) await hover(raw); + const ref = await chip("Earthworms: the moving surface"); await hover(ref); await page.locator(".cm-ref-tip", { hasText: "Earthworms: the moving surface" }).waitFor({ timeout: 5_000 }); await page.keyboard.down("ControlOrMeta"); diff --git a/apps/writer/src/App.tsx b/apps/writer/src/App.tsx index 3758875..ac70368 100644 --- a/apps/writer/src/App.tsx +++ b/apps/writer/src/App.tsx @@ -14,6 +14,7 @@ import { useWriterFeedbackContext } from "./FeedbackRoot.js"; import { ConnectGate } from "./ui/ConnectGate.js"; import { CollectionPicker } from "./ui/CollectionPicker.js"; import { Home } from "./ui/Home.js"; +import { localNamespace, recordOpened } from "./ui/recent.js"; import { Dialog } from "@mdbase-dev/ui/dialog"; import { zip } from "./export/zip.js"; import { loadThemePreference, saveThemePreference, type ThemePreference } from "@mdbase-dev/ui/theme"; @@ -207,6 +208,9 @@ function Manuscripts({ backend, collectionPicker }: { backend: WriterBackend; co useWriterFeedbackContext(path ? "manuscript" : "manuscripts", backend.collectionName); currentWorkspace.current = workspace; (window as unknown as { writer?: unknown }).writer = { backend, workspace }; + useEffect(() => { + if (path) recordOpened(localNamespace(backend), path); + }, [backend, path]); if (!path) return ; if (!workspace) return null; return <> 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/editor/Editor.tsx b/apps/writer/src/editor/Editor.tsx index 779a9ef..20d114d 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,21 @@ 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 { slashCommands, type InsertCommand } from "./slash.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,8 +54,14 @@ 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; + /** What "/" offers to insert (a footnote, a figure…). */ + inserts?: readonly InsertCommand[]; insight: EditorInsight; onChange(text: string): void; /** Mod-click on a citation or cross-reference. */ @@ -69,7 +82,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, inserts, 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 +93,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, inserts, insight, onCursor, onFollow, chapters, onFindSource, onAnchor, onSelectionAction, onImageFiles }); + latest.current = { onChange, completion, inserts, 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(), @@ -97,7 +136,7 @@ export const Editor = memo(function Editor({ states, path, text, readOnly, joinL highlightSelectionMatches(), search({ top: true }), lintGutter(), - autocompletion({ override: [writerCompletions(() => latest.current.completion)] }), + autocompletion({ override: [slashCommands(() => latest.current.inserts ?? []), writerCompletions(() => latest.current.completion)] }), mdbasePopupTheme, keymap.of([...formattingKeymap, ...closeBracketsKeymap, ...defaultKeymap, ...searchKeymap, ...historyKeymap, ...completionKeymap, indentWithTab]), writerLanguage(), @@ -114,7 +153,7 @@ export const Editor = memo(function Editor({ states, path, text, readOnly, joinL EditorView.lineWrapping, joining.current.of(joinLines ? joinLinesExtension() : []), EditorState.readOnly.of(readOnly), - placeholder("Write in Markdown. Cite with [@citekey], embed chapters with ![[path]]."), + placeholder("Write in Markdown. Cite with [@citekey]; type / to add a footnote, figure or table."), EditorView.contentAttributes.of({ "aria-label": `Markdown for ${path}`, spellcheck: "true", autocapitalize: "sentences" }), EditorView.updateListener.of((u) => { if ((u.selectionSet || u.docChanged) && !u.transactions.some((tr) => tr.annotation(remote))) latest.current.onCursor?.(u.state.selection.main.head); @@ -153,10 +192,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 +273,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/editor/slash.test.ts b/apps/writer/src/editor/slash.test.ts new file mode 100644 index 0000000..f01b360 --- /dev/null +++ b/apps/writer/src/editor/slash.test.ts @@ -0,0 +1,32 @@ +import { CompletionContext } from "@codemirror/autocomplete"; +import { EditorState } from "@codemirror/state"; +import { describe, expect, it, vi } from "vitest"; + +import { slashCommands } from "./slash.js"; + +const commands = [ + { id: "footnote", label: "Footnote", syntax: "[^1]", keywords: "note", run: vi.fn() }, + { id: "table", label: "Table", syntax: "| … |", run: vi.fn() }, +]; +const complete = (doc: string, pos = doc.length) => { + const result = slashCommands(() => commands)(new CompletionContext(EditorState.create({ doc }), pos, false)); + if (result instanceof Promise) throw new Error("The slash menu is synchronous."); + return result; +}; +const labels = (doc: string) => complete(doc)?.options.map((o) => o.label) ?? null; + +describe("the slash menu", () => { + it("offers what can be inserted after a slash at a line start or after a space", () => { + expect(labels("/")).toEqual(["Footnote", "Table"]); + expect(labels("Some text /fo")).toEqual(["Footnote"]); + expect(labels("Some text /note")).toEqual(["Footnote"]); + }); + it("stays out of the way of slashes inside words and paths", () => { + expect(complete("figures/plan")).toBeNull(); + expect(complete("and/or")).toBeNull(); + expect(complete("/nothing-matches")).toBeNull(); + }); + it("shows each item's Markdown so it can be learned", () => { + expect(complete("/")?.options[0]?.detail).toBe("[^1]"); + }); +}); diff --git a/apps/writer/src/editor/slash.ts b/apps/writer/src/editor/slash.ts new file mode 100644 index 0000000..a41cb61 --- /dev/null +++ b/apps/writer/src/editor/slash.ts @@ -0,0 +1,35 @@ +// Typing "/" at the start of a line, or after a space, lists what can be +// inserted (a footnote, a figure, a table…) so the syntax need not be +// remembered: choosing one removes the "/" and inserts it. +import type { Completion, CompletionContext, CompletionResult, CompletionSource } from "@codemirror/autocomplete"; + +/** Something the editor can insert for the writer, shared by the slash menu, the Insert menu and the command palette. */ +export interface InsertCommand { + readonly id: string; + readonly label: string; + /** The Markdown it stands for, shown so it can be learned. */ + readonly syntax: string; + readonly keywords?: string; + readonly run: () => void; +} + +export function slashCommands(commands: () => readonly InsertCommand[]): CompletionSource { + return (context: CompletionContext): CompletionResult | null => { + const slash = context.matchBefore(/(?:^|\s)\/[\p{L}\p{N}-]*$/u); + if (!slash) return null; + const from = slash.from + slash.text.indexOf("/"); + const typed = context.state.sliceDoc(from + 1, context.pos).toLowerCase(); + const options: Completion[] = commands() + .filter((c) => !typed || `${c.label} ${c.keywords ?? ""}`.toLowerCase().includes(typed)) + .map((c) => ({ + label: c.label, + detail: c.syntax, + type: "keyword", + apply: (view, _completion, start, end) => { + view.dispatch({ changes: { from: start, to: end, insert: "" }, userEvent: "delete" }); + c.run(); + }, + })); + return options.length ? { from, options, filter: false } : null; + }; +} 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..aa50421 100644 --- a/apps/writer/src/styles.css +++ b/apps/writer/src/styles.css @@ -145,6 +145,7 @@ kbd { font-family: var(--mono); font-size: 11.5px; padding: 1px 5px; border: 1px .menu-item { display: grid; gap: 1px; width: 100%; text-align: left; padding: 8px 10px; border: 0; border-radius: var(--control-radius); background: none; cursor: pointer; } .menu-item strong { color: var(--ink); font-weight: 600; } .menu-item small { color: var(--muted); font-size: 11.5px; line-height: 1.35; } +.menu-item small code { font-size: 11px; color: var(--ink-soft); } .menu-item:hover:not(:disabled), .menu-item:focus-visible { background: var(--subtle); outline: 0; } .menu-item:disabled { opacity: 0.5; cursor: default; } .menu-item.is-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; font-size: 13px; } @@ -160,6 +161,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 +334,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 +459,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 +467,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 +550,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 +615,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 +634,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 +654,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/Home.tsx b/apps/writer/src/ui/Home.tsx index 4e843c3..d4854e6 100644 --- a/apps/writer/src/ui/Home.tsx +++ b/apps/writer/src/ui/Home.tsx @@ -14,6 +14,9 @@ import { chapterEmbeds } from "../workspace/chapters.js"; import { wordCount } from "../words.js"; import { PlusIcon, SearchIcon } from "./icons.js"; import { byRecentlyEdited, noteName, relativeTime, styleName, templateName } from "./names.js"; +import { DraftStore } from "../workspace/drafts.js"; +import { localNamespace, recentManuscripts, setWordGoal, wordGoal } from "./recent.js"; +import { ChevronRight, PenIcon } from "./icons.js"; /** Manuscripts listed before a search field is worth showing. */ const SEARCH_FROM = 6; @@ -38,6 +41,14 @@ export function Home({ backend, onOpen, collectionPicker }: { backend: WriterBac const [dialog, setDialog] = useState(false); const [filter, setFilter] = useState(""); const [limit, setLimit] = useState(50); + const namespace = localNamespace(backend); + // What this browser remembers: the manuscripts opened last, and drafts not yet saved. + const [recent, setRecent] = useState(() => recentManuscripts(namespace)); + const [drafts, setDrafts] = useState(() => new DraftStore(namespace).list()); + useEffect(() => { + setRecent(recentManuscripts(namespace)); + setDrafts(new DraftStore(namespace).list()); + }, [namespace, reload]); useEffect(() => { let live = true; @@ -125,6 +136,16 @@ export function Home({ backend, onOpen, collectionPicker }: { backend: WriterBac rows.current?.querySelectorAll("[data-path]").forEach((row) => observer.observe(row)); return () => observer.disconnect(); }, [shown, limit]); + const byPath = useMemo(() => new Map((manuscripts ?? []).map((m) => [m.path, m])), [manuscripts]); + const continuing = useMemo(() => recent.map((r) => byPath.get(r.path)).find((m): m is ManuscriptSummary => Boolean(m)), [recent, byPath]); + // A chapter's draft belongs to the manuscript that embeds it; a stray one is still listed. + const draftRows = useMemo(() => drafts.map((d) => { + const manuscript = byPath.get(d.path) ?? (manuscripts ?? []).find((m) => (summaries.get(m.path)?.chapters ?? m.chapters ?? []).some((c) => c === d.path || `${c}.md` === d.path || d.path.endsWith(`/${c}.md`))); + return { ...d, manuscript, title: byPath.get(d.path)?.title ?? noteName(d.path) }; + }), [drafts, byPath, manuscripts, summaries]); + useEffect(() => { + if (continuing) setVisiblePaths((previous) => previous.has(continuing.path) ? previous : new Set([...previous, continuing.path])); + }, [continuing]); const newButton = ( + : 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 && (