diff --git a/README.md b/README.md index 0390dbc..751a1e5 100644 --- a/README.md +++ b/README.md @@ -95,8 +95,10 @@ does the same. ### Sources and citations Open **Sources** to search your Reader library and insert a citation, optionally with a page -number. Sources already cited in the manuscript appear first. You can also type `@` or `[@` -to see completion suggestions by citekey, author, title, or year. +number. Sources already cited in the manuscript appear first, the rest most recently written +first. A search of three letters or more also looks through the text of your Reader highlights. +An expanded source lists the sections that cite it. You can also type `@` or `[@` to see +completion suggestions by citekey, author, title, or year. Each source needs citation metadata and a citekey. Manage those in [mdbase Reader](https://reader.mdbase.dev) in the same collection. Reader highlights can be inserted as quotations with their source citation @@ -118,11 +120,24 @@ To include another Markdown record, put its embed on a line of its own: Writer assembles these records in order. Citations, footnotes, and cross-references work across the manuscript. Chapter records do not need a special manuscript type. -Use **Outline** to open or add chapters. Drag chapters, or use **Alt+↑/↓**, to reorder them. +Use **Outline** to open or add chapters. Drag chapters, or use **Alt+↑/↓**, to reorder them; +double-click one (or press **F2**) to rename it. A chapter's headings fold away unless it is +the one being written. Sections with open comments or problems show a small count or dot +beside their word count. Chapter cards in the editor show their title, word count, and problems, with a button to open them. ## Navigate and review +Select a passage and press **Ctrl/⌘+Alt+M** to comment on it, or **Ctrl/⌘+Alt+S** to suggest an +edit: the comment is written beside the passage. Commented lines are marked in the editor's +margin, hovering a passage shows who said what, and **F7** steps to the next comment. The +**Comments** panel lists every thread, with what others wrote since you last opened the +manuscript marked as new, and can be filtered to one person's comments or your own. + +The sidebar's tabs can be opened side by side: the split button beside them shows a second +panel beneath, so the outline and comments can be read together. Hiding the sidebar leaves a +rail of its tabs. + Click a block in the preview to jump to the corresponding text. The preview also follows the 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 @@ -140,6 +155,8 @@ Useful shortcuts (**⌘** on macOS, **Ctrl** elsewhere): | -------------- | ------------------------ | | Ctrl/⌘+K | Open the command palette | | Ctrl/⌘+Shift+E | Find a source | +| Ctrl/⌘+Shift+M | Show comments | +| F7 | Go to the next comment | | Ctrl/⌘+, | Open manuscript settings | | Ctrl/⌘+Shift+S | Choose an export format | | F8 | Go to the next problem | diff --git a/apps/writer/scripts/browser-test.mjs b/apps/writer/scripts/browser-test.mjs index fa312c2..a1fa0b4 100644 --- a/apps/writer/scripts/browser-test.mjs +++ b/apps/writer/scripts/browser-test.mjs @@ -600,7 +600,7 @@ await step("the phone layout switches between write, preview and outline", async await page.locator(".preview-pane").waitFor({ state: "visible" }); assert.equal(await page.locator(".write").isVisible(), false); await page.screenshot({ path: "out/e2e-mobile-preview.png" }); - await page.getByRole("button", { name: "Outline", exact: true }).click(); + await page.getByRole("button", { name: "Sidebar", exact: true }).click(); await page.locator(".outline").waitFor({ state: "visible" }); await page.screenshot({ path: "out/e2e-mobile-outline.png" }); const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); diff --git a/apps/writer/src/App.tsx b/apps/writer/src/App.tsx index ac70368..4c777da 100644 --- a/apps/writer/src/App.tsx +++ b/apps/writer/src/App.tsx @@ -14,7 +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 { localNamespace, recentManuscripts, 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"; @@ -208,13 +208,15 @@ 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 }; + // When this manuscript was last opened here (read before the opening is recorded, below). + const since = useMemo(() => (path ? recentManuscripts(localNamespace(backend)).find((e) => e.path === path)?.at : undefined), [backend, path]); useEffect(() => { if (path) recordOpened(localNamespace(backend), path); }, [backend, path]); if (!path) return ; if (!workspace) return null; return <> - setPath(null, force)} /> + setPath(null, force)} /> { navigation?.finish(false); setNavigation(null); }} title="Some changes are not saved">

{navigation?.message}

Download a local copy before leaving if saving cannot be completed.

