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({
))}
-
- Comment
+
+ On the record
+ {placed.length > 0 && (authors.length > 1 || newCount > 0) && (
+
+ { setWho(value); window.reset(); }}
+ options={[
+ { value: "all", label: "Everyone" },
+ ...(newCount > 0 || who === "new" ? [{ value: "new", label: `New since last visit (${newCount})` }] : []),
+ ...(me ? [{ value: "mine", label: "Mine" }] : []),
+ ...authors.map(([link, name]) => ({ value: link, label: name })),
+ ]}
+ />
+
+ )}
{pending && }
{loading && Loading comments…
}
@@ -152,6 +189,7 @@ export function CommentsPanel({
placed={p}
drafts={drafts}
people={people}
+ isNew={isNew(p)}
active={p.thread.root.path === active}
onSelect={onSelect}
onReply={onReply}
@@ -182,7 +220,7 @@ function useAction() {
return { busy, error, run };
}
-function Composer({ pending, signer, recordTitle, onSubmit, onCancel }: { pending: PendingComment; signer: string | undefined; recordTitle(path: string): string; onSubmit(text: string, replacement?: string): Promise>; onCancel(): void }) {
+export function Composer({ pending, signer, recordTitle, onSubmit, onCancel }: { pending: PendingComment; signer: string | undefined; recordTitle(path: string): string; onSubmit(text: string, replacement?: string): Promise>; onCancel(): void }) {
const { draft, kind } = pending;
const quote = draft ? draft.body.slice(draft.from, draft.to) : "";
const [text, setText] = useState("");
@@ -248,6 +286,7 @@ const Thread = memo(function Thread({
drafts,
placed,
people,
+ isNew = false,
active,
onSelect,
onReply,
@@ -257,6 +296,7 @@ const Thread = memo(function Thread({
placed: PlacedThread;
drafts: CommentDrafts;
people: People;
+ isNew?: boolean;
active: boolean;
onSelect(placed: PlacedThread): void;
onReply(thread: CommentThread, text: string): Promise>;
@@ -278,8 +318,9 @@ const Thread = memo(function Thread({
const resolvedNote = !open ? `${suggestion ? outcome : "Resolved"}${root.resolvedBy ? ` by ${personName(root.resolvedBy, people)}` : ""}${root.resolvedAt ? ` ${when(root.resolvedAt)}` : ""}` : null;
return (
-
+
onSelect(placed)} title={at === null ? "This passage is no longer in the text" : "Show in the editor"}>
+ {isNew && New since your last visit }
{suggestion ? : root.target ? {plainPassage(root.target.quote.exact) || "(a point in the text)"} : On the whole record }
{at === null && Detached: the text has changed }
@@ -354,6 +395,21 @@ const Thread = memo(function Thread({
});
/** A suggested edit as it will read: the text taken out, struck through, and the text put in. */
+/** A comment's text with its light Markdown rendered. */
+function InlineText({ text }: { text: string }) {
+ return (
+ <>
+ {inlinePieces(text).map((piece, i) =>
+ piece.kind === "link" ? {piece.text}
+ : piece.kind === "em" ? {piece.text}
+ : piece.kind === "strong" ? {piece.text}
+ : piece.kind === "code" ? {piece.text}
+ : piece.text,
+ )}
+ >
+ );
+}
+
function Suggestion({ root }: { root: CommentRecord }) {
const exact = root.target?.quote.exact ?? "";
const replacement = root.suggestion?.replacement ?? "";
@@ -377,7 +433,7 @@ function Comment({ comment, people, reply, children }: { comment: CommentRecord;
{author} {when(comment.createdAt)}
{children}
- {comment.deletedAt ? Withdrawn.
: comment.text ? {comment.text}
: null}
+ {comment.deletedAt ? Withdrawn.
: comment.text ?
: null}
);
}
diff --git a/apps/writer/src/ui/OutlinePanel.tsx b/apps/writer/src/ui/OutlinePanel.tsx
index e2fcf5a..db7e64f 100644
--- a/apps/writer/src/ui/OutlinePanel.tsx
+++ b/apps/writer/src/ui/OutlinePanel.tsx
@@ -9,7 +9,7 @@ import type { WriterDiagnostic } from "../compile/protocol.js";
import { wordCount } from "../words.js";
import type { RecordView } from "../workspace/workspace.js";
import { useRowWindow } from "./paging.js";
-import { GripIcon, PlusIcon } from "./icons.js";
+import { ChatIcon, ChevronRight, GripIcon, PlusIcon } from "./icons.js";
import { headingAt, headings, sectionWords, withoutTitle } from "./outline.js";
import { formatCount, plural, recordTitle, STATE_LABEL, STATE_TONE } from "./records.js";
@@ -26,7 +26,13 @@ export function OutlinePanel({
onJump,
onMove,
onAdd,
+ onRename,
+ commentOffsets,
}: {
+ /** Gives a chapter a new title. */
+ onRename?(path: string, title: string): Promise> | Result;
+ /** Where each record's open comment threads are anchored, for a quiet count by section. */
+ commentOffsets?: ReadonlyMap;
main: string;
/** Every record in reading order, the manuscript first. */
order: readonly string[];
@@ -47,6 +53,17 @@ export function OutlinePanel({
const [dragging, setDragging] = useState(null);
const [drop, setDrop] = useState<{ index: number; after: boolean } | null>(null);
const [announcement, setAnnouncement] = useState("");
+ // A book's chapters fold their headings away, except the one being written.
+ const [expanded, setExpanded] = useState>(() => new Set());
+ const [renaming, setRenaming] = useState(null);
+ const isExpanded = (path: string) => expanded.has(path) || path === active;
+ const toggleExpanded = (path: string) => setExpanded((set) => {
+ const next = new Set(set);
+ if (isExpanded(path)) { next.delete(path); if (path === active) next.add(`!${path}`); }
+ else { next.add(path); next.delete(`!${path}`); }
+ return next;
+ });
+ const shows = (path: string) => (path === active ? !expanded.has(`!${path}`) : expanded.has(path));
const refocus = useRef(null);
const list = useRef(null);
const rest = useMemo(() => order.slice(1), [order]);
@@ -90,7 +107,7 @@ export function OutlinePanel({
{plural(words, "word")}
-
+
{rest.length > 0 && (
@@ -120,6 +137,9 @@ export function OutlinePanel({
if (drop?.index !== index || drop.after !== after) setDrop({ index, after });
};
const dropClass = drop?.index === index && dragging !== null ? (drop.after ? "drop-after" : "drop-before") : "";
+ const hasHeadings = view ? withoutTitle(headings(view.snapshot.body), recordTitle(view, path)).length > 0 : false;
+ const comments = commentOffsets?.get(path)?.length ?? 0;
+ const openRename = () => { if (onRename && view) setRenaming(path); };
return (
- onOpen(path)}
- onKeyDown={onKeyDown}
- title={direct ? `${path} · drag, or Alt-↑/↓, to reorder` : path}
- >
-
- {direct ? index + 1 : ""}
- {direct && }
-
- {recordTitle(view, path)}
-
- {view ? : ""}
-
-
+ {renaming === path && view ? (
+ {
+ setRenaming(null);
+ if (title !== null && onRename) { const done = await onRename(path, title); if (!done.ok) setAnnouncement(done.message); }
+ requestAnimationFrame(() => list.current?.querySelector(`.record-row[data-path="${CSS.escape(path)}"]`)?.focus());
+ }}
+ />
+ ) : (
+
+ onOpen(path)}
+ onDoubleClick={openRename}
+ onKeyDown={(e) => { if (e.key === "F2") { e.preventDefault(); openRename(); } else onKeyDown(e); }}
+ title={`${path}${direct ? " · drag, or Alt-↑/↓, to reorder" : ""}${onRename ? " · double-click or F2 to rename" : ""}`}
+ >
+
+ {direct ? index + 1 : ""}
+ {direct && }
+
+ {recordTitle(view, path)}
+
+ {view ? : ""}
+
+ {hasHeadings && (
+ toggleExpanded(path)}>
+
+
+ )}
+
+ )}
+ {shows(path) && }
);
})}
@@ -168,17 +207,42 @@ export function OutlinePanel({
);
}
-function RecordState({ view, problems }: { view: RecordView | undefined; problems: number }) {
+function RecordState({ view, problems, comments = 0 }: { view: RecordView | undefined; problems: number; comments?: number }) {
const state = view?.snapshot.state;
- if ((!state || state === "saved") && !problems) return null;
+ if ((!state || state === "saved") && !problems && !comments) return null;
return (
{state && state !== "saved" && {STATE_LABEL[state]} }
+ {comments > 0 && }
{problems > 0 && {problems} }
);
}
+/** An open-comment count, kept as quiet as a word count. */
+function CommentCount({ count }: { count: number }) {
+ return {count} ;
+}
+
+/** A chapter's title, edited in place: Enter keeps it, Escape leaves it. */
+function RenameChapter({ title, onDone }: { title: string; onDone(title: string | null): void }) {
+ const [value, setValue] = useState(title);
+ return (
+
+ );
+}
+
/** 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} Retry sources
}
Down arrow moves to the sources; Enter cites the first match.
{query.trim() ? (
-
-
- {results.length === 0 &&
Nothing matches “{query}”.
}
-
+ <>
+
+
+ {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 }) => (
+
+ { setQuery(""); setOpen(entry.key); requestAnimationFrame(() => rowFor(entry.key)?.scrollIntoView({ block: "nearest" })); }} title={`${entry.title} · @${entry.key}`}>
+ {entry.title}
+ {authorYear(entry) || entry.key}{annotation.locator ? ` · ${annotation.locator}` : ""}
+ {clipPassage(annotation.quote || annotation.note || "")}
+
+
+ ))}
+
+
+ )}
+ >
) : (
<>
@@ -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 && , }
+ onJump?.(site.record, site.offset)} title={site.record}>{site.name}
+
+ ))}
+
+ )}
{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
-
+
p.thread.root.status === "open").length}
+ comments={openThreads.length}
commentsTitle={openCount(placed)}
+ split={layout.lowerTab !== null}
+ onSplit={toggleLower}
/>
sidebarScroll.current.set(layout.tab, e.currentTarget.scrollTop)} className="sidebar-panel" role="tabpanel" id={`sidebar-${layout.tab}`} aria-labelledby={`sidebar-tab-${layout.tab}`}>
- {layout.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))}
- />
- )}
- >
- ) : layout.tab === "comments" ? (
- 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}
- recordTitle={(path) => recordTitle(snap.records.get(path), path)}
- onSelect={selectThread}
- onSubmit={async (text, replacement) => {
- if (!pendingComment) return { ok: true, value: null };
- const created = signalResult(await workspace.addComment(pendingComment.draft ?? { record: pendingComment.record }, text, replacement));
- if (created.ok) {
- setPendingComment(null);
- setActiveComment(created.value.path);
- }
- return created;
- }}
- 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() } : {})}
- />
- ) : null}
-
- 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}
- {...(sourceHref ? { sourceHref } : {})}
- request={sourcesRequest}
- />
-
+ {layout.tab !== "sources" && panelFor(layout.tab)}
+ {/* Sources stays mounted (hidden) so its search, expanded source and scroll last across tab switches. */}
+ {layout.lowerTab !== "sources" && {sourcesPanel}
}
+ {layout.lowerTab && (
+
+
+ {SIDEBAR_TABS.filter((t) => t !== layout.tab).map((t) => (
+ setLowerTab(t)}>{TAB_NAME[t]}
+ ))}
+ setLowerTab(null)}>
+
+
{panelFor(layout.lowerTab)}
+
+ )}
setLayout((l) => ({ ...l, sidebarWidth }))} />
+ {!layout.sidebar && (
+
+ {SIDEBAR_TABS.map((t) => {
+ const Icon = t === "outline" ? OutlineIcon : t === "sources" ? BooksIcon : ChatIcon;
+ const count = t === "sources" ? snap.library.length : t === "comments" ? openThreads.length : 0;
+ return (
+ { setLayout((l) => ({ ...l, sidebar: true })); setSidebarTab(t); }} title={`${TAB_NAME[t]}${count ? ` (${count})` : ""}`} aria-label={TAB_NAME[t]}>
+
+ {count > 0 && {count} }
+
+ );
+ })}
+
+ )}
{active !== workspace.main && (
@@ -875,11 +1005,23 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor
anchors={anchors}
activeComment={activeComment}
onAnchor={onEditorAnchor}
+ anchorCard={anchorCard}
onSelectionAction={onSelectionAction}
/>
) : (
Opening…
)}
+ {pendingComment?.draft && !narrow && (
+
+ setPendingComment(null)}
+ />
+
+ )}
{problemsOpen && diagnostics.length > 0 && (
0 && (
{diagnostics.length}
)}
+ {p === "outline" && openThreads.length > 0 && (
+ {openThreads.length}
+ )}
- {p === "outline" ? "Outline" : p === "write" ? "Write" : "Preview"}
+ {p === "outline" ? "Sidebar" : p === "write" ? "Write" : "Preview"}
);
})}
@@ -1011,7 +1156,10 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor
...inserts.map((c) => ({ id: `insert-${c.id}`, group: "Insert", label: c.label, detail: c.syntax, keywords: c.keywords, run: c.run })),
{ id: "comment", group: "Comments", label: "Comment on the selection", shortcut: "mod+alt+m", run: () => startComment("comment") },
{ id: "suggest", group: "Comments", label: "Suggest an edit to the selection", shortcut: "mod+alt+s", keywords: "track changes", run: () => startComment("suggest") },
- { id: "comments", group: "Comments", label: "Show comments", run: showComments },
+ { id: "comments", group: "Comments", label: "Show comments", shortcut: "mod+shift+m", run: showComments },
+ { id: "next-comment", group: "Comments", label: "Next comment", shortcut: "F7", run: () => stepComment(1) },
+ { id: "previous-comment", group: "Comments", label: "Previous comment", shortcut: "shift+F7", run: () => stepComment(-1) },
+ { id: "lower-panel", group: "View", label: layout.lowerTab ? "Close the lower sidebar panel" : "Show a second sidebar panel", keywords: "split stack outline comments sources", run: toggleLower },
{ id: "next-problem", group: "Manuscript", label: "Next problem", shortcut: "F8", run: () => stepProblem(1) },
{ id: "manuscripts", group: "Manuscript", label: "All manuscripts", keywords: "back home close", run: () => void requestClose() },
{ id: "sidebar", group: "View", label: layout.sidebar ? "Hide the sidebar" : "Show the sidebar", shortcut: "mod+\\", run: () => setLayout((l) => ({ ...l, sidebar: !l.sidebar })) },
@@ -1073,9 +1221,9 @@ export function WorkspaceView({ workspace, onClose }: { workspace: ManuscriptWor
);
}
-/** Outline, Sources and Comments, as tabs: ←/→ move between them. */
-function SidebarTabs({ tab, onTab, sources, comments, commentsTitle }: { tab: SidebarTab; onTab(tab: SidebarTab): void; sources: number; comments: number; commentsTitle: string }) {
- const tabs: readonly SidebarTab[] = ["outline", "sources", "comments"];
+/** Outline, Sources and Comments, as tabs: ←/→ move between them. A second panel can open beneath. */
+function SidebarTabs({ tab, onTab, sources, comments, commentsTitle, split, onSplit }: { tab: SidebarTab; onTab(tab: SidebarTab): void; sources: number; comments: number; commentsTitle: string; split: boolean; onSplit(): void }) {
+ const tabs = SIDEBAR_TABS;
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight" && e.key !== "Home" && e.key !== "End") return;
e.preventDefault();
@@ -1096,17 +1244,52 @@ function SidebarTabs({ tab, onTab, sources, comments, commentsTitle }: { tab: Si
aria-controls={`sidebar-${t}`}
tabIndex={tab === t ? 0 : -1}
onClick={() => onTab(t)}
- title={t === "sources" ? `Find a source (${MOD_LABEL}-Shift-E)` : t === "comments" ? commentsTitle : `Outline, and search the manuscript (${MOD_LABEL}-Shift-F)`}
+ title={t === "sources" ? `Find a source (${MOD_LABEL}-Shift-E)` : t === "comments" ? `${commentsTitle} (${MOD_LABEL}-Shift-M)` : `Outline, and search the manuscript (${MOD_LABEL}-Shift-F)`}
>
- {t === "outline" ? "Outline" : t === "sources" ? "Sources" : "Comments"}
+ {TAB_NAME[t]}
{t === "sources" && sources > 0 && {sources} }
{t === "comments" && comments > 0 && {comments} }
))}
+
+
+
);
}
+/** 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";