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
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,20 @@ A sentence with a footnote.[^note]

Replace `smith2024` with a citekey from your library and the image path with a file in your collection.

The editor reads like the manuscript: a citation shows who it cites, a cross-reference what it
points to, footnote markers their numbers, and figures, tables, equations and code draw as
blocks. The Markdown is unchanged; the line the cursor is on always shows it as written. Turn
this off under **Editor** at the end of **Settings** to see the Markdown everywhere.

### Figures, tables and equations

Type `/` in the text, or use **Insert** in the top bar (or the command palette, **Ctrl/⌘+K**),
to add a **Footnote** (also **Ctrl/⌘+Alt+F**), a **Figure from an image**, a **Table**, an
**Equation**, a **Cross-reference** or a **Citation**; each shows the Markdown it stands for. A figure's image is stored in the
collection (in a `figures` folder beside the manuscript's other images) and written with a
label to refer to; its caption is selected to type. Pasting or dropping an image into the text
does the same.

### Sources and citations

Open **Sources** to search your Reader library and insert a citation, optionally with a page
Expand Down Expand Up @@ -110,8 +124,12 @@ Chapter cards in the editor show their title, word count, and problems, with a b
## Navigate and review

Click a block in the preview to jump to the corresponding text. The preview also follows the
editor's cursor. Use **Problems** to find unknown citekeys, missing labels, unavailable images
or records, and typesetting errors. **F8** moves to the next problem.
editor's cursor. **Problems** in the top bar lists unknown citekeys, missing labels, unavailable
images or records, and typesetting errors under the editor, each with its record and line; the
list stays open while you fix them, and **F8** moves to the next problem.

The home screen offers the manuscript you opened last, with an optional word goal kept in this
browser, and lists any text typed but not yet saved to the collection.

The sidebar, editor/preview split, and zoom are remembered in your browser. On a phone, switch
between writing, preview, and outline views.
Expand Down
31 changes: 24 additions & 7 deletions apps/writer/scripts/browser-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,15 @@ const editorText = () =>
return (path && window.writer.workspace.getSnapshot().records.get(path)?.snapshot.body) ?? "";
});

/** Lists the problems under the editor (the top bar's button toggles the list, which stays open while they are fixed). */
async function openProblems() {
// Problems on the line being typed are held until typing pauses: wait for them to count.
await page.locator("button.bar-problems").waitFor({ timeout: 10_000 });
if (await page.locator(".problems-strip").count()) return;
await page.locator("button.bar-problems").click();
await page.locator(".problems-strip").waitFor({ timeout: 5_000 });
}