diff --git a/apps/writer/src/backend/collection.ts b/apps/writer/src/backend/collection.ts index ffe9de2..46d8121 100644 --- a/apps/writer/src/backend/collection.ts +++ b/apps/writer/src/backend/collection.ts @@ -115,7 +115,7 @@ export class CollectionSchema { let modified = false; for (const role of this.byType.values()) for (const [domain, binding] of role) { if (!binding.active) continue; - modified ||= domain === "manuscript"; + modified ||= domain === "manuscript" || domain === "source"; for (const field of needed[domain]) fields.add(binding.fields[field] ?? field); } return [ @@ -172,7 +172,8 @@ export class CollectionStore { if (!binding.active) continue; switch (domain) { case "source": { - const source = retainEffective && previous.source ? previous.source : libraryEntry(row.path, fieldsOf(effective, binding)); + const written = row.values?.["file_mtime"] ?? row.file?.mtime; + const source = retainEffective && previous.source ? previous.source : libraryEntry(row.path, fieldsOf(effective, binding), typeof written === "string" ? written : undefined); if (source) next.source = equal(previous?.source, source) ? previous!.source! : source; break; } diff --git a/apps/writer/src/backend/connect.test.ts b/apps/writer/src/backend/connect.test.ts index cd83ca1..7493c8c 100644 --- a/apps/writer/src/backend/connect.test.ts +++ b/apps/writer/src/backend/connect.test.ts @@ -98,7 +98,7 @@ describe("annotation loading", () => { expect(await f.backend.annotationPaths()).toEqual({ ok: true, value: f.rows.map((r) => r.path) }); expect(f.metadataQueries()).toEqual([{ types: ["highlight"], frontmatterMode: "persisted", output: "metadata", select: [{ name: "csl", expression: 'record["csl"]' }, { name: "title", expression: 'record["title"]' }, - { name: "reading", expression: 'record["reading"]' }] }]); + { name: "reading", expression: 'record["reading"]' }, { name: "file_mtime", expression: "file.mtime" }] }]); const annotations = await f.backend.annotationsForSource("sources/a.md"); expect(annotations).toMatchObject({ ok: true, value: [{ quote: "Quote A", locator: "p. 12" }, { quote: "Another A" }] }); expect(f.bodyQueries()).toEqual([{ types: ["highlight"], where: 'file.path in ["annotations/a.md","annotations/c.md"]', frontmatterMode: "persisted", includeBody: true }]); @@ -155,6 +155,7 @@ describe("annotation loading", () => { expect(discovery?.select).toEqual([ { name: "csl", expression: 'record["csl"]' }, { name: "title", expression: 'record["title"]' }, { name: "about", expression: 'record["about"]' }, { name: "date", expression: 'record["date"]' }, { name: "parent", expression: 'record["parent"]' }, + { name: "file_mtime", expression: "file.mtime" }, ]); expect(f.bodyQueries()).toEqual([{ types: ["feedback"], where: 'file.path in ["comments/a.md","comments/reply.md"]', frontmatterMode: "persisted", includeBody: true }]); expect(f.readRequests).not.toHaveBeenCalled(); diff --git a/apps/writer/src/backend/types.ts b/apps/writer/src/backend/types.ts index 68922c5..e838281 100644 --- a/apps/writer/src/backend/types.ts +++ b/apps/writer/src/backend/types.ts @@ -42,6 +42,8 @@ export interface LibraryEntry { readonly item: CslItem; readonly title: string; readonly path: string; + /** When the source record was last written (ISO 8601), when the collection says. */ + readonly modified?: string; } export interface CollectionIndex { @@ -142,14 +144,14 @@ export const manuscriptBody = (starter = false, citekey?: string): string => sta : "# Introduction {#sec-intro}\n\n"; /** The citekey of a CSL-JSON item in a Reader source's `csl` field. */ -export function libraryEntry(path: string, frontmatter: JsonObject | undefined): LibraryEntry | null { +export function libraryEntry(path: string, frontmatter: JsonObject | undefined, modified?: string): LibraryEntry | null { const csl = frontmatter?.["csl"]; if (!csl || typeof csl !== "object" || Array.isArray(csl)) return null; const item = csl as Record; const key = typeof item["id"] === "string" ? item["id"] : null; if (!key) return null; const title = typeof item["title"] === "string" ? item["title"] : typeof frontmatter?.["title"] === "string" ? frontmatter["title"] : key; - return { key, item: { ...item, id: key }, title, path }; + return { key, item: { ...item, id: key }, title, path, ...(modified ? { modified } : {}) }; } /** `chapters/x.md` numbered for the nth try: `chapters/x-2.md` (and `figures/x.png` to `figures/x-2.png`). */ diff --git a/apps/writer/src/editor/Editor.tsx b/apps/writer/src/editor/Editor.tsx index 20d114d..cf27a4e 100644 --- a/apps/writer/src/editor/Editor.tsx +++ b/apps/writer/src/editor/Editor.tsx @@ -12,7 +12,7 @@ import { memo, useEffect, useLayoutEffect, useRef } from "react"; import type { WriterDiagnostic } from "../compile/protocol.js"; import { chapterCards, refreshChapterCards, type ChapterCards } from "./chapter-cards.js"; -import { commentAnchors, showAnchors, type CommentAnchor } from "./comments.js"; +import { commentAnchors, showAnchors, type AnchorCard, type CommentAnchor } from "./comments.js"; import { writerCompletions, type CompletionData } from "./completions.js"; import { fixesFor } from "./fixes.js"; import { formattingKeymap, makeLink, toggleBold, toggleCode, toggleItalic, type InlineFormat } from "./formatting.js"; @@ -39,6 +39,10 @@ export interface EditorHandle { selection(): { from: number; to: number; text: string }; /** Formats the selection as Markdown (toggling bold, italic or code), or makes it a link. */ format(kind: InlineFormat | "link"): void; + /** Where a body offset is drawn, in viewport coordinates; null while it is not on screen. */ + coords(offset: number): { left: number; top: number; bottom: number } | null; + /** The editor's scrolling element, to follow while something is placed beside the text. */ + scroller(): HTMLElement; } export interface RetainedEditor { @@ -78,11 +82,13 @@ export interface EditorProps { activeComment?: string | null; /** A click on a commented passage or suggestion. */ onAnchor?(id: string): void; + /** What a hover over a commented passage shows of its thread. */ + anchorCard?(id: string): AnchorCard | null; /** An action from the toolbar over a selection, with the selected text. */ onSelectionAction?(action: SelectionAction, selected: string): void; } -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) { +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, anchorCard, onSelectionAction }: EditorProps) { const host = useRef(null); const view = useRef(null); // Texts this editor reported, newest last. The session echoes them back @@ -94,8 +100,8 @@ export const Editor = memo(function Editor({ states, path, text, readOnly, joinL const shownDiagnostics = useRef(""); const joining = useRef(new Compartment()); 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 }; + const latest = useRef({ onChange, completion, inserts, insight, onCursor, onFollow, chapters, onFindSource, onAnchor, anchorCard, onSelectionAction, onImageFiles }); + latest.current = { onChange, completion, inserts, insight, onCursor, onFollow, chapters, onFindSource, onAnchor, anchorCard, onSelectionAction, onImageFiles }; // Layout cleanup snapshots the viewport before React detaches its DOM (which // would reset scrollTop), including development's setup/cleanup/setup cycle. @@ -142,7 +148,7 @@ export const Editor = memo(function Editor({ states, path, text, readOnly, joinL writerLanguage(), writerInsight(() => latest.current.insight, (target) => latest.current.onFollow?.(target)), chapterCards(() => latest.current.chapters), - commentAnchors((id) => latest.current.onAnchor?.(id)), + commentAnchors((id) => latest.current.onAnchor?.(id), (id) => latest.current.anchorCard?.(id) ?? null), selectionBar((action, view) => { const { from, to } = view.state.selection.main; const selected = view.state.sliceDoc(from, to); @@ -192,6 +198,13 @@ export const Editor = memo(function Editor({ states, path, text, readOnly, joinL command(v); v.focus(); }, + coords(offset) { + const at = v.coordsAtPos(Math.min(offset, v.state.doc.length)); + return at ? { left: at.left, top: at.top, bottom: at.bottom } : null; + }, + scroller() { + return v.scrollDOM; + }, insert(insertion, options) { if (v.state.readOnly) return; v.dispatch(v.state.replaceSelection(insertion), { scrollIntoView: true, userEvent: "input" }); diff --git a/apps/writer/src/editor/comments.ts b/apps/writer/src/editor/comments.ts index 380a965..261647f 100644 --- a/apps/writer/src/editor/comments.ts +++ b/apps/writer/src/editor/comments.ts @@ -1,8 +1,8 @@ // Comment anchors in the editor: commented passages are highlighted, and a // suggestion shows as a tracked change (the text it replaces struck through, // its replacement inline after it). Anchors follow edits between updates. -import { StateEffect, StateField, type Extension } from "@codemirror/state"; -import { Decoration, EditorView, WidgetType, type DecorationSet } from "@codemirror/view"; +import { RangeSet, RangeSetBuilder, StateEffect, StateField, type Extension } from "@codemirror/state"; +import { Decoration, EditorView, gutter, GutterMarker, hoverTooltip, WidgetType, type DecorationSet } from "@codemirror/view"; import { locate, type CommentTarget } from "@mdbase-writer/core/comments"; export interface CommentAnchor { @@ -14,6 +14,16 @@ export interface CommentAnchor { readonly replacement?: string; } +/** What a hover over an anchor shows of its thread. */ +export interface AnchorCard { + readonly kind: "comment" | "suggestion"; + readonly author: string; + readonly when: string; + readonly text: string; + readonly replies: number; + readonly isNew?: boolean; +} + const setAnchors = StateEffect.define<{ anchors: readonly CommentAnchor[]; active: string | null }>(); class Insertion extends WidgetType { @@ -72,10 +82,123 @@ const anchorField = StateField.define({ provide: (f) => EditorView.decorations.from(f), }); -/** Anchors, and a click on one (its thread's id). */ -export function commentAnchors(onAnchor: (id: string) => void): Extension { +/** The thread ids anchored on each line, from the first comment's position (for the margin). */ +class CommentMarker extends GutterMarker { + constructor(readonly ids: readonly string[], readonly active: boolean) { + super(); + } + override eq(other: CommentMarker) { + return other.active === this.active && other.ids.join() === this.ids.join(); + } + override toDOM() { + const el = document.createElement("button"); + el.type = "button"; + el.className = `cm-comment-marker${this.active ? " is-active" : ""}`; + el.dataset["comment"] = this.ids[0] ?? ""; + el.textContent = this.ids.length > 1 ? String(this.ids.length) : ""; + el.title = this.ids.length > 1 ? `${this.ids.length} comments on this line` : "Show this comment"; + el.setAttribute("aria-label", el.title); + return el; + } +} + +const anchorsField = StateField.define<{ anchors: readonly CommentAnchor[]; active: string | null }>({ + create: () => ({ anchors: [], active: null }), + update(value, tr) { + for (const e of tr.effects) if (e.is(setAnchors)) return e.value; + return value; + }, +}); + +/** Markers by line: the decoration set already follows edits, so lines come from it. */ +function markersFor(decorations: DecorationSet, doc: { lineAt(pos: number): { from: number } }, active: string | null): RangeSet { + const byLine = new Map(); + const cursor = decorations.iter(); + while (cursor.value) { + const id = (cursor.value.spec as { attributes?: Record; widget?: Insertion }).attributes?.["data-comment"] ?? (cursor.value.spec as { widget?: Insertion }).widget?.id; + if (id) { + const line = doc.lineAt(cursor.from).from; + const ids = byLine.get(line) ?? []; + if (!ids.includes(id)) ids.push(id); + byLine.set(line, ids); + } + cursor.next(); + } + const builder = new RangeSetBuilder(); + for (const line of [...byLine.keys()].sort((a, b) => a - b)) { + const ids = byLine.get(line) ?? []; + builder.add(line, line, new CommentMarker(ids, active !== null && ids.includes(active))); + } + return builder.finish(); +} + +const markerField = StateField.define>({ + create: () => RangeSet.empty, + update(value, tr) { + const changed = tr.effects.some((e) => e.is(setAnchors)); + if (!changed && !tr.docChanged) return value; + return markersFor(tr.state.field(anchorField), tr.state.doc, tr.state.field(anchorsField).active); + }, +}); + +/** + * Anchors, a click on one (its thread's id), a margin marker on each + * commented line, and a card on hover saying who said what. + */ +export function commentAnchors(onAnchor: (id: string) => void, card?: (id: string) => AnchorCard | null): Extension { + const hover = hoverTooltip((view, pos) => { + if (!card) return null; + const decorations = view.state.field(anchorField); + let hit: { id: string; from: number; to: number } | null = null; + decorations.between(pos, pos, (from, to, value) => { + const id = (value.spec as { attributes?: Record }).attributes?.["data-comment"]; + if (id && !hit) hit = { id, from, to }; + }); + if (!hit) return null; + const { id, from, to } = hit; + const info = card(id); + if (!info) return null; + return { + pos: from, + end: to, + above: true, + create: () => { + const dom = document.createElement("div"); + dom.className = "cm-ref-tip cm-comment-tip"; + const head = document.createElement("div"); + head.className = "cm-ref-kind"; + head.textContent = `${info.kind === "suggestion" ? "Suggestion" : "Comment"}${info.isNew ? " · new" : ""}`; + const who = document.createElement("div"); + who.className = "cm-comment-who"; + who.textContent = `${info.author} · ${info.when}`; + const body = document.createElement("div"); + body.className = "cm-ref-body"; + body.textContent = info.text.length > 240 ? `${info.text.slice(0, 239)}…` : info.text; + const hint = document.createElement("div"); + hint.className = "cm-ref-hint"; + hint.textContent = `${info.replies ? `${info.replies} ${info.replies === 1 ? "reply" : "replies"} · ` : ""}Click to open in Comments`; + dom.append(head, who, body, hint); + return { dom }; + }, + }; + }, { hoverTime: 250 }); return [ + anchorsField, anchorField, + markerField, + gutter({ + class: "cm-comment-gutter", + markers: (view) => view.state.field(markerField), + domEventHandlers: { + click(_view, _line, event) { + const id = event.target instanceof HTMLElement ? event.target.closest("[data-comment]")?.dataset["comment"] : undefined; + if (!id) return false; + onAnchor(id); + return true; + }, + }, + }), + hover, EditorView.domEventHandlers({ click(event) { const hit = event.target instanceof Element ? event.target.closest("[data-comment]") : null; diff --git a/apps/writer/src/editor/library-search.ts b/apps/writer/src/editor/library-search.ts index 561aced..3eaea4c 100644 --- a/apps/writer/src/editor/library-search.ts +++ b/apps/writer/src/editor/library-search.ts @@ -60,9 +60,15 @@ export function authorYear(entry: LibraryEntry): string { */ const collator = new Intl.Collator(); const sorted = new WeakMap(); +/** The library as it is listed: most recently written first; sources the collection has no date for follow, by author and title. */ export function sortedLibrary(library: readonly LibraryEntry[]): readonly LibraryEntry[] { let rows = sorted.get(library); - if (!rows) { rows = [...index(library)].sort((a, b) => collator.compare(a.sortKey, b.sortKey) || collator.compare(a.entry.title, b.entry.title)).map((i) => i.entry); sorted.set(library, rows); } + if (!rows) { + rows = [...index(library)] + .sort((a, b) => (b.entry.modified ?? "").localeCompare(a.entry.modified ?? "") || collator.compare(a.sortKey, b.sortKey) || collator.compare(a.entry.title, b.entry.title)) + .map((i) => i.entry); + sorted.set(library, rows); + } return rows; } diff --git a/apps/writer/src/styles.css b/apps/writer/src/styles.css index aa50421..895cb2d 100644 --- a/apps/writer/src/styles.css +++ b/apps/writer/src/styles.css @@ -253,6 +253,22 @@ kbd { font-family: var(--mono); font-size: 11.5px; padding: 1px 5px; border: 1px .sidebar-tabs button:focus-visible { outline-offset: -2px; } .tab-count { font-size: 11px; font-weight: 400; color: var(--faint); font-variant-numeric: tabular-nums; } .sidebar-panel { min-height: 0; overflow: auto; padding: 0.5rem 0.5rem 1.5rem; scrollbar-gutter: stable; } +/* A second panel beneath the first, with its own small tabs. */ +.outline.has-lower { grid-template-rows: auto minmax(0, 1fr) minmax(0, 1fr); } +.sidebar-lower { display: grid; grid-template-rows: auto minmax(0, 1fr); min-height: 0; border-top: 1px solid var(--line); } +.sidebar-lower-tabs { display: flex; align-items: center; gap: 0.9rem; min-height: 32px; padding: 0 0.5rem 0 1rem; border-bottom: 1px solid var(--line); } +.sidebar-lower-tabs > [role="tab"] { padding: 0; border: 0; background: none; color: var(--muted); font-size: 12px; cursor: pointer; } +.sidebar-lower-tabs > [role="tab"]:hover { color: var(--ink); } +.sidebar-lower-tabs > [role="tab"][aria-selected="true"] { color: var(--ink); font-weight: 700; } +.sidebar-lower-tabs > .mdbase-icon-button { margin-left: auto; } +.sidebar-split { margin-left: auto; align-self: center; color: var(--muted); } +.sidebar-split[aria-pressed="true"] { color: var(--ink); } +/* The sidebar hidden leaves a rail of its tabs. */ +.sidebar-rail { grid-area: rail; display: grid; align-content: start; justify-items: center; gap: 0.25rem; padding: 0.5rem 0; border-right: 1px solid var(--line); background: var(--canvas); } +.rail-button { position: relative; display: grid; place-items: center; width: 32px; height: 32px; border: 0; border-radius: 6px; background: none; color: var(--muted); cursor: pointer; } +.rail-button:hover { background: var(--subtle); color: var(--ink); } +.rail-button .icon { width: 18px; height: 18px; } +.rail-count { position: absolute; top: 1px; right: -2px; min-width: 14px; height: 14px; padding: 0 3px; border-radius: 7px; font-size: 9.5px; font-weight: 700; line-height: 14px; text-align: center; color: var(--ink-soft); background: var(--subtle); border: 1px solid var(--line); font-variant-numeric: tabular-nums; } /* A handle over the sidebar's right rule; it shows only while pointed at, focused or dragged. */ .sidebar-resizer { position: absolute; top: 0; bottom: 0; right: -4px; width: 7px; z-index: 2; cursor: col-resize; touch-action: none; } .sidebar-resizer::after { content: ""; position: absolute; top: 0; bottom: 0; left: 2px; width: 3px; background: transparent; transition: background 150ms var(--ease); } @@ -317,6 +333,18 @@ kbd { font-family: var(--mono); font-size: 11.5px; padding: 1px 5px; border: 1px .heading-row[aria-current="location"] { color: var(--ink); font-weight: 700; } .heading-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .heading-words { flex: none; font-size: 11px; font-weight: 400; color: var(--faint); font-variant-numeric: tabular-nums; } +/* What a section holds, as quietly as its word count: a comment count, a problem dot. */ +.outline-comments { display: inline-flex; align-items: center; gap: 2px; flex: none; font-size: 11px; font-weight: 400; color: var(--faint); font-variant-numeric: tabular-nums; } +.outline-comments .icon { width: 11px; height: 11px; } +.heading-problem { flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--danger); opacity: 0.8; } +.record-line { display: flex; align-items: stretch; } +.record-line > .record-row { flex: 1; min-width: 0; } +.record-fold { flex: none; width: 22px; border: 0; background: none; color: var(--faint); cursor: pointer; border-radius: 4px; } +.record-fold .icon { width: 12px; height: 12px; transition: transform var(--motion-quick) ease; } +.record-fold[aria-expanded="true"] .icon { transform: rotate(90deg); } +.record-fold:hover { color: var(--ink); background: var(--subtle); } +.record-rename { padding: 0.15rem 0.5rem 0.15rem 1.9rem; } +.record-rename .mdbase-field { width: 100%; font-size: 13.5px; } .add-chapter { margin-top: 0.35rem; } .add-chapter-form { display: grid; gap: 0.4rem; margin-top: 0.35rem; padding: 0.5rem; border: 1px solid var(--line); border-radius: 8px; } .add-chapter-actions { display: flex; align-items: center; gap: 0.75rem; } @@ -388,6 +416,10 @@ kbd { font-family: var(--mono); font-size: 11.5px; padding: 1px 5px; border: 1px .cite-at { display: flex; gap: 0.35rem; } .cite-at .mdbase-field { flex: 1; min-width: 0; } .cite-at .mdbase-button { white-space: nowrap; max-width: 55%; overflow: hidden; text-overflow: ellipsis; } +.source-sites { margin: 0; line-height: 1.6; } +.site-link { padding: 0; border: 0; background: none; color: var(--accent); font: inherit; cursor: pointer; } +.site-link:hover { text-decoration: underline; text-underline-offset: 2px; } +.highlight-snippet { font-size: 12px; font-style: italic; color: var(--ink-soft); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .source-facts { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.3rem 0.75rem; min-height: 26px; } .source-uses-nav { display: inline-flex; align-items: center; gap: 0.1rem; margin-left: -0.35rem; color: var(--ink-soft); } .source-uses-nav > span { padding: 0 0.15rem; } @@ -406,6 +438,12 @@ kbd { font-family: var(--mono); font-size: 11.5px; padding: 1px 5px; border: 1px .comments { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.6rem; } .comments > p { margin: 0; padding: 0 0.5rem; } .comments-bar { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; padding: 0.15rem 0 0.1rem; } +.comments-who { padding: 0 0.1rem; } +.comments-who .mdbase-select { width: 100%; } +.thread-new { color: var(--accent); font-weight: 700; } +.thread.is-new { box-shadow: inset 2px 0 0 color-mix(in oklch, var(--accent) 55%, transparent); } +.comment-text code { font-size: 0.9em; padding: 0 3px; border-radius: 3px; background: var(--subtle); } +.comment-text a { color: var(--accent); } .signing { margin: 0; } .signing.is-unsigned { display: grid; gap: 0.35rem; padding: 0.6rem 0.7rem; border: 1px solid var(--color-warning-border); border-radius: var(--control-radius); background: var(--color-warning-surface); } .signing.is-unsigned p { margin: 0; color: var(--color-warning-text); } @@ -453,7 +491,17 @@ blockquote.thread-quote::after { content: "”"; } .tone-danger[role="alert"] { margin: 0; color: var(--danger); } .cm-comment { background: color-mix(in oklch, var(--warning) 22%, transparent); border-bottom: 2px solid color-mix(in oklch, var(--warning) 70%, transparent); cursor: pointer; } -.cm-comment.is-active { background: color-mix(in oklch, var(--warning) 42%, transparent); } +.cm-comment.is-active { background: color-mix(in oklch, var(--warning) 42%, transparent); box-shadow: 0 0 0 2px color-mix(in oklch, var(--warning) 45%, transparent); border-radius: 2px; } +/* Each commented line is marked in the margin; a count when there are several. */ +.editor .cm-comment-gutter { width: 16px; } +.editor .cm-comment-gutter .cm-gutterElement { display: flex; align-items: center; justify-content: center; } +.cm-comment-marker { width: 12px; height: 12px; min-width: 12px; padding: 0; border: 0; border-radius: 50%; background: color-mix(in oklch, var(--warning) 70%, transparent); color: #fff; font: 700 8.5px/12px var(--sans); cursor: pointer; } +.cm-comment-marker:hover, .cm-comment-marker.is-active { background: var(--warning); box-shadow: 0 0 0 2px color-mix(in oklch, var(--warning) 35%, transparent); } +.cm-comment-tip .cm-comment-who { font-size: 12px; color: var(--muted); } +/* A comment written beside its passage (InlineComposer). */ +.write { position: relative; } +.inline-composer { position: absolute; z-index: 30; width: min(24rem, calc(100% - 16px)); box-shadow: 0 6px 24px oklch(20% 0.01 255 / 0.12); border-radius: var(--control-radius); background: var(--surface); animation: mdbase-popover-in var(--motion-base) var(--ease-out); } +.inline-composer .composer { background: var(--surface); } .cm-comment-point { display: inline-block; width: 0; height: 1.1em; margin: 0 1px; border-left: 2px solid var(--warning); vertical-align: text-bottom; cursor: pointer; } .cm-suggest-delete { color: var(--danger); text-decoration: line-through; text-decoration-thickness: 1.5px; background: color-mix(in oklch, var(--danger) 10%, transparent); cursor: pointer; } .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; } @@ -647,6 +695,8 @@ fieldset.settings { margin: 0; padding: 0; border: 0; min-width: 0; } .pane-tab-icon { position: relative; display: inline-grid; } .tab-badge { position: absolute; top: -5px; left: 13px; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; font-size: 10px; font-weight: 700; line-height: 16px; text-align: center; color: #fff; background: var(--warning); } .tab-badge.tone-danger { background: var(--danger); } + .tab-badge.tone-comments { background: var(--accent); } + .sidebar-rail { display: none; } .settings-sheet { width: 100vw; border-right: 0; } .outline, .write, .preview-pane { grid-area: main; border-right: 0; } .workspace .outline, .workspace .write, .workspace .preview-pane { display: none; } diff --git a/apps/writer/src/ui/CommentsPanel.tsx b/apps/writer/src/ui/CommentsPanel.tsx index 1313bfd..c0e6e6d 100644 --- a/apps/writer/src/ui/CommentsPanel.tsx +++ b/apps/writer/src/ui/CommentsPanel.tsx @@ -8,7 +8,9 @@ import { personName, type CommentChange, type People } from "../backend/comments import type { Result } from "../backend/types.js"; import { ALT_LABEL, MOD_LABEL } from "../editor/insight.js"; import type { CommentDraft } from "../workspace/workspace.js"; -import { clipPassage, describeSuggestion, plainPassage, when, type PlacedThread } from "./comments.js"; +import { Select } from "@mdbase-dev/ui/select"; + +import { clipPassage, describeSuggestion, inlinePieces, plainPassage, threadIsNew, when, type PlacedThread } from "./comments.js"; import { useRowWindow } from "./paging.js"; import { PlusIcon } from "./icons.js"; import { plural } from "./records.js"; @@ -44,8 +46,11 @@ export function CommentsPanel({ onRetry, notSetUp = false, drafts: providedDrafts, + since, }: { drafts?: CommentDrafts; + /** When the manuscript was last opened here, for marking what others wrote since. */ + since?: string | undefined; notSetUp?: boolean; loading?: boolean; onRetry?(): void; @@ -73,11 +78,28 @@ export function CommentsPanel({ onReviewAccess?(): Promise>; }) { const [filter, setFilter] = useState("open"); + // Whose threads: everyone's, what is new since the last visit, one's own, or one person's. + const [who, setWho] = useState("all"); const panel = useRef(null); const ownDrafts = useRef(new Map()); const drafts = providedDrafts ?? ownDrafts.current; const window = useRowWindow(); - const groups = useMemo(() => ({ open: placed.filter((p) => p.thread.root.status === "open"), resolved: placed.filter((p) => p.thread.root.status === "resolved") }), [placed]); + const me = people.me?.link; + const isNew = (p: PlacedThread) => threadIsNew(p.thread, since, me); + const authors = useMemo(() => { + const links = new Map(); + for (const p of placed) for (const c of [p.thread.root, ...p.thread.replies]) if (c.createdBy && !links.has(c.createdBy.toLowerCase())) links.set(c.createdBy.toLowerCase(), personName(c.createdBy, people) ?? c.createdBy); + return [...links].sort((a, b) => a[1].localeCompare(b[1])); + }, [placed, people]); + const wanted = (p: PlacedThread) => { + if (who === "all") return true; + if (who === "new") return isNew(p); + const all = [p.thread.root, ...p.thread.replies]; + if (who === "mine") return Boolean(me) && all.some((c) => c.createdBy?.toLowerCase() === me?.toLowerCase()); + return all.some((c) => c.createdBy?.toLowerCase() === who); + }; + const newCount = useMemo(() => placed.filter((p) => p.thread.root.status === "open" && isNew(p)).length, [placed, since, me]); + const groups = useMemo(() => ({ open: placed.filter((p) => p.thread.root.status === "open" && wanted(p)), resolved: placed.filter((p) => p.thread.root.status === "resolved" && wanted(p)) }), [placed, who, since, me]); const counts = { open: groups.open.length, resolved: groups.resolved.length }; const logical = groups[filter]; const shown = logical.slice(window.from, window.from + window.size); @@ -113,10 +135,25 @@ export function CommentsPanel({ ))} - + {placed.length > 0 && (authors.length > 1 || newCount > 0) && ( +
+ setValue(e.target.value)} + onFocus={(e) => e.target.select()} + onBlur={() => onDone(value.trim() && value.trim() !== title ? value.trim() : null)} + onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); onDone(null); } }} + aria-label="Chapter title" + /> + + ); +} + /** A record's word count, counted again only when its text changes. */ const Words = memo(function Words({ body }: { body: string }) { return formatCount(wordCount(body)); @@ -188,11 +252,17 @@ const Words = memo(function Words({ body }: { body: string }) { * A record's headings, less an opening heading that repeats its title. Only * the record being edited, or the one the cursor is in, renders as you type. */ -const RecordHeadings = memo(function RecordHeadings({ view, path, cursor, onJump }: { view: RecordView | undefined; path: string; cursor: number | null; onJump(path: string, offset: number): void }) { +const RecordHeadings = memo(function RecordHeadings({ view, path, cursor, onJump, comments, problems }: { view: RecordView | undefined; path: string; cursor: number | null; onJump(path: string, offset: number): void; comments?: readonly number[] | undefined; problems?: readonly WriterDiagnostic[] | undefined }) { const body = view?.snapshot.body; const title = recordTitle(view, path); const all = useMemo(() => (body ? headings(body) : []), [body]); const words = useMemo(() => (body ? sectionWords(body, all) : []), [body, all]); + // What each section holds: the comments and problems anchored between its heading and the next of its level or higher. + const counts = useMemo(() => all.map((h, i) => { + const end = all.slice(i + 1).find((n) => n.level <= h.level)?.offset ?? Number.MAX_SAFE_INTEGER; + const within = (offset: number) => offset >= h.offset && offset < end; + return { comments: comments?.filter(within).length ?? 0, problems: problems?.filter((d) => !d.field && within(d.from)).length ?? 0 }; + }), [all, comments, problems]); const list = useMemo(() => withoutTitle(all, title), [all, title]); const window = useRowWindow(); const ranks = useMemo(() => new Map(all.map((h, i) => [h, i])), [all]); @@ -226,6 +296,8 @@ const RecordHeadings = memo(function RecordHeadings({ view, path, cursor, onJump onClick={() => onJump(path, h.offset)} > {h.text} + {(counts[ranks.get(h)!]?.comments ?? 0) > 0 && } + {(counts[ranks.get(h)!]?.problems ?? 0) > 0 && } {formatCount(words[ranks.get(h)!] ?? 0)} diff --git a/apps/writer/src/ui/SourcesPanel.tsx b/apps/writer/src/ui/SourcesPanel.tsx index 7413dd1..b97f233 100644 --- a/apps/writer/src/ui/SourcesPanel.tsx +++ b/apps/writer/src/ui/SourcesPanel.tsx @@ -11,6 +11,18 @@ import { authorYear, searchLibrary, sortedLibrary } from "../editor/library-sear import { readerSourceHref } from "../apps.js"; import { useRowWindow } from "./paging.js"; import { ChevronLeft, ChevronRight, ExternalIcon, SearchIcon } from "./icons.js"; +import { clipPassage } from "./comments.js"; + +/** Where a source is cited: a place in the text, named by its section. */ +export interface CitationSite { + readonly record: string; + readonly offset: number; + readonly name: string; +} + +/** A query this long also searches the text of highlights, once typing has paused this long. */ +const HIGHLIGHT_SEARCH_FROM = 3; +const HIGHLIGHT_SEARCH_AFTER_MS = 700; /** Library sources listed before "Show all". */ const LIBRARY_PREVIEW = 20; @@ -57,7 +69,12 @@ export function SourcesPanel({ onRetry: retryLibrary, notSetUp = false, annotationsNotSetUp = false, + sitesFor, + onJump, }: { + /** Where a source is cited, by section, for an expanded row. */ + sitesFor?(key: string): readonly CitationSite[]; + onJump?(record: string, offset: number): void; annotationsNotSetUp?: boolean; problem?: string | undefined; onRetry?(): void; @@ -93,6 +110,29 @@ export function SourcesPanel({ const sorted = useMemo(() => sortedLibrary(library), [library]); const rest = useMemo(() => sorted.filter((e) => !cited.has(e.key)), [sorted, cited]); const results = useMemo(() => (query.trim() ? searchLibrary(library, query, 60) : []), [library, query]); + const highlightSearch = query.trim().length >= HIGHLIGHT_SEARCH_FROM; + // Highlights are read once typing has paused, not on every keystroke. + const [settled, setSettled] = useState(false); + useEffect(() => { + if (!highlightSearch) { setSettled(false); return; } + const t = setTimeout(() => setSettled(true), HIGHLIGHT_SEARCH_AFTER_MS); + return () => clearTimeout(t); + }, [highlightSearch, query]); + const highlightIndex = useHighlightIndex(library, loadAnnotations, annotationVersion, highlightSearch && settled && !annotationsNotSetUp); + const inHighlights = useMemo(() => { + if (!highlightSearch) return []; + const words = query.toLowerCase().split(/\s+/).filter(Boolean); + const found = new Set(results.map((e) => e.key)); + const out: { entry: LibraryEntry; annotation: SourceAnnotation }[] = []; + for (const entry of library) { + if (found.has(entry.key)) continue; + const notes = highlightIndex.loaded.get(entry.path); + const hit = notes?.find((a) => { const text = `${a.quote ?? ""} ${a.note ?? ""}`.toLowerCase(); return words.every((w) => text.includes(w)); }); + if (hit) out.push({ entry, annotation: hit }); + if (out.length >= 30) break; + } + return out; + }, [highlightSearch, query, results, library, highlightIndex.loaded]); const rowFor = (key: string) => panel.current?.querySelector(`.source-row[data-key="${CSS.escape(key)}"]`); @@ -200,6 +240,8 @@ export function SourcesPanel({ onStep={onStepCitation} href={sourceHref?.(entry)} annotationsNotSetUp={annotationsNotSetUp} + sites={open === entry.key ? sitesFor?.(entry.key) ?? [] : NO_SITES} + {...(onJump ? { onJump } : {})} /> ); const shownRest = rest.slice(libraryWindow.from, libraryWindow.from + libraryWindow.size); @@ -224,10 +266,31 @@ export function SourcesPanel({ {libraryProblem &&

Sources unavailable: {libraryProblem}

} Down arrow moves to the sources; Enter cites the first match. {query.trim() ? ( -
-
    {results.map(row)}
- {results.length === 0 &&

Nothing matches “{query}”.

} -
+ <> +
+
    {results.map(row)}
+ {results.length === 0 && !inHighlights.length &&

{highlightSearch && highlightIndex.pending > 0 ? `No source matches “${query}”; searching highlights…` : `Nothing matches “${query}”.`}

} +
+ {highlightSearch && (inHighlights.length > 0 || (results.length > 0 && highlightIndex.pending > 0)) && ( +
+

+ In highlights {inHighlights.length} + {highlightIndex.pending > 0 && · searching {highlightIndex.pending} more} +

+
    + {inHighlights.map(({ entry, annotation }) => ( +
  • + +
  • + ))} +
+
+ )} + ) : ( <>
@@ -273,7 +336,11 @@ const SourceRow = memo(function SourceRow({ onStep, href, annotationsNotSetUp, + sites, + onJump, }: { + sites: readonly CitationSite[]; + onJump?(record: string, offset: number): void; annotationsNotSetUp: boolean; entry: LibraryEntry; locators: Map; @@ -339,6 +406,17 @@ const SourceRow = memo(function SourceRow({ Cite{locator.trim() ? ` at ${/^\d/.test(locator.trim()) ? `p. ${locator.trim()}` : locator.trim()}` : ""} + {sites.length > 0 && ( +

+ Cited in + {sites.map((site, i) => ( + + {i > 0 && , } + + + ))} +

+ )}
{uses > 0 ? ( @@ -392,3 +470,38 @@ const SourceRow = memo(function SourceRow({ ); }); + +const NO_SITES: readonly CitationSite[] = []; + +/** + * The highlights of every source, loaded a few at a time once a search is long + * enough to want them, and kept until the annotations change. + */ +function useHighlightIndex(library: readonly LibraryEntry[], load: (path: string) => Promise>, version: number, wanted: boolean): { loaded: ReadonlyMap; pending: number } { + const [state, setState] = useState<{ version: number; loaded: Map }>({ version, loaded: new Map() }); + const running = useRef(false); + useEffect(() => { + if (!wanted || running.current) return; + const loaded = state.version === version ? state.loaded : new Map(); + const todo = library.filter((e) => !loaded.has(e.path)).map((e) => e.path); + if (!todo.length) { if (state.version !== version) setState({ version, loaded }); return; } + running.current = true; + let live = true; + let index = 0; + const found = new Map(loaded); + const worker = async () => { + while (live && index < todo.length) { + const path = todo[index++] as string; + const result = await load(path).catch(() => null); + if (!live) return; + found.set(path, result?.ok ? result.value : []); + if (found.size % 8 === 0 || index >= todo.length) setState({ version, loaded: new Map(found) }); + } + }; + void Promise.all([worker(), worker(), worker(), worker()]).finally(() => { running.current = false; if (live) setState({ version, loaded: new Map(found) }); }); + return () => { live = false; running.current = false; }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- a load runs once per library and annotation version + }, [wanted, library, version, load]); + const loaded = state.version === version ? state.loaded : new Map(); + return { loaded, pending: wanted ? library.filter((e) => !loaded.has(e.path)).length : 0 }; +} diff --git a/apps/writer/src/ui/WorkspaceView.tsx b/apps/writer/src/ui/WorkspaceView.tsx index a23fa72..a234ad1 100644 --- a/apps/writer/src/ui/WorkspaceView.tsx +++ b/apps/writer/src/ui/WorkspaceView.tsx @@ -21,6 +21,8 @@ import { discoveredDiagnostics, sourceKeys, type ManuscriptWorkspace } from "../ import { Dialog } from "@mdbase-dev/ui/dialog"; import { AlertIcon, + BooksIcon, + ChatIcon, CheckIcon, ChevronDown, ChevronLeft, @@ -37,6 +39,7 @@ import { PlusIcon, SidebarIcon, SplitIcon, + SplitVerticalIcon, } from "./icons.js"; import { readerSourceHref } from "../apps.js"; import { errorMessage } from "../async.js"; @@ -44,8 +47,12 @@ import { zip } from "../export/zip.js"; import { CompareDialog, type Comparison } from "./CompareDialog.js"; import { clampSidebar, clampSplit, DEFAULT_LAYOUT, gridFor, loadLayout, nextZoom, saveLayout, SIDEBAR_MAX, SIDEBAR_MIN, type Layout, type SidebarTab, type View } from "./layout.js"; import { styleName, templateName } from "./names.js"; -import { anchorsFor, ThreadPlacement, type PlacedThread } from "./comments.js"; -import { CommentsPanel, openCount, type CommentDrafts, type PendingComment } from "./CommentsPanel.js"; +import { anchorsFor, threadIsNew, ThreadPlacement, when, type PlacedThread } from "./comments.js"; +import { CommentsPanel, Composer, openCount, type CommentDrafts, type PendingComment } from "./CommentsPanel.js"; +import { headingAt, headings } from "./outline.js"; +import { personName } from "../backend/comments.js"; +import type { AnchorCard } from "../editor/comments.js"; +import type { CitationSite } from "./SourcesPanel.js"; import { ManuscriptSearch, type SearchRequest } from "./ManuscriptSearch.js"; import { OutlinePanel } from "./OutlinePanel.js"; import { ProblemsStrip } from "./ProblemsStrip.js"; @@ -98,6 +105,23 @@ const NO_MARKS: readonly SourceMark[] = []; const THEME_NAME: Record = { system: "System", light: "Light", dark: "Dark" }; const VIEW_ORDER: readonly View[] = ["both", "write", "preview"]; +const SIDEBAR_TABS: readonly SidebarTab[] = ["outline", "sources", "comments"]; +const TAB_NAME: Record = { outline: "Outline", sources: "Sources", comments: "Comments" }; +/** The sidebar tab each manuscript was left on, for this page's lifetime; a manuscript opens on its outline. */ +const rememberedTabs = new Map(); + +/** Whether the window is laid out for a phone (one pane at a time). */ +function useNarrow(): boolean { + const [narrow, setNarrow] = useState(() => typeof matchMedia === "function" && matchMedia("(max-width: 900px)").matches); + useEffect(() => { + if (typeof matchMedia !== "function") return; + const query = matchMedia("(max-width: 900px)"); + const update = () => setNarrow(query.matches); + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, []); + return narrow; +} const VIEW_NAME: Record = { both: "Editor and preview", write: "Editor only", preview: "Preview only" }; const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform); @@ -120,7 +144,7 @@ function useSustained(flag: boolean, delay: number): boolean { return flag && held; } -export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWorkspace; onClose(force?: boolean): void }) { +export function WorkspaceView({ workspace, since, onClose }: { workspace: ManuscriptWorkspace; /** When this manuscript was last opened here, so what others wrote since can be marked. */ since?: string | undefined; onClose(force?: boolean): void }) { const snap = useSyncExternalStore(workspace.subscribe, workspace.getSnapshot); const { reportError } = useFeedback(); const failedSaves = useRef(new Set()); @@ -139,7 +163,8 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor const slowCompile = useSustained(snap.compiling, 600); const [active, setActive] = useState(workspace.main); const [pane, setPane] = useState("write"); - const [layout, setLayoutState] = useState(loadLayout); + const [layout, setLayoutState] = useState(() => ({ ...loadLayout(), tab: rememberedTabs.get(workspace.main) ?? "outline" })); + const narrow = useNarrow(); const [notice, setNotice] = useState(null); // An export has no count to show, so the mark streams until it ends or is cancelled. useMdbaseMarkBusy(notice?.tone === "busy" && "stream"); @@ -186,7 +211,13 @@ 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 setSidebarTab = useCallback((tab: SidebarTab) => { + rememberedTabs.set(workspace.main, tab); + // The lower panel never repeats the upper: the two swap. + setLayout((l) => ({ ...l, tab, lowerTab: l.lowerTab === tab ? l.tab : l.lowerTab })); + }, [setLayout, workspace.main]); + const setLowerTab = useCallback((lowerTab: SidebarTab | null) => setLayout((l) => ({ ...l, lowerTab: lowerTab === l.tab ? null : lowerTab })), [setLayout]); + const toggleLower = useCallback(() => setLayout((l) => ({ ...l, lowerTab: l.lowerTab ? null : l.tab === "comments" ? "outline" : "comments" })), [setLayout]); const order = useMemo(() => workspace.readingOrder(), [workspace, snap.records, snap.recordPaths, snap.annotationPaths]); const activeView = snap.records.get(active); @@ -251,7 +282,7 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor }, [placed, active]); const showComments = useCallback(() => { - setLayout((l) => (l.sidebar && l.tab === "comments" ? l : { ...l, sidebar: true, tab: "comments" })); + setLayout((l) => (l.sidebar && (l.tab === "comments" || l.lowerTab === "comments") ? l : { ...l, sidebar: true, tab: "comments", lowerTab: l.lowerTab === "comments" ? l.tab : l.lowerTab })); setPane("outline"); }, [setLayout]); @@ -265,6 +296,27 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor showComments(); }, [snap.records, active, showComments]); + const me = snap.people.me?.link; + const anchorCard = useCallback((id: string): AnchorCard | null => { + const hit = placed.find((p) => p.thread.root.path === id); + if (!hit) return null; + const { root, replies } = hit.thread; + return { + kind: root.motivation === "editing" ? "suggestion" : "comment", + author: personName(root.createdBy, snap.people) ?? "Unsigned", + when: when(root.createdAt), + text: root.text || (root.suggestion ? `Replace with “${root.suggestion.replacement}”` : ""), + replies: replies.filter((r) => !r.deletedAt).length, + isNew: threadIsNew(hit.thread, since, me), + }; + }, [placed, snap.people, since, me]); + // Where each record's open threads are anchored, for the outline's quiet counts. + const commentOffsets = useMemo(() => { + const map = new Map(); + for (const p of placed) if (p.thread.root.status === "open" && p.at && p.at !== "whole") map.set(p.record, [...(map.get(p.record) ?? []), p.at.from]); + return map; + }, [placed]); + const openThreads = useMemo(() => placed.filter((p) => p.thread.root.status === "open"), [placed]); const selectThread = useCallback((p: PlacedThread) => { setActiveComment(p.thread.root.path); if (p.at && p.at !== "whole") jump(p.record, p.at.from); @@ -379,6 +431,40 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor else showSources(target.key); }, [jump, showSources]); + // The next or previous open comment, in reading order from the cursor (F7 / Shift-F7). + const stepComment = useCallback((direction: 1 | -1) => { + const { order, cursor, active } = citationNav.current; + const located = openThreads.filter((p) => p.at && p.at !== "whole"); + if (!located.length) return; + const here = cursor ?? { record: active, offset: -1 }; + const rank = (record: string, offset: number) => order.indexOf(record) * 1e9 + offset; + const at = rank(here.record, here.offset); + const pos = (p: PlacedThread) => rank(p.record, (p.at as { from: number }).from); + const next = direction > 0 + ? located.find((p) => pos(p) > at) ?? located[0] + : [...located].reverse().find((p) => pos(p) < at) ?? located[located.length - 1]; + if (next) { setActiveComment(next.thread.root.path); jump(next.record, (next.at as { from: number }).from); showComments(); } + }, [openThreads, jump, showComments]); + + // Where a source is cited, named by section, for the Sources panel. + const sitesFor = useCallback((key: string): readonly CitationSite[] => { + const { order, snap } = citationNav.current; + const out: CitationSite[] = []; + const seen = new Set(); + for (const record of order) { + const body = snap.records.get(record)?.snapshot.body ?? ""; + const list = headings(body); + for (const offset of referenceOffsets(body, key)) { + const heading = headingAt(list, offset); + const id = `${record}:${heading?.offset ?? -1}`; + if (seen.has(id)) continue; + seen.add(id); + out.push({ record, offset, name: heading?.text ?? recordTitle(snap.records.get(record), record) }); + } + } + return out; + }, []); + // The next or previous citation of a source, in reading order from the cursor. const citationNav = useRef({ order, snap, cursor, active }); citationNav.current = { order, snap, cursor, active }; @@ -502,6 +588,16 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor if (next) jump(next.record, next.from); }, [bodyProblems, cursor, active, jump, order]); + const submitComment = useCallback(async (text: string, replacement?: string) => { + if (!pendingComment) return { ok: true as const, value: null }; + const created = signalResult(await workspace.addComment(pendingComment.draft ?? { record: pendingComment.record }, text, replacement)); + if (created.ok) { + setPendingComment(null); + setActiveComment(created.value.path); + editor.current?.reveal(pendingComment.draft?.to ?? 0, { focus: true }); + } + return created; + }, [pendingComment, workspace]); 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"; @@ -601,6 +697,8 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor else if (mod && e.shiftKey && e.code === "Backslash") setLayout((l) => ({ ...l, view: VIEW_ORDER[(VIEW_ORDER.indexOf(l.view) + 1) % VIEW_ORDER.length] as View })); else if (mod && e.shiftKey && e.code === "KeyF") showSearch(); else if (mod && e.shiftKey && e.code === "KeyE") showSources(); + else if (mod && e.shiftKey && e.code === "KeyM") showComments(); + else if (e.key === "F7" && !mod) stepComment(e.shiftKey ? -1 : 1); else if (mod && !e.shiftKey && e.key === ",") (settingsOpen ? closeSettings() : setSettingsOpen(true)); else if (mod && e.altKey && e.code === "KeyM") startComment("comment"); else if (mod && e.altKey && e.code === "KeyS") startComment("suggest"); @@ -623,6 +721,93 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor return () => window.removeEventListener("keydown", listener, true); }, []); + const sourcesPanel = ( + void workspace.retryMetadata()} + notSetUp={workspace.setupStatus?.sources === false} + annotationsNotSetUp={workspace.setupStatus?.annotations === false} + canInsert={Boolean(activeView && activeView.snapshot.state !== "deleted" && !snap.recoveredDrafts.has(active))} + onInsert={insertSource} + onStepCitation={stepCitation} + sitesFor={sitesFor} + onJump={jump} + {...(sourceHref ? { sourceHref } : {})} + request={sourcesRequest} + /> + ); + /** A sidebar panel's contents (Sources in the upper slot is rendered by the sidebar itself, kept mounted). */ + const panelFor = (tab: SidebarTab) => + tab === "sources" ? sourcesPanel : ( + tab === "outline" ? ( + <> + recordTitle(snap.records.get(path), path)} + onGo={(hit, focus) => jump(hit.record, hit.from, { to: hit.to, focus })} + request={searchRequest} + /> + {!searchQuery.trim() && ( + { + setActive(path); + setPane("write"); + }} + onJump={jump} + onMove={(from, to) => workspace.moveChapter(from, to)} + onAdd={async (title) => signalFailure(await workspace.addChapter(title))} + onRename={(path, title) => signalFailure(workspace.renameChapter(path, title))} + commentOffsets={commentOffsets} + /> + )} + + ) : ( + void workspace.loadComments()} + placed={placed} + drafts={commentDrafts.current} + grouped={order.length > 1} + people={snap.people} + problem={snap.commentsProblem} + notSetUp={workspace.setupStatus?.comments === false} + active={activeComment} + pending={pendingComment?.draft && !narrow ? null : pendingComment} + since={since} + recordTitle={(path) => recordTitle(snap.records.get(path), path)} + onSelect={selectThread} + onSubmit={submitComment} + onCancel={() => setPendingComment(null)} + onReply={replyToThread} + onChange={changeComment} + onAccept={acceptSuggestion} + onWholeRecord={() => { + setPendingComment({ kind: "comment", record: active }); + setActiveComment(null); + }} + onCheckAccount={() => workspace.refreshPeople()} + {...(workspace.kind === "connect" ? { onReviewAccess: () => workspace.reviewIdentityAccess() } : {})} + /> + ) + ); + if (snap.phase === "failed") { return ( @@ -716,104 +901,49 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor
-
); } +/** A comment composer beside the passage it is for, following the text as it scrolls. */ +function InlineComposer({ editor, at, children }: { editor: React.RefObject; at: number; children: ReactNode }) { + const box = useRef(null); + useEffect(() => { + const el = box.current; + const host = el?.parentElement; + const handle = editor.current; + if (!el || !host || !handle) return; + const place = () => { + const coords = handle.coords(at); + const frame = host.getBoundingClientRect(); + if (!coords) { el.style.visibility = "hidden"; return; } + el.style.visibility = ""; + const width = el.offsetWidth; + const height = el.offsetHeight; + const left = Math.max(8, Math.min(coords.left - frame.left, frame.width - width - 8)); + const below = coords.bottom - frame.top + 8; + const top = below + height <= frame.height - 8 ? below : Math.max(8, coords.top - frame.top - height - 8); + el.style.left = `${left}px`; + el.style.top = `${top}px`; + }; + place(); + const scroller = handle.scroller(); + scroller.addEventListener("scroll", place, { passive: true }); + window.addEventListener("resize", place); + const observer = new ResizeObserver(place); + observer.observe(el); + return () => { scroller.removeEventListener("scroll", place); window.removeEventListener("resize", place); observer.disconnect(); }; + }, [editor, at]); + return
{children}
; +} + function ProblemsButton({ diagnostics, pending, checking, open, setOpen }: { diagnostics: readonly WriterDiagnostic[]; pending: boolean; checking: boolean; open: boolean; setOpen(open: boolean): void }) { if (!diagnostics.length) { const label = pending ? "Dependencies not checked" : checking ? "Checking…" : "No problems"; @@ -1398,6 +1581,9 @@ const SHORTCUTS: readonly [string, string][] = [ [`${MOD_LABEL} Shift \\`, "Editor and preview → editor only → preview only"], [`${MOD_LABEL} Shift F`, "Search the whole manuscript"], [`${MOD_LABEL} Shift E`, "Find a source"], + [`${MOD_LABEL} Shift M`, "Show comments"], + ["F7 / Shift F7", "Next / previous comment"], + ["F2", "Rename the chapter chosen in the outline"], [`${MOD_LABEL} ,`, "Manuscript settings"], [`${MOD_LABEL} B / ${MOD_LABEL} I`, "Bold / italic (again to remove it)"], [`${MOD_LABEL} Shift K`, "Make the selection a link"], diff --git a/apps/writer/src/ui/comments-inline.test.ts b/apps/writer/src/ui/comments-inline.test.ts new file mode 100644 index 0000000..549b970 --- /dev/null +++ b/apps/writer/src/ui/comments-inline.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import type { CommentThread } from "@mdbase-writer/core/comments"; +import { inlinePieces, threadIsNew } from "./comments.js"; + +describe("comment text", () => { + it("renders emphasis, code and links, leaving the rest as written", () => { + expect(inlinePieces("Is this *Lyell*'s phrase? See `[^note]` and [the book](https://example.org/x), or https://example.org/y.")).toEqual([ + { kind: "text", text: "Is this " }, + { kind: "em", text: "Lyell" }, + { kind: "text", text: "'s phrase? See " }, + { kind: "code", text: "[^note]" }, + { kind: "text", text: " and " }, + { kind: "link", text: "the book", href: "https://example.org/x" }, + { kind: "text", text: ", or " }, + { kind: "link", text: "https://example.org/y", href: "https://example.org/y" }, + { kind: "text", text: "." }, + ]); + expect(inlinePieces("snake_case_name stays")).toEqual([{ kind: "text", text: "snake_case_name stays" }]); + }); +}); + +const comment = (createdAt: string, createdBy?: string) => ({ path: `c/${createdAt}.md`, document: "[[a]]", motivation: "commenting" as const, status: "open" as const, createdAt, text: "x", ...(createdBy ? { createdBy } : {}) }); + +describe("what is new since the last visit", () => { + const thread: CommentThread = { root: comment("2026-10-01T00:00:00Z", "[[people/sam]]"), replies: [comment("2026-10-05T00:00:00Z", "[[people/jordan]]")] }; + it("marks a thread someone else wrote in after the last visit, but not one's own writing", () => { + expect(threadIsNew(thread, "2026-10-03T00:00:00Z", "[[people/sam]]")).toBe(true); + expect(threadIsNew(thread, "2026-10-03T00:00:00Z", "[[people/jordan]]")).toBe(false); + expect(threadIsNew(thread, "2026-10-06T00:00:00Z", undefined)).toBe(false); + expect(threadIsNew(thread, undefined, undefined)).toBe(false); + }); +}); diff --git a/apps/writer/src/ui/comments.ts b/apps/writer/src/ui/comments.ts index 890b5dc..5fc57b8 100644 --- a/apps/writer/src/ui/comments.ts +++ b/apps/writer/src/ui/comments.ts @@ -138,3 +138,35 @@ export function when(iso: string, now = Date.now()): string { if (abs < 7 * 86_400) return relative.format(Math.round(seconds / 86_400), "day"); return new Date(then).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }); } + +/** A run of comment text as it reads: plain, emphasised, code, or a link. */ +export type InlinePiece = { readonly kind: "text" | "em" | "strong" | "code"; readonly text: string } | { readonly kind: "link"; readonly text: string; readonly href: string }; + +/** + * The light Markdown a comment uses (emphasis, code, links), as pieces to + * render; anything else stays as written. Only http(s) links are links. + */ +export function inlinePieces(text: string): InlinePiece[] { + const out: InlinePiece[] = []; + const pattern = /`([^`\n]+)`|\*\*(?=\S)([^\n]*?\S)\*\*|\*(?=\S)([^*\n]*?\S)\*|(?")\]]+[^\s<>")\].,;:!?])/gu; + let last = 0; + for (const m of text.matchAll(pattern)) { + const at = m.index ?? 0; + if (at > last) out.push({ kind: "text", text: text.slice(last, at) }); + if (m[1] !== undefined) out.push({ kind: "code", text: m[1] }); + else if (m[2] !== undefined) out.push({ kind: "strong", text: m[2] }); + else if (m[3] !== undefined || m[4] !== undefined) out.push({ kind: "em", text: (m[3] ?? m[4]) as string }); + else if (m[5] !== undefined && m[6] !== undefined) out.push({ kind: "link", text: m[5], href: m[6] }); + else if (m[7] !== undefined) out.push({ kind: "link", text: m[7], href: m[7] }); + last = at + m[0].length; + } + if (last < text.length) out.push({ kind: "text", text: text.slice(last) }); + return out; +} + +/** Whether anyone else wrote in the thread after `since` (a thread of one's own is never new). */ +export function threadIsNew(thread: CommentThread, since: string | undefined, me: string | undefined): boolean { + if (!since) return false; + const mine = (c: CommentRecord) => Boolean(me && c.createdBy && c.createdBy.toLowerCase() === me.toLowerCase()); + return [thread.root, ...thread.replies].some((c) => !c.deletedAt && !mine(c) && c.createdAt > since); +} diff --git a/apps/writer/src/ui/icons.tsx b/apps/writer/src/ui/icons.tsx index 5583612..9a9b44d 100644 --- a/apps/writer/src/ui/icons.tsx +++ b/apps/writer/src/ui/icons.tsx @@ -23,6 +23,9 @@ import { MinusIcon as PhMinus } from "@phosphor-icons/react/Minus"; import { PencilSimpleIcon as PhPencilSimple } from "@phosphor-icons/react/PencilSimple"; import { PlusIcon as PhPlus } from "@phosphor-icons/react/Plus"; import { PlusSquareIcon as PhPlusSquare } from "@phosphor-icons/react/PlusSquare"; +import { ChatCircleIcon as PhChatCircle } from "@phosphor-icons/react/ChatCircle"; +import { BooksIcon as PhBooks } from "@phosphor-icons/react/Books"; +import { SquareSplitVerticalIcon as PhSquareSplitVertical } from "@phosphor-icons/react/SquareSplitVertical"; import { SidebarSimpleIcon as PhSidebarSimple } from "@phosphor-icons/react/SidebarSimple"; import { TextBIcon as PhTextB } from "@phosphor-icons/react/TextB"; import { TextItalicIcon as PhTextItalic } from "@phosphor-icons/react/TextItalic"; @@ -50,6 +53,9 @@ export const CheckIcon = icon(PhCheck); export const CloseIcon = icon(PhX); export const PlusIcon = icon(PhPlus); export const InsertIcon = icon(PhPlusSquare); +export const ChatIcon = icon(PhChatCircle); +export const BooksIcon = icon(PhBooks); +export const SplitVerticalIcon = icon(PhSquareSplitVertical); export const MinusIcon = icon(PhMinus); export const KeyboardIcon = icon(PhKeyboard); export const MoreIcon = icon(PhDotsThree); diff --git a/apps/writer/src/ui/layout.test.ts b/apps/writer/src/ui/layout.test.ts index 1b5d67a..a1259d4 100644 --- a/apps/writer/src/ui/layout.test.ts +++ b/apps/writer/src/ui/layout.test.ts @@ -6,7 +6,9 @@ describe("gridFor", () => { it("gives the sidebar its width, within bounds", () => { expect(gridFor({ ...DEFAULT_LAYOUT, sidebarWidth: 320 }).columns).toMatch(/^320px /); expect(gridFor({ ...DEFAULT_LAYOUT, sidebarWidth: 90 }).columns).toMatch(/^200px /); - expect(gridFor({ ...DEFAULT_LAYOUT, sidebar: false }).areas).toBe(`"write divider preview"`); + // Hidden, the sidebar leaves a rail of its tabs. + expect(gridFor({ ...DEFAULT_LAYOUT, sidebar: false }).areas).toBe(`"rail write divider preview"`); + expect(gridFor({ ...DEFAULT_LAYOUT, sidebar: false }).columns.startsWith("44px")).toBe(true); }); it("rounds and bounds a dragged width", () => { expect(clampSidebar(333.6)).toBe(334); diff --git a/apps/writer/src/ui/layout.ts b/apps/writer/src/ui/layout.ts index 69f07aa..24ec621 100644 --- a/apps/writer/src/ui/layout.ts +++ b/apps/writer/src/ui/layout.ts @@ -6,6 +6,8 @@ export type SidebarTab = "outline" | "sources" | "comments"; export interface Layout { readonly sidebar: boolean; readonly tab: SidebarTab; + /** A second panel shown under the first, or none. */ + readonly lowerTab: SidebarTab | null; /** The sidebar's width in CSS pixels. */ readonly sidebarWidth: number; readonly view: View; @@ -19,7 +21,7 @@ export interface Layout { } 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, livePreview: true }; +export const DEFAULT_LAYOUT: Layout = { sidebar: true, tab: "outline", lowerTab: null, 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; @@ -33,6 +35,7 @@ export function loadLayout(): Layout { return { sidebar: typeof raw.sidebar === "boolean" ? raw.sidebar : DEFAULT_LAYOUT.sidebar, tab: raw.tab === "sources" || raw.tab === "comments" ? raw.tab : "outline", + lowerTab: raw.lowerTab === "outline" || raw.lowerTab === "sources" || raw.lowerTab === "comments" ? raw.lowerTab : null, sidebarWidth: typeof raw.sidebarWidth === "number" ? clampSidebar(raw.sidebarWidth) : DEFAULT_LAYOUT.sidebarWidth, 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, @@ -56,7 +59,8 @@ export function saveLayout(layout: Layout): void { /** Grid columns and areas for the desktop workspace. */ export function gridFor(layout: Layout): { columns: string; areas: string } { const cols: [string, string][] = []; - if (layout.sidebar) cols.push([`${clampSidebar(layout.sidebarWidth)}px`, "outline"]); + // Hidden, the sidebar leaves a rail of its tabs. + cols.push(layout.sidebar ? [`${clampSidebar(layout.sidebarWidth)}px`, "outline"] : ["44px", "rail"]); if (layout.view !== "preview") cols.push([`minmax(0, ${layout.view === "both" ? layout.split : 1}fr)`, "write"]); if (layout.view === "both") cols.push(["7px", "divider"]); if (layout.view !== "write") cols.push([`minmax(0, ${layout.view === "both" ? 1 - layout.split : 1}fr)`, "preview"]); diff --git a/apps/writer/src/workspace/workspace.ts b/apps/writer/src/workspace/workspace.ts index e11d207..997fef3 100644 --- a/apps/writer/src/workspace/workspace.ts +++ b/apps/writer/src/workspace/workspace.ts @@ -190,6 +190,11 @@ export class ManuscriptWorkspace { return this.backend.kind; } + /** The namespace this browser keeps local state for the collection under (drafts, recents). */ + get namespace(): string { + return this.backend.draftNamespace ?? `${this.backend.kind}:${this.backend.collectionName}`; + } + getSnapshot = (): WorkspaceSnapshot => this.current; subscribe = (listener: () => void): (() => void) => { @@ -748,6 +753,28 @@ export class ManuscriptWorkspace { return ok(path); } + /** + * Gives a chapter a new title: in its frontmatter when it has one there, + * otherwise in the heading that opens it (what the outline shows). + */ + renameChapter(path: string, title: string): Result { + const name = title.trim(); + if (!name) return fail("A chapter needs a title."); + const view = this.current.records.get(path); + if (!view || view.snapshot.state === "deleted") return fail("The chapter is not open."); + if (typeof view.snapshot.frontmatter["title"] === "string") { + this.patchFrontmatter(path, { title: name }); + return ok(undefined); + } + const body = view.snapshot.body; + const heading = /^(#\s+)(.+?)(\s*\{[^}]*\})?\s*$/m.exec(body); + if (heading && heading.index !== undefined) { + const line = `${heading[1]}${name}${heading[3] ?? ""}`; + this.setBody(path, `${body.slice(0, heading.index)}${line}${body.slice(heading.index + heading[0].length)}`); + } else this.setBody(path, `# ${name}\n\n${body}`); + return ok(undefined); + } + /** Whether the collection accepts files, so a figure can be stored from the editor. */ get canStoreFiles(): boolean { return typeof this.backend.writeFile === "function";