From adf3addf75c79457adcd3d86867d67b3829ee429 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 6 Oct 2026 21:28:15 +1100 Subject: [PATCH 1/5] Bring comments to the text: inline composer, margin marks, hover cards, newness A comment or suggestion on a selection is written beside the passage rather than in the sidebar. Commented lines are marked in the editor's margin, hovering a passage shows who said what, and the active thread's passage stands out. Threads others wrote in since the manuscript was last opened here are marked new, and the list filters to new, mine or one person's. Comment text renders its emphasis, code and links. The whole-record button says what it is. --- apps/writer/src/App.tsx | 6 +- apps/writer/src/editor/Editor.tsx | 23 +++- apps/writer/src/editor/comments.ts | 131 ++++++++++++++++++++- apps/writer/src/ui/CommentsPanel.tsx | 70 +++++++++-- apps/writer/src/ui/comments-inline.test.ts | 33 ++++++ apps/writer/src/ui/comments.ts | 32 +++++ 6 files changed, 277 insertions(+), 18 deletions(-) create mode 100644 apps/writer/src/ui/comments-inline.test.ts diff --git a/apps/writer/src/App.tsx b/apps/writer/src/App.tsx index ac703685..4c777da1 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/editor/Editor.tsx b/apps/writer/src/editor/Editor.tsx index 20d114dd..cf27a4eb 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 380a965d..261647fe 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/ui/CommentsPanel.tsx b/apps/writer/src/ui/CommentsPanel.tsx index 1313bfd7..c0e6e6d7 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/icons.tsx b/apps/writer/src/ui/icons.tsx index 5583612c..9a9b44d0 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/workspace/workspace.ts b/apps/writer/src/workspace/workspace.ts index e11d207d..997fef35 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"; From 4ea92423d9621ab918c36e86ee19836aa25845ee Mon Sep 17 00:00:00 2001 From: callumalpass Date: Tue, 6 Oct 2026 21:28:15 +1100 Subject: [PATCH 4/5] Sidebar: opens on the outline, a second panel beneath, a rail when hidden A manuscript opens on its outline and remembers its tab for the page's lifetime. A split button shows a second panel under the first, so the outline and comments read together. Hiding the sidebar leaves a rail of its tabs with counts. The phone tab is called Sidebar and carries the open-comment count. Mod-Shift-M shows comments; F7 steps through them. --- apps/writer/src/styles.css | 52 +++- apps/writer/src/ui/WorkspaceView.tsx | 386 ++++++++++++++++++++------- apps/writer/src/ui/layout.test.ts | 4 +- apps/writer/src/ui/layout.ts | 8 +- 4 files changed, 346 insertions(+), 104 deletions(-) diff --git a/apps/writer/src/styles.css b/apps/writer/src/styles.css index aa504216..895cb2d9 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/WorkspaceView.tsx b/apps/writer/src/ui/WorkspaceView.tsx index a23fa72e..a234ad15 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
-