Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion apps/writer/scripts/browser-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions apps/writer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <Home backend={backend} onOpen={setPath} collectionPicker={collectionPicker} />;
if (!workspace) return null;
return <>
<WorkspaceView key={workspace.main} workspace={workspace} onClose={(force) => setPath(null, force)} />
<WorkspaceView key={workspace.main} workspace={workspace} since={since} onClose={(force) => setPath(null, force)} />
<Dialog open={Boolean(navigation)} onClose={() => { navigation?.finish(false); setNavigation(null); }} title="Some changes are not saved">
<p className="problem">{navigation?.message}</p>
<p>Download a local copy before leaving if saving cannot be completed.</p>
Expand Down
5 changes: 3 additions & 2 deletions apps/writer/src/backend/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down Expand Up @@ -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;
}
Expand Down
3 changes: 2 additions & 1 deletion apps/writer/src/backend/connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }]);
Expand Down Expand Up @@ -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();
Expand Down
6 changes: 4 additions & 2 deletions apps/writer/src/backend/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, unknown>;
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`). */
Expand Down
23 changes: 18 additions & 5 deletions apps/writer/src/editor/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -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<HTMLDivElement>(null);
const view = useRef<EditorView | null>(null);
// Texts this editor reported, newest last. The session echoes them back
Expand All @@ -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<ReturnType<typeof livePreviewExtension> | 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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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" });
Expand Down
Loading