async function typeAtEndOfParagraph(needle, text) {
// Click at the end of the line holding `needle`, then type like a person. The click goes to
// its last wrapped row: End alone only reaches the end of the row that was clicked.
Expand Down Expand Up @@ -261,7 +270,7 @@ await step("a setting's problem opens the setting", async () => {
const before = (await snapshot()).revision;
await page.evaluate(() => window.writer.workspace.patchFrontmatter("manuscripts/slow-change.md", { lang: "tlh" }));
await waitForRevisionAfter(before);
await page.locator("button.bar-problems").click();
await openProblems();
await page.locator(".problem-row", { hasText: "no terms for" }).click();
await page.locator(".settings-sheet .field-problem", { hasText: "no terms for" }).waitFor();
assert.equal(await page.evaluate(() => document.activeElement?.value), "tlh");
Expand All @@ -280,7 +289,7 @@ await step("an unknown citekey is reported in Problems and in the editor", async
await waitForRevisionAfter(before);
const s = await snapshot();
assert.ok(s.diagnostics.some((d) => d.includes("No source in the library has the citekey nosuchsource2020")), s.diagnostics.join("\n"));
await page.locator("button.bar-problems").click();
await openProblems();
await page.locator(".problem-row", { hasText: "nosuchsource2020" }).waitFor();
await page.keyboard.press("Escape");
await page.locator(".cm-lintRange-error").first().waitFor({ timeout: 5_000 });
Expand Down Expand Up @@ -309,14 +318,22 @@ await step("hovering a citation shows its bibliography entry; Mod-click on a cro
await page.mouse.move(point.x, point.y, { steps: 4 });
};
await page.getByRole("tab", { name: "Outline" }).click();
await page.evaluate(() => document.querySelector(".cm-scroller").scrollTo(0, 0));
await page.waitForTimeout(100);
const cite = await at("lyellPrinciples30");
assert.ok(cite, "citation on screen");
// With the cursor on the heading, the paragraph's citations and cross-references are drawn as chips.
await page.locator(".heading-row", { hasText: "Introduction" }).click();
await page.waitForTimeout(300);
const chip = async (text) => {
const box = await page.locator(".cm-lp-chip", { hasText: text }).first().boundingBox();
assert.ok(box, `${text} chip on screen`);
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
};
const cite = await chip("Lyell, 1830");
await hover(cite);
await page.locator(".cm-ref-tip", { hasText: "John Murray" }).waitFor({ timeout: 5_000 });
assert.equal(await page.locator(".cm-ref-tip em").first().textContent(), "Principles of Geology");
const ref = await at("@sec-worms", 4);
// On the cursor's line the source shows as written, and the hover still works on the key itself.
const raw = await at("lyellPrinciples30");
if (raw) await hover(raw);
const ref = await chip("Earthworms: the moving surface");
await hover(ref);
await page.locator(".cm-ref-tip", { hasText: "Earthworms: the moving surface" }).waitFor({ timeout: 5_000 });
await page.keyboard.down("ControlOrMeta");
Expand Down
4 changes: 4 additions & 0 deletions apps/writer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { useWriterFeedbackContext } from "./FeedbackRoot.js";
import { ConnectGate } from "./ui/ConnectGate.js";
import { CollectionPicker } from "./ui/CollectionPicker.js";
import { Home } from "./ui/Home.js";
import { localNamespace, recordOpened } from "./ui/recent.js";
import { Dialog } from "@mdbase-dev/ui/dialog";
import { zip } from "./export/zip.js";
import { loadThemePreference, saveThemePreference, type ThemePreference } from "@mdbase-dev/ui/theme";
Expand Down Expand Up @@ -207,6 +208,9 @@ function Manuscripts({ backend, collectionPicker }: { backend: WriterBackend; co
useWriterFeedbackContext(path ? "manuscript" : "manuscripts", backend.collectionName);
currentWorkspace.current = workspace;
(window as unknown as { writer?: unknown }).writer = { backend, workspace };
useEffect(() => {
if (path) recordOpened(localNamespace(backend), path);
}, [backend, path]);
if (!path) return <Home backend={backend} onOpen={setPath} collectionPicker={collectionPicker} />;
if (!workspace) return null;
return <>
Expand Down
14 changes: 14 additions & 0 deletions apps/writer/src/backend/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,20 @@ export class ConnectBackend implements WriterBackend {
}
}

async writeFile(path: string, bytes: Uint8Array, mediaType?: string): Promise<Result<string>> {
let target = path;
for (let n = 2; this.files.has(target) && n <= 50; n++) target = numberedPath(path, n);
try {
const stored = await this.connection.files.upload(target, bytes, { ...(mediaType ? { mediaType } : {}), signal: this.lifetime.signal });
this.files.set(stored.path, stored);
// The index learns the file now; the watch's file.put only confirms it.
this.acceptDelta(this.store.files([stored.path]));
return ok(stored.path);
} catch (e) {
return fail(errorMessage(e));
}
}

onCollectionChange(listener: (delta: CollectionDelta) => void): () => void {
this.dataListeners.add(listener);
return () => this.dataListeners.delete(listener);
Expand Down
8 changes: 8 additions & 0 deletions apps/writer/src/backend/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,14 @@ export async function createDemoBackend(): Promise<WriterBackend> {
if (!url) return fail(`No file at ${path}.`);
return ok(new Uint8Array(await (await fetch(url)).arrayBuffer()));
},
async writeFile(path: string, bytes: Uint8Array, mediaType?: string) {
let target = path;
for (let n = 2; files.has(target) && n <= 50; n++) target = numberedPath(path, n);
files.set(target, URL.createObjectURL(new Blob([bytes as BlobPart], mediaType ? { type: mediaType } : {})));
const delta = store.files([target]);
for (const listener of dataListeners) listener({ ...delta, paths: [target] });
return ok(target);
},
async comments(scope) {
const out: CommentRecord[] = [];
for (const path of store.commentScope(scope)) {
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 @@ -103,6 +103,8 @@ export interface WriterBackend {
/** Only this source's Reader annotations (including relative and bare source links). */
annotationsForSource(path: string): Promise<Result<SourceAnnotation[]>>;
readFile(path: string): Promise<Result<Uint8Array>>;
/** Stores a file (an image for a figure) at `path`, or at `path` numbered when that is taken; resolves to its path. */
writeFile?(path: string, bytes: Uint8Array, mediaType?: string): Promise<Result<string>>;
/** A Markdown record's body, read once (no record session). */
readBody(path: string): Promise<Result<string>>;
/** Metadata selects threads for these manuscript records; only their bodies are read. No scope means all. */
Expand Down Expand Up @@ -150,8 +152,8 @@ export function libraryEntry(path: string, frontmatter: JsonObject | undefined):
return { key, item: { ...item, id: key }, title, path };
}

/** `chapters/x.md` numbered for the nth try: `chapters/x-2.md`. */
export const numberedPath = (path: string, n: number) => (n > 1 ? path.replace(/(\.md)?$/i, `-${n}$1`) : path);
/** `chapters/x.md` numbered for the nth try: `chapters/x-2.md` (and `figures/x.png` to `figures/x-2.png`). */
export const numberedPath = (path: string, n: number) => (n > 1 ? path.replace(/(\.[a-z0-9]{1,5})?$/i, `-${n}$1`) : path);

export function manuscriptSlug(title: string): string {
const slug = title
Expand Down
Loading