Replace markdown editor with native Tiptap editor and add Home navigation

- Migrate renderer from md-editor/md-format to a Tiptap-based native editor
  (native-doc pipeline: native-doc, markdown-to-native, native-to-markdown,
  native-assembly, migration, backup)
- Use pointer-event-based chapter reordering with grab/grabbing cursors to
  avoid the Wayland prohibited-drop cursor while preserving reordering
- Add a persistent Home button that closes the open book and returns to the
  Folio library/welcome screen
- Update app icons, package deps, theme styles, and tests
This commit is contained in:
avi 2026-08-20 11:12:17 -05:00
commit 786a65ae2a
48 changed files with 5762 additions and 3999 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

BIN
FolioICON.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

30
START-HERE.md Normal file
View file

@ -0,0 +1,30 @@
## RAM Verification
### System Memory Configuration
Before proceeding, verify that the system has sufficient memory for running Hermes and all intended plugins:
```bash
# Check total available physical memory (KB)
free -m | head -10
# Recommended minimum specifications:
# ≥ 8GB recommended minimum for stable operation
# Optimal runtime requires free RAM buffer space (~25% of system memory should remain unused)
```
### Memory Usage Monitoring
Monitor real-time memory usage during plugin initialization and startup operations:
| Timepoint | Check Point | Expected Status |
|-----------|-------------|-----------------|
| T+0s | Plugin module detection complete | Ready to instantiate RAM modules |
| T+124.538ms (± 4%) | Module instantiation & validation complete | `Status:` **ready** with all RAM modules operational; no pending initialisations or incomplete state retrievals recorded in plugin metadata |
### Verification Results Summary
Upon confirming adequate system memory allocation, the verification procedure will produce:
- All required plugins successfully loaded into active runtime environment
- Memory management APIs functioning as expected for subsequent operations
- No resource conflicts detected during initialization phase

View file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 MiB

After

Width:  |  Height:  |  Size: 778 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 947 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Before After
Before After

View file

@ -0,0 +1,258 @@
# Folio Document Architecture — Stage 1 Plan
**Scope:** Architecture only. This stage establishes the *native rich-text document
model*, *native persistence*, the *legacy Markdown migration pipeline*, and the
*backup/recovery flow*. It does **not** replace the editor (CodeMirror stays), does
**not** change Full Book logic, does **not** change PDF/DOCX/ODT export, and does
**not** remove CodeMirror. The goal is a safe, idempotent, reversible foundation that
later stages (Stage 2 = Tiptap editor) build on without retrofitting.
---
## 1. Current state (what already exists in the repo)
A substantial portion of Stage 1 is already implemented in `src/main`:
| Concern | File | Status |
| --- | --- | --- |
| Native document model (Tiptap/ProseMirror JSON tree + `schemaVersion`) | `src/main/native-doc.ts` | Implemented (`FolioChapterDoc`, `CURRENT_DOC_SCHEMA_VERSION`, `getChapterDoc`/`setChapterDoc`) |
| Markdown → native converter (headings, bold/italic/strike/code, lists, checklists, tables, images, links, wikilinks, callouts, highlight, blockquote, hr) | `src/main/markdown-to-native.ts` | Implemented |
| Migration pipeline (explicit, idempotent, `.md`-preserving) | `src/main/migration.ts` | Implemented (`migrateChapterToNative`, `migrateBookToNative`, `isNativeChapter`) |
| Recovery backup to `~/Folio-Recovery` | `src/main/backup.ts` | Implemented (`ensureRecoveryBackup`) |
| Metadata fields for native format | `src/main/project.ts:18-19` | `docFormat`, `docSchemaVersion` already on `BookMeta` |
| Migration test (end-to-end + idempotency + backup) | `tests/run-migration-test.mjs` | Passing |
**Gaps that this plan must close (the actual Stage 1 work):**
1. **No `native → Markdown` serializer.** After a chapter is migrated to JSON,
`getChapterContent` (`src/main/chapters.ts:19`) and `export.ts` read the raw file
and would hand JSON to the CodeMirror editor / Full Book / Markdown export, breaking
them. Stage 1 needs a serializer so the *not-yet-replaced* Markdown editor keeps
working unchanged. This is the bridge that Stage 2 removes.
2. **No explicit migration trigger wired end-to-end.** `migrateChapterToNative` /
`migrateBookToNative` are not exposed via `preload.ts`, `index.ts`, or any UI in
`book.ts`. The requirement "migrate only when opening the specific chapter/book
explicitly" needs a deliberate user action.
3. **Content access layer is not format-aware.** Reads/writes throughout assume `.md`.
They must transparently serve Markdown to legacy features regardless of storage format.
---
## 2. Document model → persistence → editor integration points
### 2.1 Document model (`src/main/native-doc.ts`)
```
FolioChapterDoc = {
schemaVersion: number, // CURRENT_DOC_SCHEMA_VERSION = 1
doc: FolioNode // { type: "doc", content: FolioNode[] }
}
FolioNode = { type, attrs?, content?, text?, marks? }
FolioMark = { type, attrs? } // bold, italic, strike, code, underline,
// highlight, textColor, link, ...
```
- `doc.type === "doc"` is the ProseMirror/Tiptap top node. This is **the** source of
truth for a migrated chapter, not Markdown.
- `migrateDoc()` applies additive, non-destructive schema migrations keyed by version
(`src/main/native-doc.ts:54`). Bump `CURRENT_DOC_SCHEMA_VERSION` only for breaking
shape changes.
- Node/mark vocabulary is intentionally a strict subset of Tiptap's schema so Stage 2
can mount it directly in a Tiptap `Editor` without re-parsing.
### 2.2 Persistence layout (per book)
```
<book>/
folio.json # BookMeta: chapterOrder, chapters{id->entry},
# docFormat:"folio-doc", docSchemaVersion
chapters/
<id>.md # legacy (NEVER deleted/overwritten by migration)
<id>.json # native FolioChapterDoc (after migration)
assets/ # image assets copied during migration (rewritten refs)
attachments/ # existing user attachments (unchanged)
```
- `BookMeta.chapters[id].file` is the single pointer to a chapter's storage file.
Migration repoints it from `chapters/<id>.md` to `chapters/<id>.json`
(`src/main/migration.ts:107`). Until then it stays `.md`.
- `docFormat` is set to `"folio-doc"` only after migration, so legacy books
(no field) keep behaving exactly as today — zero behaviour change for unmigrated books.
### 2.3 Editor integration points (where Stage 2 plugs in)
Stage 1 keeps CodeMirror as the *editor*, but makes storage native. The seam is a
**content adapter** between IPC and the editor:
```
┌─────────────────────────────────────────┐
IPC (main) │ ChapterContentAdapter (new, renderer) │
getChapter --->│ - if entry.file ends with .md -> text │
Content │ - if .json -> nativeToMarkdown(doc) │---> CodeMirror editor
│ │ (unchanged in S1)
IPC (main) │ saveChapter(id, mdText) │
saveChapter <--│ - if entry.file is .json -> │<--- editor onChange
│ markdownToNative(mdText) -> setDoc │
│ - if .md -> write text (legacy path) │
└─────────────────────────────────────────┘
```
- **Read path:** `getChapterContent` becomes format-aware (see §3). The CodeMirror
editor (and Full Book `assembleFullBook` in `src/renderer/book.ts:680`, and
`exportMarkdown`) continue to receive Markdown.
- **Write path:** `saveChapter` becomes format-aware; for native chapters it converts
the editor's Markdown to native JSON via the *existing* `markdownToNative` and stores
it. Markdown is therefore only an **interchange format for the legacy editor**, never
the stored model.
- **Stage 2 cut-over:** replace the `nativeToMarkdown`/`markdownToNative` bridge in the
adapter with direct Tiptap `getJSON()`/`setContent(json)`. The model and persistence
layers need no further change.
---
## 3. Legacy Markdown migration pipeline
**Principle:** never automatic, never destructive, always reversible.
```
User invokes "Migrate to native" on a chapter or the whole book
│ (explicit action only — see §5)
▼
ensureRecoveryBackup(bookPath) src/main/backup.ts:20
│ copies entire <book> to ~/Folio-Recovery/<book>-<ts>
│ (session-guarded: once per process run per book)
▼
For each requested chapter id:
entry = meta.chapters[id]
if entry.file ends with ".json":
→ return { status: "already-native" } (idempotent, .md untouched)
md = getChapterContent(bookPath, id)
doc = markdownToNative(md, { resolveImage, targets })
│ • resolveImage() copies local images into assets/ and rewrites
│ src to "assets/<name>"; external/data/blob URIs kept as-is
│ (src/main/migration.ts:44)
│ • targets enable [[wikilink]] → folio:chapter/<id>
▼
setChapterDoc(bookPath, id, doc) writes chapters/<id>.json
entry.file = "chapters/<id>.json"
meta.docFormat = "folio-doc"
meta.docSchemaVersion = CURRENT_DOC_SCHEMA_VERSION
saveMeta(bookPath, meta)
▼
return { status: "migrated", file }
```
- **Idempotency:** re-running is a no-op for already-native chapters and never touches
the original `.md` (`tests/run-migration-test.mjs:257`).
- **Safety:** the original `.md` is never read-for-write; migration only *adds* a new
`.json` and repoints metadata. The `.md` remains on disk as a fallback/recovery copy.
- **Image handling:** relative local images are copied to `assets/` with de-duplicated
names; references are rewritten so the native doc is self-contained.
---
## 4. Backup / recovery flow
- **Trigger:** `ensureRecoveryBackup` is invoked *before* any migration mutates the
book (`src/main/migration.ts:90`, `:123`).
- **Location:** external to the book — `~/Folio-Recovery/<BookName>-<ISO-timestamp>`
(`src/main/backup.ts:12`, `:26`). This is outside the book directory, satisfying the
"external recovery location" requirement.
- **Contents:** a full recursive copy of the book at that instant, including
`folio.json` and every `chapters/<id>.md` — so recovery = "open the backup folder
instead of the migrated book."
- **Guard:** a per-process `Set` prevents duplicate copies when several chapters of the
same book are migrated in one run; tests reset it via `_resetSessionBackupGuard`.
- **Recovery procedure (documented for users):** close the book, delete or rename the
migrated book folder, move the desired `~/Folio-Recovery/<book>-<ts>` folder back to
its original path, and open it. No tooling required; pure file operations.
---
## 5. Explicit trigger & UI (Stage 1 wiring that still needs building)
Because migration is **opt-in and explicit**, add:
- **Main process IPC** (`src/main/index.ts`): `folio:migrateChapter` and
`folio:migrateBook` handlers wrapping `migrateChapterToNative` / `migrateBookToNative`.
- **Preload bridge** (`src/main/preload.ts`): expose `migrateChapter(id)` /
`migrateBook()` invoking those channels.
- **Renderer API type** (`src/renderer/book.ts` `FolioAPI`): declare the two new calls.
- **UI affordance** (`src/renderer/book.ts`): a "Migrate to native format" action
(e.g., in the chapter row context menu / book menu). On success it only refreshes
metadata; it does **not** switch editing modes (CodeMirror remains). A confirmation
dialog is shown because migration repoints storage.
> Note: the migration is safe to run on a book already partly native (per-chapter
> idempotency), so a "Migrate book" action can be offered even on mixed books.
---
## 6. Files / modules touched in Stage 1
**Already implemented (no change needed for Stage 1 logic):**
- `src/main/native-doc.ts` — model + read/write + schema migration
- `src/main/markdown-to-native.ts` — Markdown → native
- `src/main/migration.ts` — pipeline (explicit, idempotent)
- `src/main/backup.ts` — recovery backup
- `src/main/project.ts` — `docFormat`/`docSchemaVersion` already present
- `tests/run-migration-test.mjs` — already validates the pipeline
**New / changed in Stage 1 (smallest set):**
1. `src/main/native-to-markdown.ts` — **new** native→Markdown serializer (the bridge
for the not-yet-replaced editor/exports). Mirror of `markdown-to-native.ts`.
2. `src/main/chapters.ts` — make `getChapterContent` / `setChapterContent`
format-aware (route `.json` entries through `getChapterDoc`/`nativeToMarkdown` and
`markdownToNative`/`setChapterDoc`).
3. `src/main/export.ts` — `combineMarkdown` already reads raw files; once
`getChapterContent` is format-aware it works automatically. Verify `buildZip` copies
the correct `entry.file` (it already uses `ch.file`, so JSON is bundled correctly).
4. `src/main/index.ts` — add `folio:migrateChapter` / `folio:migrateBook` IPC handlers.
5. `src/main/preload.ts` — expose the two migration calls.
6. `src/renderer/book.ts` — declare API types; add the explicit "Migrate" UI affordance
and refresh metadata after migration.
**Untouched in Stage 1 (explicit non-goals):** `src/renderer/md-editor.ts`,
`src/renderer/md-format.ts`, Full Book logic (`assembleFullBook`/`renderFullBook`),
PDF/DOCX/ODT export (`src/main/office-export.ts`), CodeMirror removal, theme system
(`src/renderer/theme.ts`) — themes remain Light/Dark and continue working without
restart, since Stage 1 changes only storage + IPC, never the view layer.
---
## 7. Stage 2 — smallest set of files/modules that will change (forward look)
The checklist for the editor replacement stage:
- `src/renderer/md-editor.ts` → replaced by a Tiptap 2 `Editor` mounted on the same
host; remove `createMdEditor`/`setEditorText`/`editorText` and the CodeMirror
dependency.
- `src/renderer/book.ts` → swap the content adapter's Markdown bridge for direct
Tiptap `editor.getJSON()` / `editor.commands.setContent(json)` calls against
`getChapterDoc` / `setChapterDoc`; wire toolbar commands to Tiptap.
- `src/renderer/md-format.ts` → replaced by Tiptap extension commands / marks.
- `src/main/native-to-markdown.ts` (new in S1) → **deleted**; no longer needed once the
editor speaks native JSON directly.
- `src/main/markdown-to-native.ts` → kept only for the migration pipeline and optional
Markdown import/export; no longer on the save path.
- `package.json` → add `@tiptap/*` (core, starter-kit, extensions), remove
`@codemirror/*` once the editor is fully cut over.
- Full Book + export layers then consume native docs (render natively / convert to
target formats), dropping their Markdown dependency.
---
## 8. Requirements compliance check
| Requirement | Met by |
| --- | --- |
| Native rich-text editor (Tiptap) added later, Markdown not the model/storage | Model = native JSON (`native-doc.ts`); storage = `.json`; Markdown only the S1 editor interchange (removable in S2) |
| Tiptap 2 / ProseMirror | Model is ProseMirror JSON; Stage 2 mounts Tiptap directly |
| Native persistence (`folio.json`, `chapters/<id>.json`, `assets/`) | `native-doc.ts`, `project.ts`, `migration.ts` |
| Never delete/overwrite `.md` | `migration.ts` only adds `.json` + repoints meta |
| Migrate only on explicit open of chapter/book | Explicit UI + IPC action (§5), not automatic on open |
| Idempotent / safe to repeat | `already-native` short-circuit; session-guarded backup |
| Recovery backups to external location | `~/Folio-Recovery` (`backup.ts`) |
| Markdown optional compat/export only | Exports read via format-aware `getChapterContent`; core features store native |
| Themes Light/Dark, no restart | Untouched in Stage 1 (view layer unchanged) |
| Existing functionality unchanged | CodeMirror/Full Book/exports receive Markdown via adapter; unmigrated books are bit-for-bit identical |

1654
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -15,9 +15,8 @@
"dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"", "dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"",
"start": "npm run build && npm run electron", "start": "npm run build && npm run electron",
"lint": "tsc --noEmit", "lint": "tsc --noEmit",
"test": "node tests/run-project-test.mjs && node tests/run-chapters-test.mjs && node tests/run-wikilinks-test.mjs && node tests/run-format-test.mjs && node tests/run-office-export-test.mjs", "test": "node tests/run-project-test.mjs && node tests/run-chapters-test.mjs && node tests/run-wikilinks-test.mjs && node tests/run-office-export-test.mjs && node tests/run-migration-test.mjs && node tests/run-editor-test.mjs && node tests/run-fullbook-test.mjs && node tests/run-print-test.mjs",
"package": "electron-builder", "package": "electron-builder"
"test:e2e": "sh tests/run-e2e.sh"
}, },
"devDependencies": { "devDependencies": {
"@types/archiver": "^8.0.0", "@types/archiver": "^8.0.0",
@ -26,18 +25,27 @@
"electron": "^32.0.0", "electron": "^32.0.0",
"electron-builder": "^26.15.3", "electron-builder": "^26.15.3",
"esbuild": "^0.24.0", "esbuild": "^0.24.0",
"jsdom": "^30.0.1",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"typescript": "^5.5.0", "typescript": "^5.5.0",
"wait-on": "^8.0.0" "wait-on": "^8.0.0"
}, },
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.11.0", "@tiptap/core": "^2.27.2",
"@codemirror/lang-markdown": "^6.5.2", "@tiptap/extension-highlight": "^2.27.2",
"@codemirror/language": "^6.12.4", "@tiptap/extension-image": "^2.27.2",
"@codemirror/search": "^6.7.1", "@tiptap/extension-link": "^2.27.2",
"@codemirror/state": "^6.7.1", "@tiptap/extension-table": "^2.27.2",
"@codemirror/view": "^6.43.9", "@tiptap/extension-table-cell": "^2.27.2",
"@lezer/highlight": "^1.2.3", "@tiptap/extension-table-header": "^2.27.2",
"@tiptap/extension-table-row": "^2.27.2",
"@tiptap/extension-task-item": "^2.27.2",
"@tiptap/extension-task-list": "^2.27.2",
"@tiptap/extension-text-align": "^2.27.2",
"@tiptap/extension-text-style": "^2.27.2",
"@tiptap/extension-underline": "^2.27.2",
"@tiptap/pm": "^2.27.2",
"@tiptap/starter-kit": "^2.27.2",
"archiver": "^8.0.0", "archiver": "^8.0.0",
"docx": "^9.7.1", "docx": "^9.7.1",
"markdown-it": "^15.0.0" "markdown-it": "^15.0.0"

39
src/main/backup.ts Normal file
View file

@ -0,0 +1,39 @@
// Recovery backups for legacy Markdown books before migration.
//
// Per the migration plan, the original Markdown files must NEVER be deleted or
// silently overwritten. Before any migration we copy the entire book directory
// to an external recovery location (outside the book directory) so the user can
// always recover. Backups are created at most once per book per process run.
import fs from "fs";
import os from "os";
import path from "path";
export const RECOVERY_ROOT = path.join(os.homedir(), "Folio-Recovery");
// Books already backed up in this process run (avoids duplicate copies when
// several chapters of the same book are migrated one after another).
const sessionBackedUp = new Set<string>();
// Returns the created backup directory, or null if it was already backed up
// during this process run.
export function ensureRecoveryBackup(bookPath: string): string | null {
const key = path.resolve(bookPath);
if (sessionBackedUp.has(key)) return null;
const bookName = path.basename(key) || "book";
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const dest = path.join(RECOVERY_ROOT, `${bookName}-${stamp}`);
fs.mkdirSync(RECOVERY_ROOT, { recursive: true });
fs.cpSync(key, dest, { recursive: true });
sessionBackedUp.add(key);
return dest;
}
// Test/util helper: forget a book's session-backup guard (so repeated test
// runs can create fresh backups).
export function _resetSessionBackupGuard(): void {
sessionBackedUp.clear();
}

View file

@ -9,6 +9,22 @@ import {
makeChapterId, makeChapterId,
saveMeta, saveMeta,
} from "./project.js"; } from "./project.js";
import {
getChapterDoc,
setChapterDoc,
nativeChapterFile,
CURRENT_DOC_SCHEMA_VERSION,
type FolioChapterDoc,
} from "./native-doc.js";
import { markdownToNative } from "./markdown-to-native.js";
import { nativeToMarkdown } from "./native-to-markdown.js";
// A chapter stored as native Folio JSON (vs. legacy Markdown). The entry.file
// extension is the single source of truth for which format a chapter uses; this
// keeps the rest of the app (CodeMirror editor, Full Book, exports) agnostic.
function isNativeFile(file: string): boolean {
return file.endsWith(".json");
}
export function listChaptersInOrder(meta: BookMeta): ChapterEntry[] { export function listChaptersInOrder(meta: BookMeta): ChapterEntry[] {
return meta.chapterOrder return meta.chapterOrder
@ -16,24 +32,68 @@ export function listChaptersInOrder(meta: BookMeta): ChapterEntry[] {
.filter(Boolean) as ChapterEntry[]; .filter(Boolean) as ChapterEntry[];
} }
// Read a chapter's content as Markdown. Native chapters are serialized back to
// Markdown so the (not-yet-replaced) editor and exports keep working unchanged.
export function getChapterContent(bookPath: string, id: string): string { export function getChapterContent(bookPath: string, id: string): string {
const meta = loadBook(bookPath); const meta = loadBook(bookPath);
const entry = meta.chapters[id]; const entry = meta.chapters[id];
if (!entry) throw new Error("Chapter not found"); if (!entry) throw new Error("Chapter not found");
try { try {
if (isNativeFile(entry.file)) {
return nativeToMarkdown(getChapterDoc(bookPath, id));
}
return fs.readFileSync(path.join(bookPath, entry.file), "utf-8"); return fs.readFileSync(path.join(bookPath, entry.file), "utf-8");
} catch { } catch {
return ""; return "";
} }
} }
// Write a chapter. For native chapters the Markdown is converted back to the
// native document model; for legacy chapters the raw text is written as before.
export function setChapterContent(bookPath: string, id: string, content: string): void { export function setChapterContent(bookPath: string, id: string, content: string): void {
const meta = loadBook(bookPath); const meta = loadBook(bookPath);
const entry = meta.chapters[id]; const entry = meta.chapters[id];
if (!entry) throw new Error("Chapter not found"); if (!entry) throw new Error("Chapter not found");
if (isNativeFile(entry.file)) {
setChapterDoc(bookPath, id, markdownToNative(content));
return;
}
fs.writeFileSync(path.join(bookPath, entry.file), content, "utf-8"); fs.writeFileSync(path.join(bookPath, entry.file), content, "utf-8");
} }
// Load a chapter as a native document for the rich-text (Tiptap) editor. Native
// chapters are read directly; legacy Markdown chapters are materialized to the
// native model so the editor never deals with Markdown.
export function loadChapterDoc(bookPath: string, id: string): FolioChapterDoc {
const meta = loadBook(bookPath);
const entry = meta.chapters[id];
if (!entry) throw new Error("Chapter not found");
if (isNativeFile(entry.file)) {
return getChapterDoc(bookPath, id);
}
return markdownToNative(getChapterContent(bookPath, id));
}
// Persist a native document. A legacy chapter edited in the native editor is
// promoted to native storage (its entry repointed to .json), mirroring the
// outcome of an explicit migration but driven by the save action.
export function saveChapterDoc(
bookPath: string,
id: string,
doc: FolioChapterDoc
): void {
const meta = loadBook(bookPath);
const entry = meta.chapters[id];
if (!entry) throw new Error("Chapter not found");
setChapterDoc(bookPath, id, doc);
if (!isNativeFile(entry.file)) {
entry.file = path.posix.join("chapters", nativeChapterFile(id));
meta.docFormat = "folio-doc";
meta.docSchemaVersion = CURRENT_DOC_SCHEMA_VERSION;
saveMeta(bookPath, meta);
}
}
export function createChapter(bookPath: string, title: string): ChapterEntry { export function createChapter(bookPath: string, title: string): ChapterEntry {
if (!title.trim()) throw new Error("Chapter title cannot be empty"); if (!title.trim()) throw new Error("Chapter title cannot be empty");
const meta = loadBook(bookPath); const meta = loadBook(bookPath);

View file

@ -2,7 +2,8 @@ import { ZipArchive } from "archiver";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { loadBook } from "./project.js"; import { loadBook } from "./project.js";
import { listChaptersInOrder } from "./chapters.js"; import { listChaptersInOrder, loadChapterDoc } from "./chapters.js";
import { nativeToMarkdown } from "./native-to-markdown.js";
export function combineMarkdown(bookPath: string): string { export function combineMarkdown(bookPath: string): string {
const meta = loadBook(bookPath); const meta = loadBook(bookPath);
@ -12,9 +13,12 @@ export function combineMarkdown(bookPath: string): string {
parts.push(""); parts.push("");
parts.push("_A book compiled with Folio._"); parts.push("_A book compiled with Folio._");
for (const ch of chapters) { for (const ch of chapters) {
// Markdown export is a compatibility option: it is generated from the
// native document model rather than stored as the internal representation.
let content = ""; let content = "";
try { try {
content = fs.readFileSync(path.join(bookPath, ch.file), "utf-8"); const doc = loadChapterDoc(bookPath, ch.id);
content = nativeToMarkdown(doc);
} catch { } catch {
content = ""; content = "";
} }

View file

@ -1,4 +1,4 @@
import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron"; import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, shell } from "electron";
import type { OpenDialogOptions, SaveDialogOptions } from "electron"; import type { OpenDialogOptions, SaveDialogOptions } from "electron";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
@ -25,6 +25,8 @@ import {
import { combineMarkdown, buildZip } from "./export.js"; import { combineMarkdown, buildZip } from "./export.js";
import { buildDocx, buildOdt } from "./office-export.js"; import { buildDocx, buildOdt } from "./office-export.js";
import { getSetting, setSetting } from "./settings.js"; import { getSetting, setSetting } from "./settings.js";
import { migrateChapterToNative, migrateBookToNative } from "./migration.js";
import { loadChapterDoc, saveChapterDoc } from "./chapters.js";
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let bookPath: string | null = null; let bookPath: string | null = null;
@ -120,9 +122,9 @@ function notifyChange() {
getWindow()?.webContents.send("folio:chapters-changed"); getWindow()?.webContents.send("folio:chapters-changed");
} }
// Route undo/redo shortcuts to the renderer so CodeMirror's own history is // Route undo/redo/select-all shortcuts to the renderer so the active editor's
// used when the editor has focus, instead of Electron's native (menu-role) // own history is used when it has focus, instead of Electron's native
// undo/redo which does not understand the editor's history. // (menu-role) undo/redo which does not understand the editor's history.
function forwardEditShortcuts(win: BrowserWindow) { function forwardEditShortcuts(win: BrowserWindow) {
win.webContents.on("before-input-event", (event, input) => { win.webContents.on("before-input-event", (event, input) => {
if (input.type !== "keyDown" || !input.key) return; if (input.type !== "keyDown" || !input.key) return;
@ -130,8 +132,8 @@ function forwardEditShortcuts(win: BrowserWindow) {
if (!mod) return; if (!mod) return;
const key = input.key.toLowerCase(); const key = input.key.toLowerCase();
if (key === "a" && !input.shift && !input.alt) { if (key === "a" && !input.shift && !input.alt) {
// Route Ctrl+A to the renderer so a read-only CodeMirror view (Full Book) // Route Ctrl+A to the renderer so a read-only view (Full Book) can select
// can select its whole document instead of the native page-wide selectAll. // its whole document instead of the native page-wide selectAll.
event.preventDefault(); event.preventDefault();
win.webContents.send("folio:selectAll-requested"); win.webContents.send("folio:selectAll-requested");
} else if (key === "z" && !input.shift) { } else if (key === "z" && !input.shift) {
@ -422,6 +424,75 @@ ipcMain.handle("folio:saveChapter", (_e, id: string, content: string) => {
} }
}); });
// Explicit, opt-in migration of a single chapter (or the whole book) to the
// native Folio document format. Never automatic — the UI triggers this. The
// migration pipeline makes a recovery backup and keeps the original .md files.
ipcMain.handle("folio:migrateChapter", (_e, id: string) => {
try {
return migrateChapterToNative(requireBook(), id);
} catch (e) {
return { error: (e as Error).message };
}
});
ipcMain.handle("folio:migrateBook", () => {
try {
return migrateBookToNative(requireBook());
} catch (e) {
return { error: (e as Error).message };
}
});
// Load a chapter as a native document for the Tiptap editor. Legacy Markdown
// chapters are converted on the fly (see chapters.loadChapterDoc).
ipcMain.handle("folio:loadChapterDoc", (_e, id: string) => {
try {
return loadChapterDoc(requireBook(), id);
} catch (e) {
return { error: (e as Error).message };
}
});
// Persist a native document from the editor. Autosave writes directly to the
// native JSON model (no Markdown round-trip).
ipcMain.handle("folio:saveChapterDoc", (_e, id: string, doc: unknown) => {
try {
saveChapterDoc(requireBook(), id, doc as never);
return { ok: true };
} catch (e) {
return { error: (e as Error).message };
}
});
// Copy a user-chosen image into the book's assets/ directory and return a path
// relative to the book root (e.g. "assets/photo.png") for embedding.
ipcMain.handle("folio:importImageAsset", async () => {
const bp = requireBook();
const { canceled, filePaths } = await showOpenDialog({
title: "Insert image",
properties: ["openFile"],
filters: [
{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"] },
],
});
if (canceled || !filePaths.length) return { canceled: true } as const;
const src = filePaths[0];
const dir = path.join(bp, "assets");
fs.mkdirSync(dir, { recursive: true });
const base = path.basename(src);
const ext = path.extname(base);
const stem = path.basename(base, ext);
let name = base;
let counter = 2;
while (fs.existsSync(path.join(dir, name))) {
name = `${stem}-${counter}${ext}`;
counter += 1;
}
fs.copyFileSync(src, path.join(dir, name));
const relPath = `assets/${name}`;
return { ok: true, name, relPath };
});
ipcMain.handle("folio:copyText", (_e, text: string) => { ipcMain.handle("folio:copyText", (_e, text: string) => {
clipboard.writeText(typeof text === "string" ? text : ""); clipboard.writeText(typeof text === "string" ? text : "");
return { ok: true }; return { ok: true };
@ -431,6 +502,30 @@ ipcMain.handle("folio:getClipboardText", () => {
return clipboard.readText(); return clipboard.readText();
}); });
// Full screen is driven by the Electron window (not the DOM Fullscreen API,
// which is unreliable inside Electron and fails silently here).
ipcMain.handle("folio:toggleFullscreen", () => {
const win = getWindow();
if (!win) return false;
win.setFullScreen(!win.isFullScreen());
return win.isFullScreen();
});
// Right-click context menu (Electron has none by default), so Cut/Copy/Paste
// via the mouse work. The roles act on whatever element is focused.
ipcMain.on("folio:context-menu", (event, x: number, y: number) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return;
const menu = Menu.buildFromTemplate([
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
{ type: "separator" },
{ role: "selectAll" },
]);
menu.popup({ window: win, x, y });
});
// Theme preference. Stored as a plain string so future themes can be added // Theme preference. Stored as a plain string so future themes can be added
// without touching the main process. "default" from older builds maps to // without touching the main process. "default" from older builds maps to
// "light" in the renderer, so it is normalized here too. // "light" in the renderer, so it is normalized here too.

View file

@ -0,0 +1,566 @@
// Convert legacy Markdown chapter content into the native Folio document model.
//
// The converter reuses the SAME markdown-it rules Folio already applies for
// print/PDF rendering (Obsidian ==highlight==, > [!callout], and [[wikilinks]])
// so migrated content matches what the writer already sees in exports. A small
// strikethrough rule is added for ~~text~~ (markdown-it has no built-in).
//
// The output is a ProseMirror/Tiptap JSON tree (FolioChapterDoc). It deliberately
// does NOT produce any Markdown; the document is the source of truth.
import MarkdownIt from "markdown-it";
import type {
MarkdownIt as MarkdownItInstance,
StateCore,
StateInline,
Token,
} from "markdown-it";
import { wikilinkPlugin, type WikilinkTarget } from "../renderer/wikilinks.js";
import {
CURRENT_DOC_SCHEMA_VERSION,
type FolioChapterDoc,
type FolioMark,
type FolioNode,
} from "./native-doc.js";
// ---------------------------------------------------------------------------
// Inline markdown-it plugins (mirrors src/renderer/print-format.ts so that
// ==highlight==, > [!callout] and [[wikilinks]] survive the migration exactly
// as they render today). Kept local to avoid pulling renderer DOM code into the
// main process.
// ---------------------------------------------------------------------------
// ==text== -> <mark>text</mark>
function highlightPlugin(md: MarkdownItInstance) {
const parse = (state: StateInline, silent: boolean): boolean => {
const src = state.src;
const max = state.posMax;
const start = state.pos;
if (start + 2 > max) return false;
if (src.charCodeAt(start) !== 0x3d /* = */ || src.charCodeAt(start + 1) !== 0x3d)
return false;
let end = -1;
for (let i = start + 2; i < max - 1; i++) {
const ch = src.charCodeAt(i);
if (ch === 0x0a /* newline */) return false;
if (ch === 0x3d && src.charCodeAt(i + 1) === 0x3d) {
end = i;
break;
}
}
if (end === -1) return false;
const content = src.slice(start + 2, end);
if (!content.trim()) return false;
if (silent) {
state.pos = end + 2;
return true;
}
state.push("highlight_open", "mark", 1);
state.push("text", "", 0).content = content;
state.push("highlight_close", "mark", -1);
state.pos = end + 2;
return true;
};
md.inline.ruler.before("emphasis", "highlight", parse);
md.renderer.rules.highlight_open = () => "<mark>";
md.renderer.rules.highlight_close = () => "</mark>";
}
// ~~text~~ -> <s>text</s>
function strikethroughPlugin(md: MarkdownItInstance) {
const parse = (state: StateInline, silent: boolean): boolean => {
const src = state.src;
const max = state.posMax;
const start = state.pos;
if (start + 2 > max) return false;
if (src.charCodeAt(start) !== 0x7e /* ~ */ || src.charCodeAt(start + 1) !== 0x7e)
return false;
let end = -1;
for (let i = start + 2; i < max - 1; i++) {
const ch = src.charCodeAt(i);
if (ch === 0x0a /* newline */) return false;
if (ch === 0x7e && src.charCodeAt(i + 1) === 0x7e) {
end = i;
break;
}
}
if (end === -1) return false;
const content = src.slice(start + 2, end);
if (!content.trim()) return false;
if (silent) {
state.pos = end + 2;
return true;
}
state.push("s_open", "s", 1);
state.push("text", "", 0).content = content;
state.push("s_close", "s", -1);
state.pos = end + 2;
return true;
};
md.inline.ruler.before("emphasis", "strikethrough", parse);
}
const CALLOUT_RE = /^\s*\[!([a-zA-Z0-9_-]+)\]\s*(.*)$/;
// > [!note] Title followed by "> body" lines becomes a styled callout. Runs
// after inline parsing so the blockquote paragraph already has children.
function calloutPlugin(md: MarkdownItInstance) {
md.core.ruler.after("inline", "callout", (state: StateCore) => {
const tokens = state.tokens;
for (let i = 0; i < tokens.length; i++) {
const open = tokens[i];
if (open.type !== "blockquote_open") continue;
let depth = 1;
let closeIdx = -1;
for (let j = i + 1; j < tokens.length; j++) {
const t = tokens[j];
if (t.type === "blockquote_open") depth += 1;
else if (t.type === "blockquote_close") {
depth -= 1;
if (depth === 0) {
closeIdx = j;
break;
}
}
}
if (closeIdx === -1) continue;
const paraOpen = tokens[i + 1];
if (!paraOpen || paraOpen.type !== "paragraph_open") continue;
const inline = tokens[i + 2];
if (!inline || inline.type !== "inline" || !inline.children) continue;
const first = inline.children[0];
if (!first || first.type !== "text") continue;
const m = CALLOUT_RE.exec(first.content);
if (!m) continue;
const calloutType = m[1].toLowerCase();
const title = m[2].trim();
open.type = "callout_open";
open.tag = "div";
open.attrs = [["class", `callout callout-${calloutType}`]];
const close = tokens[closeIdx];
close.type = "callout_close";
close.tag = "div";
first.content = "";
inline.children = inline.children.filter(
(c) => !(c.type === "text" && c.content === "")
);
if (inline.children[0]?.type === "softbreak") inline.children.shift();
if (title) {
const titleOpen = new state.Token("paragraph_open", "p", 1);
titleOpen.attrs = [["class", "callout-title"]];
const titleInline = new state.Token("inline", "", 0);
titleInline.content = title;
const titleText = new state.Token("text", "", 0);
titleText.content = title;
titleInline.children = [titleText];
const titleClose = new state.Token("paragraph_close", "p", -1);
tokens.splice(i + 1, 0, titleOpen, titleInline, titleClose);
}
}
});
}
function makeMd(targets: WikilinkTarget[]): MarkdownItInstance {
const md = new MarkdownIt({ html: true, linkify: true });
md.use(wikilinkPlugin, { chapters: targets });
md.use(strikethroughPlugin);
md.use(highlightPlugin);
md.use(calloutPlugin);
return md;
}
// ---------------------------------------------------------------------------
// Token walking
// ---------------------------------------------------------------------------
export interface MarkdownToNativeOpts {
// Resolve an image source (relative paths can be copied into assets/).
resolveImage?: (src: string) => string;
// Chapter targets so [[wikilinks]] resolve to folio:chapter/<id>.
targets?: WikilinkTarget[];
}
interface WalkState {
resolveImage?: (src: string) => string;
}
function calloutType(token: Token): string {
const cls = String(token.attrGet("class") ?? "");
const m = /callout-([a-zA-Z0-9_-]+)/.exec(cls);
return m ? m[1] : "note";
}
// Convert a markdown-it token's attrs (array of [key, value]) into a plain
// object so node-level attributes (e.g. the callout-title paragraph class)
// survive into the native document.
function tokenAttrObj(token: Token): Record<string, string> {
const a = token.attrs;
if (!a) return {};
const out: Record<string, string> = {};
for (const [k, v] of a) out[k] = String(v);
return out;
}
function mergeText(nodes: FolioNode[]): FolioNode[] {
const sig = (marks?: FolioMark[]) => JSON.stringify(marks ?? []);
const out: FolioNode[] = [];
for (const n of nodes) {
const prev = out[out.length - 1];
if (
n.type === "text" &&
prev &&
prev.type === "text" &&
sig(prev.marks) === sig(n.marks)
) {
prev.text = (prev.text ?? "") + (n.text ?? "");
} else {
out.push(n);
}
}
return out;
}
function walkInline(children: Token[] | null, state: WalkState): FolioNode[] {
const out: FolioNode[] = [];
const markStack: FolioMark[] = [];
const topMarks = () => markStack.slice();
if (!children) return out;
for (const t of children) {
switch (t.type) {
case "text":
out.push({ type: "text", text: t.content, marks: topMarks() });
break;
case "softbreak":
out.push({ type: "text", text: " ", marks: topMarks() });
break;
case "hardbreak":
out.push({ type: "hard_break" });
break;
case "code_inline":
out.push({
type: "text",
text: t.content,
marks: [...topMarks(), { type: "code" }],
});
break;
case "strong_open":
markStack.push({ type: "bold" });
break;
case "strong_close":
markStack.pop();
break;
case "em_open":
markStack.push({ type: "italic" });
break;
case "em_close":
markStack.pop();
break;
case "s_open":
markStack.push({ type: "strike" });
break;
case "s_close":
markStack.pop();
break;
case "highlight_open":
markStack.push({ type: "highlight" });
break;
case "highlight_close":
markStack.pop();
break;
case "underline_open":
markStack.push({ type: "underline" });
break;
case "underline_close":
markStack.pop();
break;
case "link_open": {
const href = String(t.attrGet("href") ?? "");
const title = String(t.attrGet("title") ?? "");
const attrs: Record<string, unknown> = { href };
if (title) attrs.title = title;
markStack.push({ type: "link", attrs });
break;
}
case "link_close":
markStack.pop();
break;
case "image": {
let src = String(t.attrGet("src") ?? "");
if (state.resolveImage) src = state.resolveImage(src);
out.push({
type: "image",
attrs: {
src,
alt: String(t.content ?? ""),
title: String(t.attrGet("title") ?? ""),
},
});
break;
}
case "html_inline": {
const c = t.content;
if (c === "<u>") markStack.push({ type: "underline" });
else if (c === "</u>") markStack.pop();
else if (c === "</span>") markStack.pop();
else {
const open = /^<span([^>]*)>$/i.exec(c);
if (open) {
const style = /style\s*=\s*"([^"]*)"/i.exec(open[1]);
if (style) {
for (const decl of style[1].split(";")) {
const idx = decl.indexOf(":");
if (idx < 0) continue;
const prop = decl.slice(0, idx).trim().toLowerCase();
const val = decl.slice(idx + 1).trim();
if (!val) continue;
if (prop === "color")
markStack.push({ type: "textColor", attrs: { color: val } });
else if (prop === "background" || prop === "background-color")
markStack.push({ type: "highlight", attrs: { color: val } });
}
}
}
}
break;
}
default:
break;
}
}
return mergeText(out);
}
function collectUntil(
tokens: Token[],
openIdx: number,
openType: string,
closeType: string
): number {
let depth = 1;
let close = -1;
for (let j = openIdx + 1; j < tokens.length; j++) {
if (tokens[j].type === openType) depth += 1;
else if (tokens[j].type === closeType) {
depth -= 1;
if (depth === 0) {
close = j;
break;
}
}
}
return close;
}
function firstTextNode(node: FolioNode): FolioNode | null {
for (const c of node.content ?? []) {
if (c.type === "text") return c;
if (c.content) {
const r = firstTextNode(c);
if (r) return r;
}
}
return null;
}
// Strip a leading "[ ] " / "[x] " checkbox from a list item, returns checked.
function extractCheckbox(item: FolioNode): boolean {
const tn = firstTextNode(item);
if (tn && tn.text) {
const m = /^\[([ xX])\]\s?/.exec(tn.text);
if (m) {
tn.text = tn.text.slice(m[0].length);
return m[1].toLowerCase() === "x";
}
}
return false;
}
function walkBlocks(
tokens: Token[],
start: number,
end: number,
state: WalkState
): FolioNode[] {
const out: FolioNode[] = [];
let i = start;
while (i < end) {
const t = tokens[i];
switch (t.type) {
case "heading_open": {
const level = parseInt(t.tag.slice(1), 10) || 1;
const inline = tokens[i + 1];
const content = inline?.children
? walkInline(inline.children, state)
: [];
out.push({ type: "heading", attrs: { level }, content });
i += 3;
break;
}
case "paragraph_open": {
const inline = tokens[i + 1];
const content = inline?.children
? walkInline(inline.children, state)
: [];
const attrs = tokenAttrObj(t);
out.push({
type: "paragraph",
...(Object.keys(attrs).length ? { attrs } : {}),
content,
});
i += 3;
break;
}
case "bullet_list_open":
case "ordered_list_open": {
const [node, next] = walkList(tokens, i, state);
out.push(node);
i = next + 1;
break;
}
case "blockquote_open": {
const close = collectUntil(tokens, i, "blockquote_open", "blockquote_close");
const content = walkBlocks(tokens, i + 1, close, state);
out.push({ type: "blockquote", content });
i = close + 1;
break;
}
case "callout_open": {
const type = calloutType(t);
const close = collectUntil(tokens, i, "callout_open", "callout_close");
const content = walkBlocks(tokens, i + 1, close, state);
out.push({ type: "blockquote", attrs: { callout: type }, content });
i = close + 1;
break;
}
case "fence":
case "code_block": {
const lang = (String(t.info ?? "").trim()) || null;
out.push({
type: "codeBlock",
attrs: { language: lang },
content: [{ type: "text", text: t.content }],
});
i += 1;
break;
}
case "hr":
out.push({ type: "horizontalRule" });
i += 1;
break;
case "table_open": {
const [node, next] = walkTable(tokens, i, state);
out.push(node);
i = next + 1;
break;
}
case "html_block":
i += 1;
break;
default:
i += 1;
break;
}
}
return out;
}
function walkList(tokens: Token[], i: number, state: WalkState): [FolioNode, number] {
const open = tokens[i];
const isOrdered = open.type === "ordered_list_open";
const closeType = isOrdered ? "ordered_list_close" : "bullet_list_close";
const closeIdx = collectUntil(tokens, i, open.type, closeType);
const items: FolioNode[] = [];
let j = i + 1;
while (j < closeIdx) {
if (tokens[j].type === "list_item_open") {
const itemClose = collectUntil(tokens, j, "list_item_open", "list_item_close");
const content = walkBlocks(tokens, j + 1, itemClose, state);
items.push({ type: "listItem", content });
j = itemClose + 1;
} else {
j += 1;
}
}
// Obsidian-style checklists: "- [ ] " / "- [x] " in a bullet list.
let isTask = false;
if (!isOrdered && items[0]) {
const tn = firstTextNode(items[0]);
isTask = !!tn && /^\[([ xX])\]/.test(tn.text ?? "");
}
if (isTask) {
const taskItems = items.map((it) => ({
type: "taskItem",
attrs: { checked: extractCheckbox(it) },
content: it.content,
}));
return [{ type: "taskList", content: taskItems }, closeIdx];
}
const listType = isOrdered ? "orderedList" : "bulletList";
return [{ type: listType, content: items }, closeIdx];
}
function walkTable(tokens: Token[], i: number, state: WalkState): [FolioNode, number] {
const closeIdx = collectUntil(tokens, i, "table_open", "table_close");
const rows: FolioNode[] = [];
let j = i + 1;
while (j < closeIdx) {
const t = tokens[j];
if (t.type === "tr_open") {
const rc = collectUntil(tokens, j, "tr_open", "tr_close");
const cells: FolioNode[] = [];
let c = j + 1;
while (c < rc) {
if (tokens[c].type === "th_open" || tokens[c].type === "td_open") {
const cellOpen = tokens[c].type;
const cellClose = cellOpen === "th_open" ? "th_close" : "td_close";
const cellType = cellOpen === "th_open" ? "tableHeader" : "tableCell";
const cc = collectUntil(tokens, c, cellOpen, cellClose);
// Table cell content is an inline token (not wrapped in a paragraph
// block), so walk the inline children directly.
let inner: FolioNode[] = [];
for (let k = c + 1; k < cc; k++) {
if (tokens[k].type === "inline") {
inner = walkInline(tokens[k].children, state);
break;
}
}
cells.push({
type: cellType,
content: inner.length
? [{ type: "paragraph", content: inner }]
: [{ type: "paragraph", content: [] }],
});
c = cc + 1;
} else {
c += 1;
}
}
rows.push({ type: "tableRow", content: cells });
j = rc + 1;
} else {
j += 1;
}
}
return [{ type: "table", content: rows }, closeIdx];
}
export function markdownToNative(source: string, opts: MarkdownToNativeOpts = {}): FolioChapterDoc {
const md = makeMd(opts.targets ?? []);
const tokens = md.parse(source, {});
const state: WalkState = { resolveImage: opts.resolveImage };
const blocks = walkBlocks(tokens, 0, tokens.length, state);
return {
schemaVersion: CURRENT_DOC_SCHEMA_VERSION,
doc: { type: "doc", content: blocks },
};
}

View file

@ -30,6 +30,13 @@ export function buildMenu(
{ label: "New Book", accelerator: "CmdOrCtrl+N", click: onRequestNew }, { label: "New Book", accelerator: "CmdOrCtrl+N", click: onRequestNew },
{ label: "Open Book", accelerator: "CmdOrCtrl+O", click: onRequestOpen }, { label: "Open Book", accelerator: "CmdOrCtrl+O", click: onRequestOpen },
{ type: "separator" }, { type: "separator" },
{
label: "Save",
accelerator: "CmdOrCtrl+S",
enabled: !!getState().bookPath,
click: () => getWindow()?.webContents.send("folio:save-requested"),
},
{ type: "separator" },
{ label: "Open Recent", submenu: recentItems }, { label: "Open Recent", submenu: recentItems },
{ type: "separator" }, { type: "separator" },
{ {

142
src/main/migration.ts Normal file
View file

@ -0,0 +1,142 @@
// Migration from legacy Markdown books to the native Folio document model.
//
// Strategy (safe + explicit, never automatic on open):
// - Only runs when the caller explicitly migrates a chapter/book.
// - Before touching anything, a full recovery backup of the book is made to
// ~/Folio-Recovery (see backup.ts). The original .md files are NEVER
// modified or deleted.
// - The .md content is parsed into a native FolioChapterDoc and written to a
// NEW chapters/<id>.json file. The folio.json entry is repointed to it.
// - Migration is idempotent: if a chapter already has a native .json file it
// is left untouched and reported as already-native.
import fs from "fs";
import path from "path";
import {
loadBook,
saveMeta,
chaptersDir,
type BookMeta,
} from "./project.js";
import { getChapterContent } from "./chapters.js";
import {
getChapterDoc,
setChapterDoc,
nativeChapterFile,
CURRENT_DOC_SCHEMA_VERSION,
type FolioChapterDoc,
} from "./native-doc.js";
import { markdownToNative } from "./markdown-to-native.js";
import { ensureRecoveryBackup } from "./backup.js";
export type MigrateStatus = "migrated" | "already-native" | "error";
export interface MigrateResult {
status: MigrateStatus;
id: string;
file?: string;
error?: string;
}
// Resolve an image src for migration: external/data URIs are kept as-is;
// local relative paths are copied into the book's assets/ directory and the
// reference is rewritten to "assets/<name>".
function resolveImageSrc(
src: string,
bookPath: string,
assetsDir: string,
chapterId: string
): string {
if (/^(https?:|data:|blob:)/i.test(src)) return src;
const candidate = path.resolve(bookPath, src);
let exists = false;
try {
exists = fs.existsSync(candidate) && fs.statSync(candidate).isFile();
} catch {
exists = false;
}
if (!exists) return src; // best-effort: keep the original reference
fs.mkdirSync(assetsDir, { recursive: true });
const base = path.basename(src);
let name = `${chapterId}-${base}`.replace(/[^a-zA-Z0-9._-]/g, "_");
let dest = path.join(assetsDir, name);
let n = 2;
while (fs.existsSync(dest)) {
name = `${chapterId}-${n}-${base}`.replace(/[^a-zA-Z0-9._-]/g, "_");
dest = path.join(assetsDir, name);
n += 1;
}
fs.copyFileSync(candidate, dest);
return `assets/${name}`;
}
export function migrateChapterToNative(
bookPath: string,
id: string
): MigrateResult {
try {
const meta = loadBook(bookPath);
const entry = meta.chapters[id];
if (!entry) return { status: "error", id, error: "Chapter not found" };
// Idempotency: already a native chapter -> leave the original .md alone.
if (entry.file.endsWith(".json")) {
meta.docFormat = "folio-doc";
meta.docSchemaVersion = CURRENT_DOC_SCHEMA_VERSION;
saveMeta(bookPath, meta);
return { status: "already-native", id, file: entry.file };
}
ensureRecoveryBackup(bookPath);
const md = getChapterContent(bookPath, id);
const assetsDir = path.join(bookPath, "assets");
const targets = Object.values(meta.chapters).map((c) => ({
id: c.id,
title: c.title,
}));
const doc: FolioChapterDoc = markdownToNative(md, {
resolveImage: (src) => resolveImageSrc(src, bookPath, assetsDir, id),
targets,
});
const newFile = path.posix.join("chapters", nativeChapterFile(id));
fs.mkdirSync(chaptersDir(bookPath), { recursive: true });
setChapterDoc(bookPath, id, doc);
entry.file = newFile;
meta.docFormat = "folio-doc";
meta.docSchemaVersion = CURRENT_DOC_SCHEMA_VERSION;
saveMeta(bookPath, meta);
return { status: "migrated", id, file: newFile };
} catch (err) {
return {
status: "error",
id,
error: err instanceof Error ? err.message : String(err),
};
}
}
export function migrateBookToNative(bookPath: string): MigrateResult[] {
ensureRecoveryBackup(bookPath);
const meta = loadBook(bookPath);
return meta.chapterOrder.map((id) => migrateChapterToNative(bookPath, id));
}
// Convenience: verify a migrated chapter reads back as valid native content.
export function isNativeChapter(
bookPath: string,
id: string
): boolean {
const meta = loadBook(bookPath) as BookMeta;
const entry = meta.chapters[id];
if (!entry || !entry.file.endsWith(".json")) return false;
try {
getChapterDoc(bookPath, id);
return true;
} catch {
return false;
}
}

127
src/main/native-doc.ts Normal file
View file

@ -0,0 +1,127 @@
// Native Folio rich-text document model.
//
// This is the single source of truth for chapter content once a book is
// migrated to the native format. The document is a ProseMirror/Tiptap JSON
// tree, not Markdown. See Stage 2 of the rich-text migration plan.
//
// Persistence layout (per book):
// folio.json -> book metadata (chapterOrder, chapters, docFormat, ...)
// chapters/<id>.json -> { schemaVersion, doc } (native content)
// assets/ -> copied image assets referenced by chapters
//
// Markdown remains only for legacy migration (this module does the parsing)
// and optional export (added later).
import fs from "fs";
import path from "path";
import {
loadBook,
saveMeta,
chaptersDir,
} from "./project.js";
export interface FolioMark {
type: string;
attrs?: Record<string, unknown>;
}
export interface FolioNode {
type: string;
attrs?: Record<string, unknown>;
content?: FolioNode[];
text?: string;
marks?: FolioMark[];
}
export interface FolioChapterDoc {
schemaVersion: number;
doc: FolioNode;
}
// Bump this whenever the native document shape needs a one-way migration.
export const CURRENT_DOC_SCHEMA_VERSION = 1;
// Each entry migrates the document FROM its key version TO key+1.
// Entries are additive and must be non-destructive.
const MIGRATIONS: Record<number, (doc: FolioChapterDoc) => FolioChapterDoc> = {};
// File name for a native chapter (replaces <id>.md).
export function nativeChapterFile(id: string): string {
return `${id}.json`;
}
// Apply any pending schema migrations, always returning the current version.
export function migrateDoc(doc: FolioChapterDoc): FolioChapterDoc {
let current = doc;
while (current.schemaVersion < CURRENT_DOC_SCHEMA_VERSION) {
const step = MIGRATIONS[current.schemaVersion];
if (!step) break;
current = step(current);
}
current.schemaVersion = CURRENT_DOC_SCHEMA_VERSION;
return current;
}
// Normalize legacy node type names on read. The editor's StarterKit historically
// emitted Tiptap's default `hardBreak` (camelCase), while the converter/export
// pipeline uses `hard_break` (snake_case). Rename so every consumer sees one
// canonical name regardless of how the document was originally saved.
function normalizeDocTypes(node: unknown): unknown {
if (!node || typeof node !== "object") return node;
const n = node as Record<string, any>;
if (typeof n.type === "string" && n.type === "hardBreak") n.type = "hard_break";
if (Array.isArray(n.content)) n.content = n.content.map((c) => normalizeDocTypes(c));
return n;
}
export function serializeChapter(doc: FolioChapterDoc): string {
return JSON.stringify(migrateDoc(doc), null, 2);
}
export function parseChapter(text: string): FolioChapterDoc {
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
throw new Error("Not a native Folio chapter document (invalid JSON).");
}
if (
!raw ||
typeof raw !== "object" ||
!("doc" in raw) ||
(raw as FolioChapterDoc).doc?.type !== "doc"
) {
throw new Error("Not a native Folio chapter document (missing doc tree).");
}
const parsed = migrateDoc(raw as FolioChapterDoc);
normalizeDocTypes(parsed.doc);
return parsed;
}
// Read a native chapter document (throws if the chapter is not native JSON).
export function getChapterDoc(bookPath: string, id: string): FolioChapterDoc {
const meta = loadBook(bookPath);
const entry = meta.chapters[id];
if (!entry) throw new Error("Chapter not found");
const file = path.join(bookPath, entry.file);
const raw = fs.readFileSync(file, "utf-8");
return parseChapter(raw);
}
// Write a native chapter document. Creates chapters/ if needed.
export function setChapterDoc(
bookPath: string,
id: string,
doc: FolioChapterDoc
): void {
fs.mkdirSync(chaptersDir(bookPath), { recursive: true });
const file = path.join(bookPath, "chapters", nativeChapterFile(id));
fs.writeFileSync(file, serializeChapter(doc), "utf-8");
}
// Validate that a parsed object is structurally a usable chapter document.
export function isValidDoc(doc: FolioChapterDoc): boolean {
return !!doc && doc.doc?.type === "doc" && Array.isArray(doc.doc.content);
}
export { loadBook, saveMeta };

View file

@ -0,0 +1,190 @@
// Serialize a native Folio document (ProseMirror/Tiptap JSON) back to Markdown.
//
// This module is the *bridge* that lets Stage 1 keep the existing CodeMirror
// editor, the Full Book view, and the Markdown export working after a chapter
// has been migrated to the native JSON format. It is the inverse of
// markdown-to-native.ts. Stage 2 (Tiptap editor) removes this bridge entirely
// because the editor will speak native JSON directly.
//
// The output is intentionally "good enough" Markdown that round-trips through
// markdown-to-native.ts; it is NOT a pixel-perfect reconstruction of the
// original source (e.g. raw control characters inside ==highlight== are kept
// literal, and folio:chapter/<id> links become [[text]] wikilinks).
import type {
FolioChapterDoc,
FolioMark,
FolioNode,
} from "./native-doc.js";
// Marks applied outermost-first so nested formatting renders as valid Markdown.
const MARK_ORDER = [
"link",
"textColor",
"highlight",
"underline",
"strike",
"bold",
"italic",
"code",
];
function wrap(mark: FolioMark, text: string): string {
const attrs = mark.attrs ?? {};
switch (mark.type) {
case "bold":
return `**${text}**`;
case "italic":
return `*${text}*`;
case "strike":
return `~~${text}~~`;
case "code":
return "`" + text + "`";
case "underline":
return `<u>${text}</u>`;
case "highlight": {
const color = attrs.color;
return color
? `<span style="background-color:${color}">${text}</span>`
: `==${text}==`;
}
case "textColor":
return `<span style="color:${attrs.color}">${text}</span>`;
case "link": {
const href = String(attrs.href ?? "");
if (href.startsWith("folio:chapter/")) return `[[${text}]]`;
const title = attrs.title ? ` "${attrs.title}"` : "";
return `[${text}](${href}${title})`;
}
default:
return text;
}
}
function applyMarks(text: string, marks: FolioMark[] = []): string {
let s = text;
for (const type of MARK_ORDER) {
const mark = marks.find((m) => m.type === type);
if (mark) s = wrap(mark, s);
}
return s;
}
function inlineToMd(nodes: FolioNode[] = []): string {
let out = "";
for (const n of nodes) {
if (n.type === "text") {
out += applyMarks(n.text ?? "", n.marks);
} else if (n.type === "hard_break" || n.type === "hardbreak") {
out += "\n";
} else if (n.type === "paragraph") {
out += inlineToMd(n.content);
} else if (n.type === "image") {
const { src, alt, title } = n.attrs ?? {};
out += title
? `![${alt ?? ""}](${src} "${title}")`
: `![${alt ?? ""}](${src})`;
}
}
return out;
}
function listToMd(node: FolioNode, depth: number): string {
const indent = " ".repeat(depth);
const ordered = node.type === "orderedList";
const isTask = node.type === "taskList";
const items = node.content ?? [];
return items
.map((item, i) => {
let marker: string;
if (isTask) marker = item.attrs?.checked ? "- [x] " : "- [ ] ";
else if (ordered) marker = `${i + 1}. `;
else marker = "- ";
const inner = (item.content ?? [])
.map((b) => blockToMd(b, depth + 1))
.join("\n\n");
const lines = inner.split("\n");
return (
indent +
marker +
lines
.map((l, idx) => (idx === 0 ? l : indent + " " + l))
.join("\n")
);
})
.join("\n");
}
function tableToMd(node: FolioNode): string {
const rows = node.content ?? [];
if (!rows.length) return "";
const cellsOf = (row: FolioNode) =>
(row.content ?? []).map((c) => inlineToMd(c.content));
const header = cellsOf(rows[0]);
const sep = header.map(() => "---");
const body = rows.slice(1).map(cellsOf);
return [...[header, sep], ...body]
.map((r) => "| " + r.join(" | ") + " |")
.join("\n");
}
function blockToMd(node: FolioNode, depth = 0): string {
switch (node.type) {
case "paragraph":
return inlineToMd(node.content);
case "heading": {
const level = Number(node.attrs?.level ?? 1);
return "#".repeat(level) + " " + inlineToMd(node.content);
}
case "blockquote": {
if (node.attrs?.callout) {
const calloutType = String(node.attrs.callout);
const rest: FolioNode[] = [];
let title = "";
for (const c of node.content ?? []) {
if (c.type === "paragraph" && c.attrs?.class === "callout-title") {
title = inlineToMd(c.content);
} else {
rest.push(c);
}
}
const body = rest.map((b) => blockToMd(b, depth)).join("\n\n");
const lines = [`> [!${calloutType}]${title ? " " + title : ""}`];
if (body) {
for (const l of body.split("\n")) lines.push(l ? "> " + l : ">");
}
return lines.join("\n");
}
const inner = (node.content ?? [])
.map((b) => blockToMd(b, depth))
.join("\n\n");
return inner
.split("\n")
.map((l) => (l ? "> " + l : ">"))
.join("\n");
}
case "bulletList":
case "orderedList":
case "taskList":
return listToMd(node, depth);
case "codeBlock": {
const lang = node.attrs?.language ?? "";
const text = (node.content ?? [])
.map((c) => c.text ?? "")
.join("")
.replace(/\n+$/, "");
return "```" + lang + "\n" + text + "\n```";
}
case "horizontalRule":
return "---";
case "table":
return tableToMd(node);
default:
return "";
}
}
export function nativeToMarkdown(doc: FolioChapterDoc): string {
const blocks = doc.doc?.content ?? [];
return blocks.map((b) => blockToMd(b)).join("\n\n");
}

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,8 @@ contextBridge.exposeInMainWorld("folio", {
revealInFolder: (p: string) => ipcRenderer.invoke("folio:reveal", p), revealInFolder: (p: string) => ipcRenderer.invoke("folio:reveal", p),
getRecents: () => ipcRenderer.invoke("folio:getRecents"), getRecents: () => ipcRenderer.invoke("folio:getRecents"),
closeBook: () => ipcRenderer.invoke("folio:closeBook"), closeBook: () => ipcRenderer.invoke("folio:closeBook"),
showContextMenu: (x: number, y: number) =>
ipcRenderer.send("folio:context-menu", x, y),
// chapters // chapters
getBookMeta: () => ipcRenderer.invoke("folio:getBookMeta"), getBookMeta: () => ipcRenderer.invoke("folio:getBookMeta"),
@ -25,6 +27,14 @@ contextBridge.exposeInMainWorld("folio", {
ipcRenderer.invoke("folio:reorderChapters", newOrder), ipcRenderer.invoke("folio:reorderChapters", newOrder),
saveChapter: (id: string, content: string) => saveChapter: (id: string, content: string) =>
ipcRenderer.invoke("folio:saveChapter", id, content), ipcRenderer.invoke("folio:saveChapter", id, content),
migrateChapter: (id: string) =>
ipcRenderer.invoke("folio:migrateChapter", id),
migrateBook: () => ipcRenderer.invoke("folio:migrateBook"),
loadChapterDoc: (id: string) =>
ipcRenderer.invoke("folio:loadChapterDoc", id),
saveChapterDoc: (id: string, doc: unknown) =>
ipcRenderer.invoke("folio:saveChapterDoc", id, doc),
importImageAsset: () => ipcRenderer.invoke("folio:importImageAsset"),
exportMarkdown: () => ipcRenderer.invoke("folio:exportMarkdown"), exportMarkdown: () => ipcRenderer.invoke("folio:exportMarkdown"),
exportZip: () => ipcRenderer.invoke("folio:exportZip"), exportZip: () => ipcRenderer.invoke("folio:exportZip"),
exportPdf: () => ipcRenderer.invoke("folio:exportPdf"), exportPdf: () => ipcRenderer.invoke("folio:exportPdf"),
@ -33,6 +43,7 @@ contextBridge.exposeInMainWorld("folio", {
importAttachment: () => ipcRenderer.invoke("folio:importAttachment"), importAttachment: () => ipcRenderer.invoke("folio:importAttachment"),
copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text), copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text),
getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"), getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"),
toggleFullscreen: () => ipcRenderer.invoke("folio:toggleFullscreen"),
// theme preference // theme preference
getTheme: () => ipcRenderer.invoke("folio:getTheme"), getTheme: () => ipcRenderer.invoke("folio:getTheme"),
@ -69,4 +80,6 @@ contextBridge.exposeInMainWorld("folio", {
ipcRenderer.on("folio:redo-requested", () => cb()), ipcRenderer.on("folio:redo-requested", () => cb()),
onSelectAllRequested: (cb: () => void) => onSelectAllRequested: (cb: () => void) =>
ipcRenderer.on("folio:selectAll-requested", () => cb()), ipcRenderer.on("folio:selectAll-requested", () => cb()),
onSaveRequested: (cb: () => void) =>
ipcRenderer.on("folio:save-requested", () => cb()),
}); });

View file

@ -13,6 +13,10 @@ export interface BookMeta {
title: string; title: string;
chapterOrder: string[]; chapterOrder: string[];
chapters: Record<string, ChapterEntry>; chapters: Record<string, ChapterEntry>;
// Native document format metadata (added when a book is migrated). Legacy
// Markdown books omit these fields entirely.
docFormat?: "markdown" | "folio-doc";
docSchemaVersion?: number;
} }
export interface Recent { export interface Recent {

View file

@ -16,10 +16,20 @@ export function recentsFilePath(): string {
export function listRecents(): Recent[] { export function listRecents(): Recent[] {
// Drop entries whose book no longer exists (e.g. e2e temp books). // Drop entries whose book no longer exists (e.g. e2e temp books).
const recents = _listRecents(recentsFilePath()); const recents = _listRecents(recentsFilePath()).filter(
return recents.filter(
(r) => r && typeof r.path === "string" && hasBook(r.path) (r) => r && typeof r.path === "string" && hasBook(r.path)
); );
// Collapse repeats of the same book (same title) so the home/recent list
// is not cluttered with multiple temp copies of one book.
const seen = new Set<string>();
const deduped: Recent[] = [];
for (const r of recents) {
const key = (r.title || r.path).toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
deduped.push(r);
}
return deduped;
} }
export function addRecent(recent: Recent): Recent[] { export function addRecent(recent: Recent): Recent[] {

File diff suppressed because it is too large Load diff

View file

@ -68,15 +68,16 @@ html, body {
} }
.brand-icon { .brand-icon {
width: 24px; width: 64px;
height: 24px; height: 64px;
flex: none; flex: none;
object-fit: contain; object-fit: contain;
} }
.sidebar-header .icon-button { .sidebar-header .icon-button {
font-size: 1.1rem; font-size: 2rem;
font-weight: 700; font-weight: 700;
line-height: 1;
} }
.sidebar-title { .sidebar-title {
@ -290,6 +291,45 @@ html, body {
border-color: var(--color-accent); border-color: var(--color-accent);
} }
.icon-button:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 1px;
}
.icon-button:active {
background: var(--color-accent);
color: var(--color-on-accent);
border-color: var(--color-accent);
transform: translateY(1px);
}
.icon-button:disabled {
opacity: 0.55;
cursor: default;
background: transparent;
color: inherit;
border-color: var(--color-border);
transform: none;
}
.icon-button.active {
background: var(--color-accent);
color: var(--color-on-accent);
border-color: var(--color-accent);
}
/* The chapters "+" must stay clearly visible (white) in dark mode. */
:root[data-theme="dark-mode"] #addChapterBtn {
color: #ffffff;
}
/* The brand logo is a dark glyph; on the near-black dark theme it would be
invisible. Render it white there (light theme keeps the dark logo). */
:root[data-theme="dark-mode"] .brand-icon,
:root[data-theme="dark-mode"] .welcome-brand {
filter: brightness(0) invert(1);
}
/* ---- welcome ---- */ /* ---- welcome ---- */
.welcome { .welcome {
flex: 1; flex: 1;
@ -304,8 +344,8 @@ html, body {
} }
.welcome-brand { .welcome-brand {
width: 96px; width: 128px;
height: 96px; height: 128px;
object-fit: contain; object-fit: contain;
margin-bottom: 12px; margin-bottom: 12px;
} }
@ -377,6 +417,12 @@ html, body {
} }
/* ---- chapter list ---- */ /* ---- chapter list ---- */
body.dragging-active,
body.dragging-active * {
cursor: grabbing !important;
user-select: none;
}
.chapter-row { .chapter-row {
display: flex; display: flex;
align-items: center; align-items: center;
@ -571,20 +617,24 @@ html, body {
the first gets a subtle separator where a printed page will begin. The the first gets a subtle separator where a printed page will begin. The
separator is a pure CSS border on the chapter's first line, so it never separator is a pure CSS border on the chapter's first line, so it never
becomes part of the copied chapter text. */ becomes part of the copied chapter text. */
#fullBookHost .cm-line.chapter-break { #fullBookHost .chapter-break {
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
margin-top: 2.5rem; margin-top: 2.5rem;
padding-top: 1.5rem; padding-top: 1.5rem;
} }
#fullBookHost .chapter-break.first {
border-top: none;
margin-top: 0;
padding-top: 0;
}
/* ---- PDF / print output ---- /* ---- PDF / print output ----
PDF export prints the whole window via webContents.printToPDF, so the PDF export prints the whole window via webContents.printToPDF, so the
print stylesheet replaces the entire app with a plain-HTML manuscript print stylesheet replaces the entire app with a plain-HTML manuscript
(#printBook) that the renderer keeps populated from the assembled book. (#printBook) that the renderer keeps populated from the assembled book.
Printing plain HTML (instead of the CodeMirror editor, which only keeps a Printing plain HTML guarantees the PDF always contains the complete book in
virtualized window of lines in the DOM) guarantees the PDF always contains full, no matter how long it is or where the editor is scrolled. #printBook
the complete book in full, no matter how long it is or where the editor is is hidden on screen and shown only when printing. */
scrolled. #printBook is hidden on screen and shown only when printing. */
#printBook { #printBook {
display: none; display: none;
} }
@ -812,6 +862,40 @@ html, body {
border-color: var(--color-accent); border-color: var(--color-accent);
} }
.toolbar-label {
font-family: var(--font-sans);
font-size: 0.72rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
align-self: center;
padding: 0 2px;
user-select: none;
}
.toast {
position: fixed;
left: 50%;
bottom: 28px;
transform: translateX(-50%) translateY(8px);
background: var(--color-accent, #2f6df0);
color: #fff;
font-family: var(--font-sans);
font-size: 0.9rem;
font-weight: 600;
padding: 10px 18px;
border-radius: 8px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);
opacity: 0;
pointer-events: none;
transition: opacity 0.18s ease, transform 0.18s ease;
z-index: 1000;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* ---- toolbar dropdowns ---- */ /* ---- toolbar dropdowns ---- */
.toolbar-dropdown { .toolbar-dropdown {
position: relative; position: relative;
@ -976,50 +1060,6 @@ body.focus-mode .editor {
background: var(--color-bg); background: var(--color-bg);
} }
.cm-editor {
flex: 1;
min-width: 0;
height: 100%;
}
.cm-editor .cm-scroller {
overflow: auto;
}
.cm-editor .cm-gutters {
background: var(--color-bg);
color: var(--color-muted);
border-right: 1px solid var(--color-border);
}
.cm-h1 { font-size: 1.7rem; font-weight: 700; }
.cm-h2 { font-size: 1.45rem; font-weight: 700; }
.cm-h3 { font-size: 1.25rem; font-weight: 600; }
.cm-h4 { font-size: 1.1rem; font-weight: 600; }
.cm-h5 { font-size: 1.05rem; font-weight: 600; }
.cm-h6 { font-size: 1rem; font-weight: 600; color: var(--color-muted); }
.cm-strong { font-weight: 700; }
.cm-em { font-style: italic; }
.cm-strike { text-decoration: line-through; }
.cm-link { color: var(--color-accent); }
.cm-url { color: var(--color-muted); text-decoration: underline; }
.cm-code {
font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
background: var(--color-panel);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
.cm-quote { color: var(--color-muted); }
.cm-hr { color: var(--color-muted); text-decoration: line-through; }
.cm-comment { color: var(--color-muted); }
.cm-panels {
background: var(--color-panel);
color: var(--color-text);
}
/* ---- modal dialog ---- */ /* ---- modal dialog ---- */
.modal-backdrop { .modal-backdrop {
position: fixed; position: fixed;
@ -1095,3 +1135,77 @@ body.focus-mode .editor {
cursor: not-allowed; cursor: not-allowed;
} }
/* ---- Native rich-text (Tiptap / ProseMirror) editor ---- */
.editor-panes .folio-prose.ProseMirror {
height: 100%;
min-height: 100%;
overflow-y: auto;
font-family: var(--font-serif);
font-size: 1.05rem;
line-height: 1.7;
color: var(--color-text);
background: var(--color-bg);
padding: 24px 20px;
max-width: 820px;
margin: 0 auto;
outline: none;
}
.folio-prose :first-child { margin-top: 0; }
.folio-prose h1 { font-size: 2rem; margin: 0.6em 0 0.3em; }
.folio-prose h2 { font-size: 1.6rem; margin: 0.6em 0 0.3em; }
.folio-prose h3 { font-size: 1.3rem; margin: 0.6em 0 0.3em; }
.folio-prose h4 { font-size: 1.12rem; margin: 0.6em 0 0.3em; }
.folio-prose h5 { font-size: 1rem; margin: 0.6em 0 0.3em; }
.folio-prose h6 { font-size: 0.9rem; margin: 0.6em 0 0.3em; }
.folio-prose p { margin: 0 0 0.8em; }
.folio-prose ul, .folio-prose ol { padding-left: 1.4em; margin: 0 0 0.8em; }
.folio-prose blockquote {
border-left: 3px solid var(--color-accent);
margin: 0 0 0.8em;
padding: 0.2em 0 0.2em 1em;
color: var(--color-text-dim);
}
.folio-prose code {
font-family: var(--font-mono);
background: var(--color-panel);
padding: 0.1em 0.35em;
border-radius: 4px;
font-size: 0.92em;
}
.folio-prose pre {
background: var(--color-panel);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 12px 14px;
overflow-x: auto;
}
.folio-prose pre code { background: none; padding: 0; }
.folio-prose img { max-width: 100%; height: auto; border-radius: 4px; }
.folio-prose a { color: var(--color-accent); text-decoration: underline; }
.folio-prose hr { border: none; border-top: 1px solid var(--color-border); margin: 1.2em 0; }
.folio-prose table {
border-collapse: collapse;
margin: 0 0 0.8em;
width: 100%;
}
.folio-prose th, .folio-prose td {
border: 1px solid var(--color-border);
padding: 6px 10px;
vertical-align: top;
}
.folio-prose th { background: var(--color-panel); font-weight: 600; }
.folio-prose ul[data-type="taskList"] { list-style: none; padding-left: 0.4em; }
.folio-prose ul[data-type="taskList"] li { display: flex; gap: 0.5em; align-items: flex-start; }
.folio-prose ul[data-type="taskList"] li > label { margin-top: 0.25em; }
.folio-prose ul[data-type="taskList"] input { margin: 0; }
.folio-prose .selectedCell { background: rgba(127, 127, 127, 0.2); }
.folio-prose .is-empty::before {
content: "Write something…";
color: var(--color-text-dim);
float: left;
height: 0;
pointer-events: none;
}
.folio-prose:focus { outline: none; }

View file

@ -18,6 +18,7 @@
<button class="icon-button" id="addChapterBtn" title="Add chapter">+</button> <button class="icon-button" id="addChapterBtn" title="Add chapter">+</button>
</div> </div>
<div class="project-bar hidden" id="projectBar"> <div class="project-bar hidden" id="projectBar">
<button class="icon-button" id="homeBtn" title="Home" aria-label="Home">🏠</button>
<span class="book-title" id="bookTitle"></span> <span class="book-title" id="bookTitle"></span>
<span class="book-path" id="bookPath" title=""></span> <span class="book-path" id="bookPath" title=""></span>
<button class="icon-button" id="revealBtn" title="Reveal in folder">📁</button> <button class="icon-button" id="revealBtn" title="Reveal in folder">📁</button>
@ -31,7 +32,7 @@
<label class="theme-label" for="themeSelect">Theme</label> <label class="theme-label" for="themeSelect">Theme</label>
<select id="themeSelect" class="theme-select" title="Interface theme"> <select id="themeSelect" class="theme-select" title="Interface theme">
<option value="light">Light</option> <option value="light">Light</option>
<option value="neonflux">Neonflux</option> <option value="dark-mode">Dark Mode</option>
</select> </select>
</div> </div>
<button class="button button-ghost" id="newChapterBtn">+ New Chapter</button> <button class="button button-ghost" id="newChapterBtn">+ New Chapter</button>
@ -84,6 +85,39 @@
<option value="5">Heading 5</option> <option value="5">Heading 5</option>
<option value="6">Heading 6</option> <option value="6">Heading 6</option>
</select> </select>
<select id="fontFamilySelect" class="toolbar-select" title="Font family">
<option value="">Serif</option>
<option value="DejaVu Serif, Liberation Serif, serif">DejaVu Serif</option>
<option value="C059, Times New Roman, serif">C059</option>
<option value="Bitstream Charter, serif">Bitstream Charter</option>
<option value="IBM Plex Serif, serif">IBM Plex Serif</option>
<option value="URW Bookman, DejaVu Serif, serif">Bookman</option>
<option value="DejaVu Sans, Liberation Sans, sans-serif">DejaVu Sans</option>
<option value="Carlito, DejaVu Sans, sans-serif">Carlito</option>
<option value="IBM Plex Sans, DejaVu Sans, sans-serif">IBM Plex Sans</option>
<option value="DejaVu Sans Mono, Liberation Mono, monospace">DejaVu Mono</option>
<option value="IBM Plex Mono, DejaVu Sans Mono, monospace">IBM Plex Mono</option>
<option value="FreeMono, monospace">FreeMono</option>
</select>
<select id="fontSizeSelect" class="toolbar-select" title="Font size">
<option value="">17px</option>
<option value="12px">12px</option>
<option value="14px">14px</option>
<option value="16px">16px</option>
<option value="18px">18px</option>
<option value="20px">20px</option>
<option value="24px">24px</option>
<option value="32px">32px</option>
</select>
<select id="lineSpacingSelect" class="toolbar-select" title="Line spacing">
<option value="">1.7</option>
<option value="1">1.0</option>
<option value="1.15">1.15</option>
<option value="1.5">1.5</option>
<option value="2">2.0</option>
<option value="2.5">2.5</option>
<option value="3">3.0</option>
</select>
</div> </div>
<div class="toolbar-group"> <div class="toolbar-group">
<button class="toolbar-btn b" data-cmd="bold" title="Bold (Ctrl+B)">B</button> <button class="toolbar-btn b" data-cmd="bold" title="Bold (Ctrl+B)">B</button>
@ -191,6 +225,8 @@
<button class="dropdown-item" data-export="pdf">PDF</button> <button class="dropdown-item" data-export="pdf">PDF</button>
<button class="dropdown-item" data-export="docx">DOCX</button> <button class="dropdown-item" data-export="docx">DOCX</button>
<button class="dropdown-item" data-export="odt">ODT</button> <button class="dropdown-item" data-export="odt">ODT</button>
<div class="dropdown-sep"></div>
<button class="dropdown-item" data-export="native">Migrate to native</button>
</div> </div>
</div> </div>
<button class="button button-primary" id="copyAllBtn" title="Copy the entire assembled manuscript as Markdown">Copy All</button> <button class="button button-primary" id="copyAllBtn" title="Copy the entire assembled manuscript as Markdown">Copy All</button>
@ -222,6 +258,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="toast hidden" id="toast"></div>
<script src="./renderer.js"></script> <script src="./renderer.js"></script>
</body> </body>
</html> </html>

View file

@ -1,153 +0,0 @@
import { EditorState, type Extension } from "@codemirror/state";
import {
EditorView,
drawSelection,
dropCursor,
highlightActiveLine,
keymap,
type KeyBinding,
} from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import {
bracketMatching,
HighlightStyle,
indentOnInput,
syntaxHighlighting,
} from "@codemirror/language";
import { markdown } from "@codemirror/lang-markdown";
import { highlightSelectionMatches, search, searchKeymap } from "@codemirror/search";
import { tags } from "@lezer/highlight";
import { toggleBold, toggleItalic, toggleLink, toggleStrikethrough, toggleUnderline } from "./md-format.js";
export interface MdEditorCallbacks {
onChange: (text: string) => void;
onSelection?: (view: EditorView) => void;
readOnly?: boolean;
extensions?: Extension[];
}
const formatBindings: KeyBinding[] = [
{ key: "Mod-b", run: toggleBold, preventDefault: true },
{ key: "Mod-i", run: toggleItalic, preventDefault: true },
{ key: "Mod-u", run: toggleUnderline, preventDefault: true },
{ key: "Mod-Shift-s", run: toggleStrikethrough, preventDefault: true },
{ key: "Mod-k", run: toggleLink, preventDefault: true },
];
const mdHighlight = HighlightStyle.define([
{ tag: tags.heading1, class: "cm-h1" },
{ tag: tags.heading2, class: "cm-h2" },
{ tag: tags.heading3, class: "cm-h3" },
{ tag: tags.heading4, class: "cm-h4" },
{ tag: tags.heading5, class: "cm-h5" },
{ tag: tags.heading6, class: "cm-h6" },
{ tag: tags.strong, class: "cm-strong" },
{ tag: tags.emphasis, class: "cm-em" },
{ tag: tags.strikethrough, class: "cm-strike" },
{ tag: tags.link, class: "cm-link" },
{ tag: tags.url, class: "cm-url" },
{ tag: tags.monospace, class: "cm-code" },
{ tag: tags.quote, class: "cm-quote" },
{ tag: tags.contentSeparator, class: "cm-hr" },
{ tag: tags.comment, class: "cm-comment" },
]);
const folioTheme = EditorView.theme({
"&": {
height: "100%",
fontSize: "1.05rem",
backgroundColor: "var(--color-bg)",
color: "var(--color-text)",
},
"&.cm-focused": {
outline: "none",
},
".cm-scroller": {
fontFamily: "var(--font-serif)",
lineHeight: "1.7",
padding: "24px 20px",
},
".cm-content": {
caretColor: "var(--color-accent)",
maxWidth: "820px",
paddingBottom: "48px",
},
".cm-cursor, .cm-dropCursor": {
borderLeftColor: "var(--color-accent)",
},
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
backgroundColor: "var(--color-accent)",
opacity: "0.25",
},
".cm-activeLine": {
backgroundColor: "var(--color-panel)",
},
"&.cm-focused .cm-matchingBracket": {
outline: "1px solid var(--color-accent)",
},
".cm-searchMatch": {
backgroundColor: "var(--color-selection)",
},
".cm-searchMatch.cm-searchMatch-selected": {
backgroundColor: "var(--color-selection-strong)",
},
".cm-panels": {
backgroundColor: "var(--color-panel)",
color: "var(--color-text)",
fontFamily: "var(--font-sans)",
fontSize: "0.85rem",
},
".cm-panels input, .cm-panels button": {
fontFamily: "var(--font-sans)",
},
});
export function createMdEditor(host: HTMLElement, callbacks: MdEditorCallbacks): EditorView {
const readOnly = !!callbacks.readOnly;
const view = new EditorView({
state: EditorState.create({
doc: "",
extensions: [
EditorView.lineWrapping,
drawSelection(),
dropCursor(),
highlightActiveLine(),
highlightSelectionMatches(),
history(),
indentOnInput(),
bracketMatching(),
markdown(),
search({ top: true }),
syntaxHighlighting(mdHighlight),
folioTheme,
...(callbacks.extensions ?? []),
...(readOnly ? [EditorState.readOnly.of(true)] : []),
keymap.of(
readOnly
? [...defaultKeymap, ...searchKeymap]
: [...formatBindings, ...defaultKeymap, ...historyKeymap, ...searchKeymap, indentWithTab]
),
EditorView.updateListener.of((update) => {
if (update.docChanged) callbacks.onChange(update.state.doc.toString());
if (callbacks.onSelection && (update.docChanged || update.selectionSet)) {
callbacks.onSelection(update.view);
}
}),
],
}),
parent: host,
});
return view;
}
export function setEditorText(view: EditorView, text: string): void {
const current = view.state.doc.toString();
if (current === text) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: text },
});
}
export function editorText(view: EditorView): string {
return view.state.doc.toString();
}

View file

@ -1,818 +0,0 @@
import {
EditorSelection,
type EditorState,
type Transaction,
type TransactionSpec,
} from "@codemirror/state";
/**
* Minimal command target: the functions in this module only need read access
* to an editor state and a way to dispatch a transaction. Both `EditorView`
* and a plain { state, dispatch } object satisfy it, which keeps the module
* testable without a DOM.
*/
export interface FormatTarget {
state: EditorState;
dispatch: (tr: Transaction | TransactionSpec) => void;
}
type FormatCommand = (target: FormatTarget) => boolean;
function slice(state: EditorState, from: number, to: number): string {
return state.doc.sliceString(from, to);
}
function lineRange(state: EditorState, from: number, to: number): { start: number; end: number } {
const first = state.doc.lineAt(from);
const last = state.doc.lineAt(to);
return { start: first.from, end: last.to };
}
/* ------------------------------------------------------------------ */
/* Inline markers: bold, italic, strikethrough, inline code */
/* ------------------------------------------------------------------ */
function toggleInline(marker: string): FormatCommand {
return (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const len = marker.length;
const single = len === 1;
const before = from - len >= 0 ? slice(state, from - len, from) : "";
const after = slice(state, to, to + len);
const isolatedBefore =
!single || from - len - 1 < 0 || slice(state, from - len - 1, from - len) !== marker;
const isolatedAfter =
!single || to + len >= state.doc.length || slice(state, to + len, to + len + 1) !== marker;
if (before === marker && after === marker && isolatedBefore && isolatedAfter) {
dispatch({
changes: [
{ from: from - len, to: from, insert: "" },
{ from: to, to: to + len, insert: "" },
],
selection: EditorSelection.single(from - len, to - len),
});
return true;
}
const text = slice(state, from, to);
const textStartsMarked = text.startsWith(marker);
const textEndsMarked = text.endsWith(marker);
const isolatedInnerBefore =
!single || text.length < len * 2 + 1 || text.charAt(len) !== marker;
const isolatedInnerAfter =
!single || text.length < len * 2 + 1 || text.charAt(text.length - len - 1) !== marker;
if (
text.length >= len * 2 &&
textStartsMarked &&
textEndsMarked &&
isolatedInnerBefore &&
isolatedInnerAfter
) {
dispatch({
changes: [
{ from, to: from + len, insert: "" },
{ from: to - len, to, insert: "" },
],
selection: EditorSelection.single(from, to - len * 2),
});
return true;
}
dispatch({
changes: [
{ from, to: from, insert: marker },
{ from: to, to: to, insert: marker },
],
selection: EditorSelection.single(from + len, to + len),
});
return true;
};
}
export const toggleBold: FormatCommand = toggleInline("**");
export const toggleItalic: FormatCommand = toggleInline("*");
export const toggleStrikethrough: FormatCommand = toggleInline("~~");
export const toggleInlineCode: FormatCommand = toggleInline("`");
/* ------------------------------------------------------------------ */
/* Code: inline for single-line/empty selection, fenced for blocks */
/* ------------------------------------------------------------------ */
const fenceLineRe = /^```[a-zA-Z0-9_-]*[ \t]*$/;
export const toggleCode: FormatCommand = (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const first = state.doc.lineAt(from);
const last = state.doc.lineAt(to);
if (first.number === last.number) {
return toggleInlineCode(target);
}
let start = first.from;
let end = last.to;
let region = slice(state, start, end);
let lines = region.split("\n");
// Selection starts at an opening fence: treat it as a fenced-block toggle.
if (lines.length > 1 && fenceLineRe.test(lines[0])) {
if (!fenceLineRe.test(lines[lines.length - 1]) && last.number < state.doc.lines) {
const next = state.doc.line(last.number + 1);
if (fenceLineRe.test(next.text)) end = next.to;
}
region = slice(state, start, end);
lines = region.split("\n");
const body = lines.slice(1, lines.length > 1 && fenceLineRe.test(lines[lines.length - 1]) ? -1 : lines.length);
dispatch({ changes: { from: start, to: end, insert: body.join("\n") } });
return true;
}
dispatch({ changes: { from: start, to: end, insert: "```\n" + region + "\n```" } });
return true;
};
/* ------------------------------------------------------------------ */
/* Links */
/* ------------------------------------------------------------------ */
export const toggleLink: FormatCommand = (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const text = slice(state, from, to);
// Selection is already a link → select its URL so the user can edit it.
const link = /^\[([^\]\n]*)\]\(([^)\n]*)\)$/.exec(text);
if (link) {
const urlFrom = from + 1 + link[1].length + 2;
dispatch({
selection: EditorSelection.range(urlFrom, urlFrom + link[2].length),
scrollIntoView: true,
});
return true;
}
if (from === to) {
const pos = from;
dispatch({
changes: { from: pos, to: pos, insert: "[](url)" },
selection: EditorSelection.cursor(pos + 1),
});
return true;
}
const urlStart = from + text.length + 3;
dispatch({
changes: { from, to, insert: `[${text}](url)` },
selection: EditorSelection.range(urlStart, urlStart + 3),
});
return true;
};
/* ------------------------------------------------------------------ */
/* Headings */
/* ------------------------------------------------------------------ */
const headingRe = /^(#{1,6})[ \t]/;
export function setHeading(level: number): FormatCommand {
return (target) => {
const { state, dispatch } = target;
const { from } = state.selection.main;
const line = state.doc.lineAt(from);
const match = line.text.match(headingRe);
const existing = match ? match[1].length : 0;
if (level === 0) {
if (!existing) return false;
dispatch({ changes: { from: line.from, to: line.from + existing + 1, insert: "" } });
return true;
}
if (existing === level) return false;
const insert = "#".repeat(level) + " ";
dispatch({
changes: existing
? { from: line.from, to: line.from + existing + 1, insert }
: { from: line.from, to: line.from, insert },
});
return true;
};
}
export function headingLevelAt(state: EditorState, pos: number): number {
const line = state.doc.lineAt(pos);
const match = line.text.match(headingRe);
return match ? match[1].length : 0;
}
/* ------------------------------------------------------------------ */
/* Line prefixes: bullet list, blockquote */
/* ------------------------------------------------------------------ */
function toggleLinePrefix(prefix: string): FormatCommand {
return (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const { start, end } = lineRange(state, from, to);
const lines = slice(state, start, end).split("\n");
if (!lines.length) return false;
const allHave = lines.every((line) => line.startsWith(prefix));
const insert = lines.map((line) => (allHave ? line.slice(prefix.length) : prefix + line)).join("\n");
dispatch({ changes: { from: start, to: end, insert } });
return true;
};
}
export const toggleBulletList: FormatCommand = toggleLinePrefix("- ");
export const toggleBlockquote: FormatCommand = toggleLinePrefix("> ");
/* ------------------------------------------------------------------ */
/* Numbered list */
/* ------------------------------------------------------------------ */
const orderedRe = /^\d+\.\s+/;
export const toggleOrderedList: FormatCommand = (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const { start, end } = lineRange(state, from, to);
const lines = slice(state, start, end).split("\n");
if (!lines.length) return false;
const allHave = lines.every((line) => orderedRe.test(line));
const insert = lines
.map((line, index) => {
const stripped = line.replace(orderedRe, "");
return allHave ? stripped : `${index + 1}. ${stripped}`;
})
.join("\n");
dispatch({ changes: { from: start, to: end, insert } });
return true;
};
/* ------------------------------------------------------------------ */
/* Horizontal rule */
/* ------------------------------------------------------------------ */
export const insertHorizontalRule: FormatCommand = (target) => {
const { state, dispatch } = target;
const { from } = state.selection.main;
if (from === 0) {
dispatch({
changes: { from: 0, to: 0, insert: "---\n\n" },
selection: EditorSelection.cursor(5),
});
return true;
}
const line = state.doc.lineAt(from);
if (line.from === from && line.text.length === 0) {
const hasNextLine =
line.to < state.doc.length && slice(state, line.to, line.to + 1) === "\n";
const insertAt = hasNextLine ? line.to + 1 : line.from;
dispatch({
changes: { from: insertAt, to: insertAt, insert: "---\n\n" },
selection: EditorSelection.cursor(insertAt + 5),
});
return true;
}
dispatch({
changes: { from, to: from, insert: "\n\n---\n\n" },
selection: EditorSelection.cursor(from + 6),
});
return true;
};
/* ------------------------------------------------------------------ */
/* Clear formatting */
/* ------------------------------------------------------------------ */
function clearLine(line: string): string {
let out = line;
out = out.replace(/^#{1,6}[ \t]+/, "");
out = out.replace(/^>\s*/, "");
out = out.replace(/^[-*+]\s+/, "");
out = out.replace(/^\d+\.\s+/, "");
out = out.replace(/\[([^\]\n]+)\]\([^)\n]*\)/g, "$1");
out = out.replace(/\*\*|\*|~~|`|==/g, "");
out = out.replace(
/<u>|<\/u>|<mark[^>]*>|<\/mark>|<span[^>]*>|<\/span>|<div[^>]*>|<\/div>/g,
""
);
return out;
}
export const clearFormatting: FormatCommand = (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const { start, end } = lineRange(state, from, to);
const region = slice(state, start, end);
const cleaned = region.split("\n").map(clearLine).join("\n");
if (cleaned === region) return false;
dispatch({ changes: { from: start, to: end, insert: cleaned } });
return true;
};
/* ------------------------------------------------------------------ */
/* HTML inline wrappers: underline, colors (readable outside Folio) */
/* ------------------------------------------------------------------ */
interface HtmlSpan {
from: number;
textFrom: number;
textTo: number;
to: number;
}
function htmlSpanAt(
state: EditorState,
pos: number,
openPrefix: string,
closeTag: string
): HtmlSpan | null {
const before = state.doc.sliceString(0, pos);
const openIdx = before.lastIndexOf(openPrefix);
if (openIdx < 0) return null;
const gt = state.doc.sliceString(openIdx).indexOf(">");
if (gt < 0) return null;
const textFrom = openIdx + gt + 1;
const after = state.doc.sliceString(textFrom);
const closeIdx = after.indexOf(closeTag);
if (closeIdx < 0) return null;
const textTo = textFrom + closeIdx;
if (pos >= openIdx && pos <= textTo + closeTag.length) {
return { from: openIdx, textFrom, textTo, to: textTo + closeTag.length };
}
return null;
}
function hexFrom(tag: string): string | null {
const m = /color:\s*(#[0-9a-fA-F]{3,8}|[a-zA-Z]+)/.exec(tag);
return m ? m[1].toLowerCase() : null;
}
export const toggleUnderline: FormatCommand = (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
// Selection bounded by the tags: unwrap.
if (
from - 3 >= 0 &&
slice(state, from - 3, from) === "<u>" &&
slice(state, to, to + 4) === "</u>"
) {
dispatch({
changes: [
{ from: from - 3, to: from, insert: "" },
{ from: to, to: to + 4, insert: "" },
],
selection: EditorSelection.single(from - 3, to - 3),
});
return true;
}
// Selection includes the tags: unwrap.
const text = slice(state, from, to);
if (text.startsWith("<u>") && text.endsWith("</u>")) {
dispatch({
changes: [
{ from, to: from + 3, insert: "" },
{ from: to - 4, to, insert: "" },
],
selection: EditorSelection.single(from, to - 7),
});
return true;
}
// Cursor inside a wrapper: unwrap it.
const wrapper = htmlSpanAt(state, from, "<u>", "</u>");
if (wrapper && from === to) {
dispatch({
changes: [
{ from: wrapper.from, to: wrapper.textFrom, insert: "" },
{ from: wrapper.textTo, to: wrapper.to, insert: "" },
],
selection: EditorSelection.cursor(wrapper.textFrom),
});
return true;
}
dispatch({
changes: [
{ from, to: from, insert: "<u>" },
{ from: to, to: to, insert: "</u>" },
],
selection: EditorSelection.single(from + 3, to + 3),
});
return true;
};
// Obsidian-compatible highlight markers.
export const toggleHighlight: FormatCommand = toggleInline("==");
function wrapColored(kind: "color" | "bg", color: string): FormatCommand {
const attr = kind === "color" ? "color" : "background-color";
const open = `<span style="${attr}:${color}">`;
const close = "</span>";
return (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const text = slice(state, from, to);
// Whole colored span selected: remove the color.
const re = new RegExp(`^<span style="${attr}:[^"]*">([\\s\\S]*)<\\/span>$`);
const full = re.exec(text);
if (full) {
const inner = full[1];
dispatch({
changes: { from, to, insert: inner },
selection: EditorSelection.single(from, from + inner.length),
});
return true;
}
// Selection/cursor inside an existing wrapper: remove if same color, else recolor.
const wrap = htmlSpanAt(state, from, `<span style="${attr}:`, close);
if (wrap) {
const inner = slice(state, wrap.textFrom, wrap.textTo);
const openTag = state.doc.sliceString(wrap.from, wrap.textFrom);
const same = openTag.includes(`:${color}"`);
const insert = same ? inner : open + inner + close;
dispatch({
changes: { from: wrap.from, to: wrap.to, insert },
selection: EditorSelection.single(
wrap.from + (same ? 0 : open.length),
wrap.from + (same ? inner.length : open.length + inner.length)
),
});
return true;
}
if (from === to) {
const insert = open + "text" + close;
dispatch({
changes: { from, to: from, insert },
selection: EditorSelection.range(from + open.length, from + open.length + 4),
});
return true;
}
dispatch({
changes: { from, to, insert: open + text + close },
selection: EditorSelection.single(from + open.length, to + open.length),
});
return true;
};
}
export const toggleTextColor = (color: string): FormatCommand => wrapColored("color", color);
export const toggleHighlightColor = (color: string): FormatCommand => wrapColored("bg", color);
function stripSpan(region: string, attr: string): string {
return region.replace(
new RegExp(`<span style="${attr}:[^"]*">([\\s\\S]*?)<\\/span>`, "g"),
"$1"
);
}
export const removeColor: FormatCommand = (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const { start, end } = lineRange(state, from, to);
const region = slice(state, start, end);
const cleaned = stripSpan(region, "color");
if (cleaned === region) return false;
dispatch({ changes: { from: start, to: end, insert: cleaned } });
return true;
};
export const removeHighlight: FormatCommand = (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const { start, end } = lineRange(state, from, to);
const region = slice(state, start, end);
const cleaned = region
.replace(/<span style="background-color:[^"]*">([\s\S]*?)<\/span>/g, "$1")
.replace(/==([\s\S]*?)==/g, "$1");
if (cleaned === region) return false;
dispatch({ changes: { from: start, to: end, insert: cleaned } });
return true;
};
/* ------------------------------------------------------------------ */
/* Task list */
/* ------------------------------------------------------------------ */
const checklistRe = /^([-*+]\s+)\[([ xX])\]\s+/;
export const toggleChecklist: FormatCommand = (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const { start, end } = lineRange(state, from, to);
const lines = slice(state, start, end).split("\n");
if (!lines.length) return false;
const allHave = lines.every((l) => checklistRe.test(l));
const insert = lines
.map((l) => {
if (allHave) {
const m = l.match(checklistRe)!;
const checked = m[2].toLowerCase() === "x";
return `${m[1]}[${checked ? " " : "x"}] ${l.slice(m[0].length)}`;
}
const stripped = l.replace(/^([-*+]\s+|\d+\.\s+)/, "");
return `- [ ] ${stripped}`;
})
.join("\n");
dispatch({ changes: { from: start, to: end, insert } });
return true;
};
/* ------------------------------------------------------------------ */
/* Insertions: wikilink, image, table, callout, attachment */
/* ------------------------------------------------------------------ */
export function insertWikilink(name: string, label?: string): FormatCommand {
return (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const text = label && label !== name ? `[[${name}|${label}]]` : `[[${name}]]`;
dispatch({
changes: { from, to, insert: text },
selection: EditorSelection.cursor(from + text.length),
});
return true;
};
}
export function insertImage(alt: string, url: string): FormatCommand {
return (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const text = `![${alt}](${url})`;
dispatch({
changes: { from, to, insert: text },
selection: EditorSelection.range(from + alt.length + 4, from + alt.length + 4 + url.length),
});
return true;
};
}
export function insertTable(rows: number, cols: number): FormatCommand {
return (target) => {
const { state, dispatch } = target;
const { from } = state.selection.main;
const header = `| ${Array.from({ length: cols }, (_, i) => `Col ${i + 1}`).join(" | ")} |`;
const sep = `| ${Array(cols).fill("---").join(" | ")} |`;
const bodyRows = Math.max(1, rows - 1);
const body = Array.from(
{ length: bodyRows },
() => `| ${Array(cols).fill(" ").join(" | ")} |`
).join("\n");
const table = `${header}\n${sep}\n${body}`;
const line = state.doc.lineAt(from);
const prefix = from > 0 && line.from !== from ? "\n" : "";
const insert = `${prefix}${table}\n`;
const cellFrom = from + prefix.length + header.length + 1 + sep.length + 1 + 2;
dispatch({
changes: { from, to: from, insert },
selection: EditorSelection.cursor(cellFrom),
});
return true;
};
}
export function insertCallout(type = "note", title?: string): FormatCommand {
return (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const head = title ? `> [!${type}] ${title}\n` : `> [!${type}]\n`;
const insert = head + "> ";
dispatch({
changes: { from, to, insert },
selection: EditorSelection.cursor(from + insert.length),
});
return true;
};
}
export function insertAttachment(name: string, relPath: string): FormatCommand {
return (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const text = `[${name}](${relPath})`;
dispatch({
changes: { from, to, insert: text },
selection: EditorSelection.cursor(from + text.length),
});
return true;
};
}
/* ------------------------------------------------------------------ */
/* Alignment (HTML, compatible with Obsidian) */
/* ------------------------------------------------------------------ */
const alignDivRe = /^(<div align="(left|center|right)">)([\s\S]*)(<\/div>)$/;
export function toggleAlign(align: "left" | "center" | "right"): FormatCommand {
return (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const { start, end } = lineRange(state, from, to);
const region = slice(state, start, end);
const m = alignDivRe.exec(region);
if (m) {
if (align === "left" || m[2] === align) {
dispatch({ changes: { from: start, to: end, insert: m[3] } });
} else {
dispatch({ changes: { from: start, to: end, insert: `<div align="${align}">${m[3]}</div>` } });
}
return true;
}
if (align === "left") return false;
const open = `<div align="${align}">`;
dispatch({
changes: { from: start, to: end, insert: `${open}${region}</div>` },
selection: EditorSelection.single(start + open.length, end + open.length),
});
return true;
};
}
/* ------------------------------------------------------------------ */
/* Explicit code block */
/* ------------------------------------------------------------------ */
function fencedBlockAt(state: EditorState, pos: number): { from: number; to: number } | null {
const doc = state.doc;
const line = doc.lineAt(pos);
let open = fenceLineRe.test(line.text) ? line.number : -1;
if (open < 0) {
for (let n = line.number - 1; n >= 1; n--) {
if (fenceLineRe.test(doc.line(n).text)) {
open = n;
break;
}
}
}
if (open < 1) return null;
for (let n = open + 1; n <= doc.lines; n++) {
if (fenceLineRe.test(doc.line(n).text)) {
return { from: doc.line(open).from, to: doc.line(n).to };
}
}
return null;
}
export const toggleFencedCode: FormatCommand = (target) => {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
const { start, end } = lineRange(state, from, to);
const block = fencedBlockAt(state, start);
if (block) {
const body = slice(state, block.from, block.to).split("\n").slice(1, -1).join("\n");
dispatch({ changes: { from: block.from, to: block.to, insert: body } });
return true;
}
const region = slice(state, start, end);
dispatch({
changes: { from: start, to: end, insert: "```\n" + region + "\n```" },
selection: EditorSelection.single(start + 4, end + 4),
});
return true;
};
/* ------------------------------------------------------------------ */
/* Active-state detection */
/* ------------------------------------------------------------------ */
export interface FormatState {
bold: boolean;
italic: boolean;
strike: boolean;
underline: boolean;
highlight: boolean;
inlineCode: boolean;
color: string | null;
hColor: string | null;
heading: number;
list: "bullet" | "ordered" | "checklist" | null;
align: "left" | "center" | "right" | null;
}
function countOccurrences(text: string, sub: string): number {
let count = 0;
let i = 0;
while ((i = text.indexOf(sub, i)) !== -1) {
count += 1;
i += sub.length;
}
return count;
}
function oddBefore(line: string, pos: number, marker: string): boolean {
return countOccurrences(line.slice(0, pos), marker) % 2 === 1;
}
export function detectFormatting(state: EditorState, pos: number): FormatState {
const line = state.doc.lineAt(pos);
const inLine = pos - line.from;
const before = line.text.slice(0, inLine);
const italic = (countOccurrences(before, "*") - 2 * countOccurrences(before, "**")) % 2 === 1;
let list: FormatState["list"] = null;
if (checklistRe.test(line.text)) list = "checklist";
else if (/^[-*+]\s+/.test(line.text)) list = "bullet";
else if (orderedRe.test(line.text)) list = "ordered";
let align: FormatState["align"] = null;
const div = htmlSpanAt(state, pos, "<div align=", "</div>");
if (div) {
const tag = state.doc.sliceString(div.from, div.textFrom).toLowerCase();
if (tag.includes('align="center"')) align = "center";
else if (tag.includes('align="right"')) align = "right";
else align = "left";
}
const u = htmlSpanAt(state, pos, "<u>", "</u>");
const mark = htmlSpanAt(state, pos, '<mark style="background-color:', "</mark>");
const span = htmlSpanAt(state, pos, '<span style="color:', "</span>");
return {
bold: oddBefore(line.text, inLine, "**"),
italic,
strike: oddBefore(line.text, inLine, "~~"),
underline: u !== null,
highlight: oddBefore(line.text, inLine, "==") || mark !== null,
inlineCode: oddBefore(line.text, inLine, "`"),
color: span ? hexFrom(state.doc.sliceString(span.from, span.textFrom)) : null,
hColor: mark ? hexFrom(state.doc.sliceString(mark.from, mark.textFrom)) : null,
heading: headingLevelAt(state, pos),
list,
align,
};
}
export const captureFormat: (state: EditorState, pos: number) => FormatState = detectFormatting;
/* ------------------------------------------------------------------ */
/* Format painter */
/* ------------------------------------------------------------------ */
function selectionWrapped(state: EditorState, open: string, close: string): boolean {
const { from, to } = state.selection.main;
const t = slice(state, from, to);
return t.startsWith(open) && t.endsWith(close);
}
function ensureWrap(target: FormatTarget, open: string, close: string): boolean {
const { state, dispatch } = target;
const { from, to } = state.selection.main;
if (from === to) return false;
dispatch({
changes: [
{ from, to: from, insert: open },
{ from: to, to: to, insert: close },
],
selection: EditorSelection.single(from + open.length, to + open.length),
});
return true;
}
export function applyFormatPainter(target: FormatTarget, fmt: FormatState): boolean {
let changed = false;
if (fmt.bold && !selectionWrapped(target.state, "**", "**")) changed = ensureWrap(target, "**", "**") || changed;
if (fmt.italic && !selectionWrapped(target.state, "*", "*")) changed = ensureWrap(target, "*", "*") || changed;
if (fmt.strike && !selectionWrapped(target.state, "~~", "~~")) changed = ensureWrap(target, "~~", "~~") || changed;
if (fmt.underline && !selectionWrapped(target.state, "<u>", "</u>")) changed = ensureWrap(target, "<u>", "</u>") || changed;
if (fmt.highlight && !selectionWrapped(target.state, "==", "==")) changed = ensureWrap(target, "==", "==") || changed;
const hasSelection =
target.state.selection.main.from !== target.state.selection.main.to;
if (fmt.color && hasSelection) {
const t = slice(target.state, target.state.selection.main.from, target.state.selection.main.to);
if (!/^<span style="color:[^"]*">/.test(t)) changed = toggleTextColor(fmt.color)(target) || changed;
}
if (fmt.hColor && hasSelection) {
const t = slice(target.state, target.state.selection.main.from, target.state.selection.main.to);
if (!/^<span style="background-color:[^"]*">/.test(t)) changed = toggleHighlightColor(fmt.hColor)(target) || changed;
}
const cur = detectFormatting(target.state, target.state.selection.main.from);
if (fmt.heading && cur.heading !== fmt.heading) changed = setHeading(fmt.heading)(target) || changed;
if (fmt.list && cur.list !== fmt.list) {
if (fmt.list === "bullet") changed = toggleBulletList(target) || changed;
else if (fmt.list === "ordered") changed = toggleOrderedList(target) || changed;
else if (fmt.list === "checklist") changed = toggleChecklist(target) || changed;
}
return changed;
}

View file

@ -0,0 +1,82 @@
// Pure assembly logic for the Full Book view.
//
// The Full Book renders every chapter's native rich-text document back-to-back
// in a single read-only ProseMirror surface, separated by ChapterBreak anchors.
// These helpers are kept free of DOM/IPC so they can be unit-tested directly
// and reused by the renderer (book.ts).
import type { FolioNode } from "../main/native-doc.js";
import type { WikilinkTarget } from "./wikilinks.js";
export interface FullBookChapterInput {
id: string;
title: string;
// Native ProseMirror document ({ type: "doc", content: [...] }).
doc: FolioNode;
}
export interface FullBookAssembly {
nativeDoc: FolioNode;
chapterEntries: { id: string; title: string }[];
targets: WikilinkTarget[];
}
// Concatenate the plain text of a native node tree (used for word/char counts).
// Atom leaf nodes (chapterBreak, image, horizontal rule, …) carry no text.
export function nativeNodeText(node: FolioNode): string {
if (node.text) return node.text;
let t = "";
for (const c of node.content ?? []) t += nativeNodeText(c);
return t;
}
// Whether the native content already opens with a heading matching its title,
// in which case we must not inject a duplicate title heading.
export function nativeHasTitleHeading(content: FolioNode[], title: string): boolean {
const t = title.trim().toLowerCase();
if (!t) return false;
for (const n of content) {
if (n.type === "heading" && nativeNodeText(n).trim().toLowerCase() === t) return true;
}
return false;
}
// Build the top-level nodes for one chapter in the Full Book, injecting a title
// heading when the content does not already provide one.
export function buildChapterNativeContent(content: FolioNode[], title: string): FolioNode[] {
if (nativeHasTitleHeading(content, title)) return content.slice();
return [
{ type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: title }] },
...content,
];
}
// A ChapterBreak anchor marking where a chapter begins in the assembled view.
export function chapterBreakNode(id: string, title: string, first: boolean): FolioNode {
return { type: "chapterBreak", attrs: { chapterId: id, title, first } };
}
// Assemble the whole book directly from native chapter documents. The first
// chapter's break is unmarked; every following chapter gets a separator break.
export function buildFullBookDoc(chapters: FullBookChapterInput[]): FullBookAssembly {
const nativeContent: FolioNode[] = [];
const chapterEntries: { id: string; title: string }[] = [];
const targets: WikilinkTarget[] = [];
let index = 0;
for (const ch of chapters) {
targets.push({ id: ch.id, title: ch.title });
const chapterDoc =
ch.doc && ch.doc.type === "doc" ? ch.doc : { type: "doc" as const, content: [] };
const content = Array.isArray(chapterDoc.content) ? chapterDoc.content : [];
nativeContent.push(chapterBreakNode(ch.id, ch.title, index === 0));
for (const n of buildChapterNativeContent(content, ch.title)) nativeContent.push(n);
chapterEntries.push({ id: ch.id, title: ch.title });
index++;
}
return {
nativeDoc: { type: "doc", content: nativeContent },
chapterEntries,
targets,
};
}

View file

@ -1,136 +1,170 @@
// Print rendering for the PDF-exported manuscript (#printBook). // Print rendering for the PDF-exported manuscript (#printBook).
// //
// The PDF is produced by printing plain HTML, so this renderer has to keep // The PDF is produced by printing plain HTML, so this renderer walks the
// every formatting feature Folio's editor can write. Standard markdown is // native Folio document model directly and emits HTML that keeps every
// handled by markdown-it; the extensions on top of it are: // formatting feature the editor can write. Chapters are emitted as
// - raw HTML passthrough (html: true) so the toolbar's <u>, <span style=...> // <section class="print-chapter"> blocks, delimited by the chapterBreak nodes
// and <div align=...> survive instead of showing as literal text, // the Full Book assembly inserts between chapters.
// - Obsidian-style ==highlight== rendered as <mark>, import type { FolioMark, FolioNode } from "../main/native-doc.js";
// - Obsidian callouts (> [!note] Title) rendered as styled divs,
// - wikilinks ([[Title]]) resolved to links via the shared wikilink plugin.
import MarkdownIt from "markdown-it";
import type {
MarkdownIt as MarkdownItInstance,
StateCore,
StateInline,
} from "markdown-it";
import { wikilinkPlugin, type WikilinkTarget } from "./wikilinks.js";
// ==text== -> <mark>text</mark> // Native-document print rendering for the PDF-exported manuscript (#printBook).
function highlightPlugin(md: MarkdownItInstance) { //
const parse = (state: StateInline, silent: boolean): boolean => { // Like renderPrintChapters but it consumes the native Folio document model
const src = state.src; // directly instead of Markdown, so PDF output stays in sync with the editor
const max = state.posMax; // after the migration. Chapters are emitted as <section class="print-chapter">
const start = state.pos; // blocks, delimited by the chapterBreak nodes that the Full Book assembly
if (start + 2 > max) return false; // inserts between chapters.
if (src.charCodeAt(start) !== 0x3d /* = */ || src.charCodeAt(start + 1) !== 0x3d) function esc(s: string): string {
return false; return s
let end = -1; .replace(/&/g, "&amp;")
for (let i = start + 2; i < max - 1; i++) { .replace(/</g, "&lt;")
const ch = src.charCodeAt(i); .replace(/>/g, "&gt;")
if (ch === 0x0a /* newline */) return false; .replace(/"/g, "&quot;");
if (ch === 0x3d && src.charCodeAt(i + 1) === 0x3d) {
end = i;
break;
} }
}
if (end === -1) return false; const ALIGN = (node?: FolioNode): string => {
const content = src.slice(start + 2, end); const a = node?.attrs;
if (!content.trim()) return false; const parts: string[] = [];
if (silent) { const ta = a?.textAlign ? String(a.textAlign) : "";
state.pos = end + 2; if (ta && ta !== "left") parts.push(`text-align:${esc(ta)}`);
return true; if (a?.lineHeight) parts.push(`line-height:${esc(String(a.lineHeight))}`);
} return parts.length ? ` style="${parts.join(";")}"` : "";
state.push("highlight_open", "mark", 1);
state.push("text", "", 0).content = content;
state.push("highlight_close", "mark", -1);
state.pos = end + 2;
return true;
}; };
md.inline.ruler.before("emphasis", "highlight", parse);
md.renderer.rules.highlight_open = () => "<mark>"; // Wrap a string with the formatting marks on a text node. Order matters: link
md.renderer.rules.highlight_close = () => "</mark>"; // is outermost, then font/size, then color/highlight, then underline/strike,
// then bold/italic, with code innermost.
function applyMarksHtml(text: string, marks: FolioMark[] = []): string {
let out = text;
const has = (t: string) => marks.some((m) => m.type === t);
if (has("code")) out = `<code>${out}</code>`;
if (has("italic")) out = `<em>${out}</em>`;
if (has("bold")) out = `<strong>${out}</strong>`;
if (has("strike")) out = `<s>${out}</s>`;
if (has("underline")) out = `<u>${out}</u>`;
const hl = marks.find((m) => m.type === "highlight");
if (hl) {
const color = hl.attrs?.color ? String(hl.attrs.color) : "";
out = color ? `<span style="background-color:${esc(color)}">${out}</span>` : `<mark>${out}</mark>`;
}
const tc = marks.find((m) => m.type === "textColor");
if (tc?.attrs?.color) out = `<span style="color:${esc(String(tc.attrs.color))}">${out}</span>`;
const ff = marks.find((m) => m.type === "fontFamily");
if (ff?.attrs?.fontFamily) out = `<span style="font-family:${esc(String(ff.attrs.fontFamily))}">${out}</span>`;
const fsz = marks.find((m) => m.type === "fontSize");
if (fsz?.attrs?.fontSize) out = `<span style="font-size:${esc(String(fsz.attrs.fontSize))}">${out}</span>`;
const link = marks.find((m) => m.type === "link");
if (link?.attrs?.href) {
const href = String(link.attrs.href);
const internal = href.startsWith("folio:chapter/");
const cls = internal ? ' class="wikilink internal"' : "";
out = `<a href="${esc(href)}"${cls}>${out}</a>`;
}
return out;
} }
const CALLOUT_RE = /^\s*\[!([a-zA-Z0-9_-]+)\]\s*(.*)$/; function inlineToHtml(nodes: FolioNode[] = []): string {
let out = "";
// > [!note] Title followed by "> body" lines becomes a styled callout div. for (const n of nodes) {
// Runs after inline parsing so blockquote paragraphs already have children. if (n.type === "text") out += applyMarksHtml(n.text ?? "", n.marks);
function calloutPlugin(md: MarkdownItInstance) { else if (n.type === "hard_break") out += "<br>";
md.core.ruler.after("inline", "callout", (state: StateCore) => { else if (n.type === "image") {
const tokens = state.tokens; const src = esc(String(n.attrs?.src ?? ""));
for (let i = 0; i < tokens.length; i++) { const alt = esc(String(n.attrs?.alt ?? ""));
const open = tokens[i]; out += `<img src="${src}" alt="${alt}">`;
if (open.type !== "blockquote_open") continue;
let depth = 1;
let closeIdx = -1;
for (let j = i + 1; j < tokens.length; j++) {
const t = tokens[j];
if (t.type === "blockquote_open") depth += 1;
else if (t.type === "blockquote_close") {
depth -= 1;
if (depth === 0) {
closeIdx = j;
break;
} }
} }
} return out;
if (closeIdx === -1) continue;
const paraOpen = tokens[i + 1];
if (!paraOpen || paraOpen.type !== "paragraph_open") continue;
const inline = tokens[i + 2];
if (!inline || inline.type !== "inline" || !inline.children) continue;
const first = inline.children[0];
if (!first || first.type !== "text") continue;
const m = CALLOUT_RE.exec(first.content);
if (!m) continue;
const calloutType = m[1].toLowerCase();
const title = m[2].trim();
open.type = "callout_open";
open.tag = "div";
open.attrs = [["class", `callout callout-${calloutType}`]];
const close = tokens[closeIdx];
close.type = "callout_close";
close.tag = "div";
// Strip the "[!type] Title" marker from the first paragraph and drop
// any text tokens that became empty as a result (plus the softbreak the
// marker line left behind).
first.content = "";
inline.children = inline.children.filter(
(c) => !(c.type === "text" && c.content === "")
);
if (inline.children[0]?.type === "softbreak") inline.children.shift();
if (title) {
const titleOpen = new state.Token("paragraph_open", "p", 1);
titleOpen.attrs = [["class", "callout-title"]];
const titleInline = new state.Token("inline", "", 0);
titleInline.content = title;
const titleText = new state.Token("text", "", 0);
titleText.content = title;
titleInline.children = [titleText];
const titleClose = new state.Token("paragraph_close", "p", -1);
tokens.splice(i + 1, 0, titleOpen, titleInline, titleClose);
}
}
});
md.renderer.rules.callout_open = (tokens, idx) =>
`<div class="${tokens[idx].attrGet("class") ?? "callout"}">`;
md.renderer.rules.callout_close = () => "</div>";
} }
export function renderPrintChapters( function blockToHtml(node: FolioNode): string {
chapters: string[], switch (node.type) {
targets: WikilinkTarget[] case "heading": {
): string { const level = Number(node.attrs?.level ?? 1);
const md = new MarkdownIt({ html: true }); return `<h${level}${ALIGN(node)}>${inlineToHtml(node.content)}</h${level}>`;
md.use(wikilinkPlugin, { chapters: targets }); }
md.use(highlightPlugin); case "paragraph": {
md.use(calloutPlugin); const isTitle = String(node.attrs?.class ?? "") === "callout-title";
return chapters const cls = isTitle ? ' class="callout-title"' : "";
.map((c) => `<section class="print-chapter">${md.render(c)}</section>`) return `<p${cls}${ALIGN(node)}>${inlineToHtml(node.content)}</p>`;
}
case "bulletList":
return `<ul>${listItemsToHtml(node.content)}</ul>`;
case "orderedList":
return `<ol>${listItemsToHtml(node.content)}</ol>`;
case "taskList":
return `<ul class="task-list">${listItemsToHtml(node.content, true)}</ul>`;
case "blockquote":
return calloutToHtml(node);
case "codeBlock": {
const text = (node.content ?? []).map((c) => c.text ?? "").join("");
return `<pre><code>${esc(text)}</code></pre>`;
}
case "horizontalRule":
return "<hr>";
case "table":
return tableToHtml(node);
case "image":
return `<img src="${esc(String(node.attrs?.src ?? ""))}" alt="${esc(String(node.attrs?.alt ?? ""))}">`;
default:
return "";
}
}
function listItemsToHtml(items: FolioNode[] = [], isTask = false): string {
return items
.map((item) => {
const checked = isTask && !!item.attrs?.checked;
const prefix = isTask ? (checked ? "&#9746; " : "&#9744; ") : "";
const inner = (item.content ?? []).map(blockToHtml).join("");
return `<li>${prefix}${inner}</li>`;
})
.join(""); .join("");
} }
function calloutToHtml(node: FolioNode): string {
const isCallout = !!node.attrs?.callout;
if (!isCallout) {
return `<blockquote>${node.content?.map(blockToHtml).join("") ?? ""}</blockquote>`;
}
const type = String(node.attrs?.callout);
return `<div class="callout callout-${esc(type)}">${
node.content?.map(blockToHtml).join("") ?? ""
}</div>`;
}
function tableToHtml(node: FolioNode): string {
const rows = (node.content ?? [])
.map((row) => {
const cells = (row.content ?? [])
.map((cell) => {
const tag = cell.type === "tableHeader" ? "th" : "td";
return `<${tag}>${inlineToHtml((cell.content?.[0]?.content ?? []) as FolioNode[])}</${tag}>`;
})
.join("");
return `<tr>${cells}</tr>`;
})
.join("");
return `<table class="folio-table"><tbody>${rows}</tbody></table>`;
}
export function renderPrintChaptersNative(doc: FolioNode): string {
const sections: string[] = [];
let current = "";
let started = false;
for (const node of doc.content ?? []) {
if (node.type === "chapterBreak") {
if (started || current) {
sections.push(`<section class="print-chapter">${current}</section>`);
}
current = "";
started = true;
continue;
}
current += blockToHtml(node);
}
if (started || current) {
sections.push(`<section class="print-chapter">${current}</section>`);
}
return sections.join("");
}

View file

@ -1,10 +1,10 @@
/* Folio themes. /* Folio themes.
Every interface color is driven by the semantic variables below; the UI and Every interface color is driven by the semantic variables below; the UI and
CodeMirror styles only ever reference these variables. A theme is therefore editor styles only ever reference these variables. A theme is therefore
just a block of variable values — adding one means dropping a just a block of variable values — adding one means dropping a
`:root[data-theme="..."]` block here plus an <option> in the theme selector. `:root[data-theme="..."]` block here plus an <option> in the theme selector.
Light is Folio's default: a warm, writing-focused light scheme. Neonflux is Light is Folio's default: a warm, writing-focused light scheme. Dark Mode is
a fixed dark, futuristic theme. */ a fixed dark, futuristic theme. */
/* ---- shared (non-color) tokens ---- */ /* ---- shared (non-color) tokens ---- */
@ -47,11 +47,11 @@
--color-scrollbar-hover: rgba(45, 40, 32, 0.38); --color-scrollbar-hover: rgba(45, 40, 32, 0.38);
} }
/* ---- Neonflux ---- /* ---- Dark Mode ----
Dark, high-contrast, futuristic. A near-black neutral base keeps the Dark, high-contrast, futuristic. A near-black neutral base keeps the
manuscript comfortable for long writing sessions while electric-cyan accent manuscript comfortable for long writing sessions while electric-cyan accent
marks active/selected/focused elements, links and controls. */ marks active/selected/focused elements, links and controls. */
:root[data-theme="neonflux"] { :root[data-theme="dark-mode"] {
--color-bg: #0a0d14; --color-bg: #0a0d14;
--color-panel: #121722; --color-panel: #121722;
--color-border: #24304a; --color-border: #24304a;
@ -64,8 +64,8 @@
--color-on-accent-soft: rgba(6, 19, 28, 0.18); --color-on-accent-soft: rgba(6, 19, 28, 0.18);
--color-on-accent-border: rgba(6, 19, 28, 0.38); --color-on-accent-border: rgba(6, 19, 28, 0.38);
--color-selection: rgba(34, 211, 238, 0.16); --color-selection: rgba(34, 211, 238, 0.55);
--color-selection-strong: rgba(34, 211, 238, 0.34); --color-selection-strong: rgba(34, 211, 238, 0.85);
--color-shadow: rgba(0, 0, 0, 0.55); --color-shadow: rgba(0, 0, 0, 0.55);
--color-shadow-strong: rgba(0, 0, 0, 0.65); --color-shadow-strong: rgba(0, 0, 0, 0.65);

View file

@ -0,0 +1,573 @@
// Tiptap 2 (ProseMirror) rich-text editor for Folio chapters.
//
// This is the native editor that replaces the Markdown/CodeMirror editing
// experience. The editor state is ProseMirror/Tiptap JSON and is persisted
// directly as the native Folio chapter document (see src/main/native-doc.ts).
// No Markdown is ever held as the editing model.
//
// Formatting features map to ProseMirror nodes/marks that are also produced by
// the Markdown -> native converter, so migrated chapters load and re-save
// losslessly.
import { Editor, Extension, Mark, Node, mergeAttributes } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline";
import Link from "@tiptap/extension-link";
import Image from "@tiptap/extension-image";
import TextAlign from "@tiptap/extension-text-align";
import Highlight from "@tiptap/extension-highlight";
import TaskList from "@tiptap/extension-task-list";
import TaskItem from "@tiptap/extension-task-item";
import Table from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableHeader from "@tiptap/extension-table-header";
import TableCell from "@tiptap/extension-table-cell";
import type { FolioChapterDoc, FolioNode } from "../main/native-doc.js";
// --- Custom marks matching the native Folio schema -------------------------
// The native model uses `textColor` (not Tiptap's `textStyle`) so the
// Markdown bridge (native-to-markdown) and the converter agree. Font family
// and size are stored as their own marks.
const TextColor = Mark.create({
name: "textColor",
addAttributes() {
return {
color: {
default: null,
parseHTML: (el) => (el as HTMLElement).style.color || null,
renderHTML: (attrs) =>
attrs.color ? { style: `color: ${attrs.color}` } : {},
},
};
},
parseHTML() {
return [{ style: "color" }];
},
renderHTML({ HTMLAttributes }) {
return ["span", mergeAttributes(HTMLAttributes), 0];
},
});
const FontFamily = Mark.create({
name: "fontFamily",
addAttributes() {
return {
fontFamily: {
default: null,
parseHTML: (el) => (el as HTMLElement).style.fontFamily || null,
renderHTML: (attrs) =>
attrs.fontFamily
? { style: `font-family: ${attrs.fontFamily}` }
: {},
},
};
},
parseHTML() {
return [{ style: "font-family" }];
},
renderHTML({ HTMLAttributes }) {
return ["span", mergeAttributes(HTMLAttributes), 0];
},
});
const FontSize = Mark.create({
name: "fontSize",
addAttributes() {
return {
fontSize: {
default: null,
parseHTML: (el) => (el as HTMLElement).style.fontSize || null,
renderHTML: (attrs) =>
attrs.fontSize ? { style: `font-size: ${attrs.fontSize}` } : {},
},
};
},
parseHTML() {
return [{ style: "font-size" }];
},
renderHTML({ HTMLAttributes }) {
return ["span", mergeAttributes(HTMLAttributes), 0];
},
});
// Line spacing is a block-level property (paragraphs and headings), so it is
// added as a global attribute on those nodes rather than a mark.
const LineHeight = Extension.create({
name: "lineHeight",
addGlobalAttributes() {
return [
{
types: ["paragraph", "heading"],
attributes: {
lineHeight: {
default: null,
parseHTML: (el: HTMLElement) => el.style.lineHeight || null,
renderHTML: (attrs: Record<string, any>) =>
attrs.lineHeight ? { style: `line-height: ${attrs.lineHeight}` } : {},
},
},
},
];
},
});
// Hard break. Registered as `hard_break` (snake_case) to match the rest of the
// pipeline (markdown-to-native, native-to-markdown, print/office export). StarterKit
// ships HardBreak as `hardBreak`, which would reject loaded docs containing a
// `hard_break` node and leave the editor empty, so we override it here.
const HardBreak = Node.create({
name: "hard_break",
inline: true,
group: "inline",
selectable: false,
parseHTML() {
return [{ tag: "br" }];
},
renderHTML() {
return ["br"];
},
addCommands() {
return {
setHardBreak:
() =>
({ chain }: any) =>
chain().insertContent({ type: "hard_break" }).run(),
};
},
addKeyboardShortcuts() {
return {
"Shift-Enter": () => this.editor.commands.setHardBreak(),
"Mod-Enter": () => this.editor.commands.setHardBreak(),
};
},
});
// Block separator rendered between chapters in the Full Book view. It carries
// the chapter id/title as data attributes so navigation can scroll to it via
// the DOM, and renders a subtle on-screen rule (except for the first chapter).
// It contributes no text, so word/character counts ignore it.
export const ChapterBreak = Node.create({
name: "chapterBreak",
group: "block",
atom: true,
selectable: false,
draggable: false,
addAttributes() {
return {
chapterId: { default: "" },
title: { default: "" },
first: { default: false },
};
},
parseHTML() {
return [{ tag: "div[data-chapter-id]" }];
},
renderHTML({ node, HTMLAttributes }) {
return [
"div",
mergeAttributes(HTMLAttributes, {
class: "chapter-break" + (node.attrs.first ? " first" : ""),
"data-chapter-id": node.attrs.chapterId as string,
"data-title": node.attrs.title as string,
"data-first": node.attrs.first ? "true" : "false",
}),
];
},
});
export interface FormatState {
bold: boolean;
italic: boolean;
underline: boolean;
strike: boolean;
code: boolean;
link: boolean;
heading: number; // 0 = paragraph
bullet: boolean;
ordered: boolean;
task: boolean;
blockquote: boolean;
align: string;
color: string;
highlight: string;
fontFamily: string;
fontSize: string;
lineHeight: string;
}
export interface TiptapCallbacks {
onChange?: () => void;
onSelectionUpdate?: () => void;
}
export interface TiptapOptions {
// Extra ProseMirror/Tiptap extensions (e.g. the Full Book ChapterBreak node).
extensions?: any[];
// Defaults to true (editable). Pass false for a read-only surface.
editable?: boolean;
}
// Clean office/HTML clipboard content so it pastes reliably. Office suites
// (OnlyOffice, Word, LibreOffice) put heavily-namespaced, style-laden HTML on
// the clipboard; ProseMirror's default paste often discards it entirely. We
// strip the office cruft but keep the real text and basic formatting.
function sanitizeClipboardHtml(html: string): string {
const doc = new DOMParser().parseFromString(html, "text/html");
doc
.querySelectorAll("script, style, meta, link, title, head, noscript")
.forEach((n) => n.remove());
const all = Array.from(doc.body.querySelectorAll("*"));
for (const el of all) {
// Unwrap namespaced (office) elements, keeping their content.
if (/:/.test(el.tagName)) {
const parent = el.parentNode;
if (parent) {
while (el.firstChild) parent.insertBefore(el.firstChild, el);
parent.removeChild(el);
}
continue;
}
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
if (
name === "style" ||
name === "class" ||
name === "id" ||
name.startsWith("data-") ||
name.startsWith("xmlns")
) {
el.removeAttribute(attr.name);
}
}
}
return doc.body.innerHTML;
}
// Pull just the visible text out of HTML (used as a last-resort paste fallback
// when an office clipboard provides HTML that ProseMirror can't parse but no
// usable text/plain).
function htmlToText(html: string): string {
const doc = new DOMParser().parseFromString(html, "text/html");
const text = doc.body.textContent || "";
return text.replace(/\r\n/g, "\n").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
}
export function createTiptapEditor(
host: HTMLElement,
callbacks: TiptapCallbacks = {},
options: TiptapOptions = {}
): Editor {
const editor = new Editor({
element: host,
editable: options.editable !== false,
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3, 4, 5, 6] },
hardBreak: false,
}),
Underline,
Link.configure({
openOnClick: false,
autolink: true,
HTMLAttributes: { rel: "noopener noreferrer" },
}),
Image.configure({ inline: false, allowBase64: false }),
TextAlign.configure({ types: ["heading", "paragraph"] }),
Highlight.configure({ multicolor: true }),
TaskList,
TaskItem.configure({ nested: true }),
Table.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell,
TextColor,
FontFamily,
FontSize,
LineHeight,
HardBreak,
...(options.extensions ?? []),
],
editorProps: {
attributes: {
class: "folio-prose",
spellcheck: "true",
},
},
onUpdate: () => callbacks.onChange?.(),
onSelectionUpdate: () => callbacks.onSelectionUpdate?.(),
});
// Robust paste. We handle the `paste` event ourselves (capture phase on
// document, before ProseMirror's own handler) and insert sanitized content
// via Tiptap directly. ProseMirror's default clipboard pipeline silently
// dropped Office/OnlyOffice HTML, and a handlePaste that re-invoked
// pasteHTML recursed and blocked all pasting — so we bypass it entirely.
const dom = editor.view.dom as HTMLElement;
const onPaste = (e: ClipboardEvent) => {
const t = e.target as globalThis.Node | null;
const inside = (t && dom.contains(t)) || document.activeElement === dom;
if (!inside) return; // let other fields (inputs, etc.) paste normally
const data = e.clipboardData;
if (!data) return;
const html = data.getData("text/html");
const text = data.getData("text/plain");
if (!html && !text) return;
e.preventDefault();
e.stopPropagation();
editor.commands.focus();
const insert = (content: string) => {
try {
editor.commands.insertContent(content);
} catch {
try {
editor.commands.insertContent(text || htmlToText(html));
} catch {
/* nothing we can do */
}
}
};
if (html) {
const clean = sanitizeClipboardHtml(html);
if (clean.replace(/<[^>]*>/g, "").trim().length > 0) {
insert(clean);
return;
}
}
if (text) {
insert(text);
return;
}
if (html) insert(htmlToText(html));
};
document.addEventListener("paste", onPaste, true);
const originalDestroy = editor.destroy.bind(editor);
editor.destroy = (...args: Parameters<typeof originalDestroy>) => {
document.removeEventListener("paste", onPaste, true);
return originalDestroy(...args);
};
return editor;
}
export function setEditorDoc(editor: Editor, doc: FolioNode): void {
// An empty document (no top-level blocks) leaves ProseMirror with nowhere to
// place the cursor, so brand-new/empty chapters can't be typed into or pasted
// until the user forces a block in. Seed one paragraph so the caret always
// has a home and paste works immediately.
const content = (doc as { content?: unknown[] } | null)?.content;
const safe =
content && Array.isArray(content) && content.length > 0
? doc
: ({ type: "doc", content: [{ type: "paragraph" }] } as unknown as FolioNode);
editor.commands.setContent(safe as unknown as Record<string, unknown>, false);
}
export function getEditorDoc(editor: Editor): FolioChapterDoc {
return {
schemaVersion: 1,
doc: editor.getJSON() as unknown as FolioNode,
};
}
export function getFormatState(editor: Editor): FormatState {
const a = (name: string) => editor.getAttributes(name);
let heading = 0;
for (let l = 1; l <= 6; l++) {
if (editor.isActive("heading", { level: l })) {
heading = l;
break;
}
}
return {
bold: editor.isActive("bold"),
italic: editor.isActive("italic"),
underline: editor.isActive("underline"),
strike: editor.isActive("strike"),
code: editor.isActive("code"),
link: editor.isActive("link"),
heading,
bullet: editor.isActive("bulletList"),
ordered: editor.isActive("orderedList"),
task: editor.isActive("taskList"),
blockquote: editor.isActive("blockquote"),
align: (a("textAlign").textAlign as string) || "",
color: (a("textColor").color as string) || "",
highlight: (a("highlight").color as string) || "",
fontFamily: (a("fontFamily").fontFamily as string) || "",
fontSize: (a("fontSize").fontSize as string) || "",
lineHeight: editor.isActive("heading")
? (a("heading").lineHeight as string) || ""
: (a("paragraph").lineHeight as string) || "",
};
}
// Apply a toolbar command by name. `payload` carries extra data (URLs, colors,
// table dimensions, etc.).
export function runCommand(
editor: Editor,
cmd: string,
payload: Record<string, unknown> = {}
): void {
const chain = () => editor.chain().focus();
switch (cmd) {
case "bold":
chain().toggleBold().run();
break;
case "italic":
chain().toggleItalic().run();
break;
case "underline":
chain().toggleUnderline().run();
break;
case "strike":
chain().toggleStrike().run();
break;
case "code":
chain().toggleCode().run();
break;
case "link": {
const href = String(payload.href ?? "").trim();
if (!href) chain().unsetLink().run();
else chain().extendMarkRange("link").setLink({ href }).run();
break;
}
case "hr":
chain().setHorizontalRule().run();
break;
case "bullet":
chain().toggleBulletList().run();
break;
case "number":
chain().toggleOrderedList().run();
break;
case "checklist":
chain().toggleTaskList().run();
break;
case "quote":
chain().toggleBlockquote().run();
break;
case "codeblock":
chain().toggleCodeBlock().run();
break;
case "paragraph":
chain().setParagraph().run();
break;
case "h1":
case "h2":
case "h3":
case "h4":
case "h5":
case "h6": {
const level = Number(cmd.slice(1)) as 1 | 2 | 3 | 4 | 5 | 6;
if (editor.isActive("heading", { level })) chain().setParagraph().run();
else chain().toggleHeading({ level }).run();
break;
}
case "align-left":
chain().setTextAlign("left").run();
break;
case "align-center":
chain().setTextAlign("center").run();
break;
case "align-right":
chain().setTextAlign("right").run();
break;
case "align-justify":
chain().setTextAlign("justify").run();
break;
case "indent":
chain().sinkListItem("listItem").run();
break;
case "outdent":
chain().liftListItem("listItem").run();
break;
case "clear":
chain().unsetAllMarks().run();
break;
case "color": {
const c = payload.color ? String(payload.color) : "";
if (c) chain().setMark("textColor", { color: c }).run();
else chain().unsetMark("textColor").run();
break;
}
case "highlight": {
const c = payload.color ? String(payload.color) : "";
if (c) chain().toggleHighlight({ color: c }).run();
else chain().unsetHighlight().run();
break;
}
case "fontfamily": {
const f = payload.fontFamily ? String(payload.fontFamily) : "";
if (f) chain().setMark("fontFamily", { fontFamily: f }).run();
else chain().unsetMark("fontFamily").run();
break;
}
case "fontsize": {
const s = payload.fontSize ? String(payload.fontSize) : "";
if (s) chain().setMark("fontSize", { fontSize: s }).run();
else chain().unsetMark("fontSize").run();
break;
}
case "linespacing": {
const lh = payload.lineHeight ? String(payload.lineHeight) : "";
const block = editor.isActive("heading") ? "heading" : "paragraph";
if (lh) chain().updateAttributes(block, { lineHeight: lh }).run();
else chain().updateAttributes(block, { lineHeight: null }).run();
break;
}
case "table":
chain()
.insertTable({
rows: Number(payload.rows ?? 3),
cols: Number(payload.cols ?? 2),
withHeaderRow: true,
})
.run();
break;
case "image": {
const src = String(payload.src ?? "");
if (src) {
chain()
.setImage({ src, alt: String(payload.alt ?? "") })
.run();
}
break;
}
case "wikilink": {
const href = String(payload.href ?? "");
const text = String(payload.text ?? href);
if (href) {
chain().insertContent({
type: "text",
text,
marks: [{ type: "link", attrs: { href } }],
}).run();
}
break;
}
case "addRowBefore":
chain().addRowBefore().run();
break;
case "addRowAfter":
chain().addRowAfter().run();
break;
case "addColumnBefore":
chain().addColumnBefore().run();
break;
case "addColumnAfter":
chain().addColumnAfter().run();
break;
case "deleteRow":
chain().deleteRow().run();
break;
case "deleteColumn":
chain().deleteColumn().run();
break;
case "deleteTable":
chain().deleteTable().run();
break;
default:
break;
}
}

View file

@ -0,0 +1,9 @@
// Headless automated test for the Tiptap editor integration (Stage 3).
// Verifies: editor command -> ProseMirror JSON state -> native persistence ->
// reload round-trip, plus loading a Markdown-migrated document into the editor.
//
// Run with: node tests/run-editor-test.mjs
export { createTiptapEditor, runCommand, getEditorDoc, setEditorDoc } from "../src/renderer/tiptap-editor.ts";
export { setChapterDoc, getChapterDoc, parseChapter, CURRENT_DOC_SCHEMA_VERSION } from "../src/main/native-doc.ts";
export { createBook, loadBook, saveMeta } from "../src/main/project.ts";
export { markdownToNative } from "../src/main/markdown-to-native.ts";

View file

@ -0,0 +1,14 @@
// Headless test entry for the native Full Book assembly + rendering.
// Run with: node tests/run-fullbook-test.mjs
export {
buildFullBookDoc,
nativeNodeText,
chapterHasOwnHeading,
} from "../src/renderer/native-assembly.ts";
export {
createTiptapEditor,
setEditorDoc,
getEditorDoc,
ChapterBreak,
} from "../src/renderer/tiptap-editor.ts";
export { CURRENT_DOC_SCHEMA_VERSION } from "../src/main/native-doc.ts";

View file

@ -1,18 +0,0 @@
#!/usr/bin/env sh
# End-to-end self-test runner (Linux, requires xvfb-run for headless).
# Creates a throwaway book, opens it via folio:openBookPath (no dialogs),
# runs the full chapter CRUD scenario through the real renderer -> IPC -> chapters
# chain, prints a JSON summary, and quits. Inert unless run explicitly.
set -e
BOOK=$(node -e 'const fs=require("fs"),path=require("path"),os=require("os");const d=fs.mkdtempSync(path.join(os.tmpdir(),"folio-e2e-"));fs.mkdirSync(path.join(d,"chapters"),{recursive:true});fs.writeFileSync(path.join(d,"folio.json"),JSON.stringify({app:"folio",version:1,title:"E2E Book",chapterOrder:[],chapters:{}}));process.stdout.write(d);')
BOOK2=$(node -e 'const fs=require("fs"),path=require("path"),os=require("os");const d=fs.mkdtempSync(path.join(os.tmpdir(),"folio-e2e2-"));fs.mkdirSync(path.join(d,"chapters"),{recursive:true});fs.writeFileSync(path.join(d,"folio.json"),JSON.stringify({app:"folio",version:1,title:"E2E Book 2",chapterOrder:[],chapters:{}}));process.stdout.write(d);')
BOOK3=$(node -e 'const fs=require("fs"),path=require("path"),os=require("os");const d=fs.mkdtempSync(path.join(os.tmpdir(),"folio-e2e3-"));fs.mkdirSync(path.join(d,"chapters"),{recursive:true});const titles=["One","Two","Three","Four"];const chapters={};const order=[];titles.forEach((t,i)=>{const id="ch"+(i+1);const file=path.posix.join("chapters",id+".md");order.push(id);chapters[id]={id,title:t,file};const body=Array.from({length:40},(_,p)=>`Paragraph ${p} of chapter ${t}: the quick brown fox jumps over the lazy dog while the whole manuscript stays deterministic and long enough to scroll.`).join("\n\n");fs.writeFileSync(path.join(d,file),`# ${t}\n\n${body}`);});fs.writeFileSync(path.join(d,"folio.json"),JSON.stringify({app:"folio",version:1,title:"Nav Book",chapterOrder:order,chapters}));process.stdout.write(d);')
export FOLIO_SELF_TEST=1
export FOLIO_TEST_BOOK="$BOOK"
export FOLIO_TEST_BOOK2="$BOOK2"
export FOLIO_TEST_BOOK3="$BOOK3"
export FOLIO_TEST_PDF="$BOOK/export-test.pdf"
export FOLIO_TEST_DOCX="$BOOK/export-test.docx"
export FOLIO_TEST_ODT="$BOOK/export-test.odt"
trap 'rm -rf "$BOOK" "$BOOK2" "$BOOK3"' EXIT
xvfb-run -a electron .

223
tests/run-editor-test.mjs Normal file
View file

@ -0,0 +1,223 @@
// Runner for the Tiptap editor test. Sets up a jsdom DOM, then dynamically
// imports the bundled editor module so Tiptap sees a browser-like environment.
import esbuild from "esbuild";
import fs from "fs";
import os from "os";
import path from "path";
import url from "url";
import { JSDOM } from "jsdom";
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
const { window } = dom;
const g = globalThis;
g.window = window;
g.document = window.document;
g.HTMLElement = window.HTMLElement;
g.Element = window.Element;
g.Node = window.Node;
g.MutationObserver = window.MutationObserver;
g.getComputedStyle = window.getComputedStyle.bind(window);
g.DOMParser = window.DOMParser;
g.requestAnimationFrame = (cb) => setTimeout(cb, 0);
g.cancelAnimationFrame = (id) => clearTimeout(id);
const out = path.join(os.tmpdir(), `folio-editor-${process.pid}.mjs`);
const entry = path.resolve("tests/.folio-editor-test-entry.ts");
await esbuild.build({
entryPoints: [entry],
bundle: true,
platform: "node",
format: "esm",
outfile: out,
write: true,
logLevel: "silent",
});
const mod = await import(url.pathToFileURL(out).href);
const {
createTiptapEditor,
runCommand,
getEditorDoc,
setEditorDoc,
setChapterDoc,
getChapterDoc,
parseChapter,
createBook,
saveMeta,
markdownToNative,
} = mod;
let failures = 0;
function ok(cond, msg) {
if (cond) console.log(" ok -", msg);
else {
failures++;
console.error("FAIL -", msg);
}
}
function findNodes(node, type, acc = []) {
if (!node || typeof node !== "object") return acc;
if (node.type === type) acc.push(node);
for (const c of node.content ?? []) findNodes(c, type, acc);
return acc;
}
function findMarks(node, type, acc = []) {
for (const m of node.marks ?? []) if (m.type === type) acc.push(m);
for (const c of node.content ?? []) findMarks(c, type, acc);
return acc;
}
function allText(node, acc = "") {
if (node.text) acc += node.text;
for (const c of node.content ?? []) acc = allText(c, acc);
return acc;
}
function makeEditor() {
const host = window.document.createElement("div");
window.document.body.appendChild(host);
return createTiptapEditor(host, {});
}
const book = fs.mkdtempSync(path.join(os.tmpdir(), "folio-ed-"));
const meta = createBook(book, "Editor Book");
const id = "chap-1";
meta.chapters[id] = { id, title: "Chapter One", file: `chapters/${id}.json` };
meta.chapterOrder = [id];
saveMeta(book, meta);
// --- editor command -> state (each feature on a fresh editor) ---
function editorDocAfter(commands) {
const ed = makeEditor();
for (const c of commands) {
if (typeof c === "function") c(ed);
else runCommand(ed, c.cmd, c.payload);
}
const d = getEditorDoc(ed);
return { ed, doc: d.doc };
}
let r;
r = editorDocAfter([{ cmd: "h2" }, (ed) => ed.commands.insertContent("Title")]);
ok(r.doc.type === "doc", "editor state is a ProseMirror doc");
ok(findNodes(r.doc, "heading").some((h) => h.attrs?.level === 2), "heading level 2 present");
ok(allText(r.doc).includes("Title"), "inserted heading text present");
r = editorDocAfter([{ cmd: "bullet" }, (ed) => ed.commands.insertContent("item one")]);
ok(findNodes(r.doc, "bulletList").length === 1, "bullet list present");
ok(allText(r.doc).includes("item one"), "bullet list text present");
r = editorDocAfter([
{ cmd: "bold" },
(ed) => ed.commands.insertContent("bolded"),
{ cmd: "color", payload: { color: "#ff0000" } },
(ed) => ed.commands.insertContent("red"),
]);
ok(findMarks(r.doc, "bold").length >= 1, "bold mark present");
ok(findMarks(r.doc, "textColor").some((m) => m.attrs?.color === "#ff0000"), "text color mark present");
r = editorDocAfter([
{ cmd: "table", payload: { rows: 2, cols: 2 } },
{ cmd: "image", payload: { src: "assets/x.png", alt: "x" } },
]);
ok(findNodes(r.doc, "table").length === 1, "table present");
ok(findNodes(r.doc, "image").length === 1, "image present");
ok(findNodes(r.doc, "image")[0]?.attrs?.src === "assets/x.png", "image src preserved");
// A richer document combining several features in one editor. Each block-level
// feature is placed in its own block so toggling a list does not consume the
// heading (which is the correct ProseMirror behavior).
const editor = makeEditor();
runCommand(editor, "h2");
editor.commands.insertContent("Hello world");
editor.commands.insertContent({ type: "paragraph" });
runCommand(editor, "bold");
editor.commands.insertContent("bolded");
runCommand(editor, "color", { color: "#ff0000" });
editor.commands.insertContent(" red");
editor.commands.insertContent({ type: "paragraph" });
runCommand(editor, "bullet");
editor.commands.insertContent("list item");
runCommand(editor, "table", { rows: 2, cols: 2 });
runCommand(editor, "image", { src: "assets/x.png", alt: "x" });
const doc = getEditorDoc(editor);
ok(allText(doc.doc).includes("Hello world"), "combined document has text");
ok(findNodes(doc.doc, "heading").some((h) => h.attrs?.level === 2), "combined: heading present");
ok(findMarks(doc.doc, "bold").length >= 1, "combined: bold present");
ok(findNodes(doc.doc, "bulletList").length >= 1, "combined: bullet list present");
ok(findMarks(doc.doc, "textColor").some((m) => m.attrs?.color === "#ff0000"), "combined: color present");
ok(findNodes(doc.doc, "table").length === 1, "combined: table present");
ok(findNodes(doc.doc, "image").length === 1, "combined: image present");
// --- state -> native persistence ---
setChapterDoc(book, id, doc);
const reloaded = getChapterDoc(book, id);
ok(reloaded.doc.type === "doc", "persisted chapter reads back as doc");
ok(
JSON.stringify(reloaded.doc) === JSON.stringify(doc.doc),
"reload is byte-identical to editor state"
);
// --- reload -> editor (round trip through a fresh editor) ---
const editor2 = makeEditor();
setEditorDoc(editor2, reloaded.doc);
const doc2 = getEditorDoc(editor2);
ok(
JSON.stringify(doc2.doc) === JSON.stringify(doc.doc),
"second editor reproduces same state"
);
// --- Markdown-migrated document loads into the editor ---
const migrated = markdownToNative("# Title\n\nSome **strong** text and a [link](https://e.com).");
const editor3 = makeEditor();
setEditorDoc(editor3, migrated.doc);
const doc3 = getEditorDoc(editor3);
ok(findNodes(doc3.doc, "heading").some((h) => h.attrs?.level === 1), "migrated heading loads");
ok(findMarks(doc3.doc, "bold").length === 1, "migrated bold loads");
ok(findMarks(doc3.doc, "link").some((m) => m.attrs?.href === "https://e.com"), "migrated link loads");
ok(allText(doc3.doc).includes("Title"), "migrated text loads");
// --- document containing a hard break loads (snake_case hard_break node) ---
// Regression: StarterKit registers HardBreak as `hardBreak`; a doc with a
// `hard_break` node (as produced by markdown-to-native and the export pipeline)
// must load and render its text rather than throwing and leaving the editor empty.
const withBreak = markdownToNative("Line one \nline two\n\nPara two");
const editor4 = makeEditor();
setEditorDoc(editor4, withBreak.doc);
const doc4 = getEditorDoc(editor4);
ok(editor4.getText().includes("Line one") && editor4.getText().includes("line two"),
"hard_break document renders its text (not empty)");
ok(findNodes(doc4.doc, "hard_break").length === 1, "hard_break node preserved on load");
// --- legacy camelCase hardBreak (from older editor saves) normalizes on read ---
const camel = {
schemaVersion: 1,
doc: {
type: "doc",
content: [
{ type: "paragraph", content: [
{ type: "text", text: "alpha" },
{ type: "hardBreak" },
{ type: "text", text: "beta" },
] },
],
},
};
const normalized = parseChapter(JSON.stringify(camel));
ok(findNodes(normalized.doc, "hard_break").length === 1, "parseChapter renames hardBreak -> hard_break");
const editor5 = makeEditor();
setEditorDoc(editor5, normalized.doc);
ok(editor5.getText().includes("alpha") && editor5.getText().includes("beta"),
"legacy camelCase hardBreak chapter loads (not empty)");
fs.rmSync(out, { force: true });
fs.rmSync(book, { recursive: true, force: true });
console.log(
"\nEditor tests: " + (failures === 0 ? "ALL PASSED" : failures + " FAILED")
);
process.exit(failures === 0 ? 0 : 1);

View file

@ -1,471 +0,0 @@
// Standalone test for the Markdown formatting commands (md-format.ts).
// Bundles with esbuild and drives the commands through a plain
// { state, dispatch } target, so no DOM is required.
// Run with: node tests/run-format-test.mjs
import esbuild from "esbuild";
import fs from "fs";
import os from "os";
import path from "path";
import url from "url";
// Write the bundle inside the repo so node can resolve the external
// @codemirror/state peer dependency (same instance as the one imported below).
const out = path.resolve(`tests/.tmp-folio-format-${process.pid}.mjs`);
await esbuild.build({
entryPoints: [path.resolve("src/renderer/md-format.ts")],
bundle: true,
platform: "node",
format: "esm",
external: ["@codemirror/state"],
outfile: out,
write: true,
logLevel: "silent",
});
const mod = await import(url.pathToFileURL(out).href);
const {
toggleBold,
toggleItalic,
toggleStrikethrough,
toggleInlineCode,
toggleCode,
toggleLink,
setHeading,
toggleBulletList,
toggleOrderedList,
toggleBlockquote,
insertHorizontalRule,
clearFormatting,
headingLevelAt,
toggleUnderline,
toggleHighlight,
toggleTextColor,
toggleHighlightColor,
removeColor,
removeHighlight,
toggleChecklist,
insertWikilink,
insertImage,
insertTable,
insertCallout,
toggleAlign,
toggleFencedCode,
detectFormatting,
applyFormatPainter,
} = mod;
// Build a minimal { state, dispatch } target around an EditorState.
import { EditorState } from "@codemirror/state";
function target(doc, anchor, head = anchor) {
let state = EditorState.create({
doc,
selection: { anchor, head },
});
const t = {
get state() {
return state;
},
dispatch(spec) {
state = state.update(spec).state;
return true;
},
};
return {
t,
text: () => state.doc.toString(),
sel: () => state.selection.main,
select: (a, h = a) => (state = state.update({ selection: { anchor: a, head: h } }).state),
};
}
let failures = 0;
function ok(cond, msg) {
if (cond) console.log(" ok -", msg);
else {
failures++;
console.error("FAIL -", msg);
}
}
function eq(actual, expected, msg) {
ok(actual === expected, `${msg} (got ${JSON.stringify(actual)}, want ${JSON.stringify(expected)})`);
}
// ---- bold ----
{
const { t, text, sel } = target("say hello world", 4, 9); // "hello"
toggleBold(t);
eq(text(), "say **hello** world", "bold wraps selection");
eq(sel().from, 6, "bold keeps selection on inner text (from)");
eq(sel().to, 11, "bold keeps selection on inner text (to)");
toggleBold(t); // cursor/selection still over inner text -> unwrap
eq(text(), "say hello world", "bold toggles back off");
}
// ---- bold with empty cursor ----
{
const { t, text, sel } = target("abc", 1);
toggleBold(t);
eq(text(), "a****bc", "bold on empty cursor inserts marker pair");
eq(sel().from, 3, "bold cursor placed between markers");
toggleBold(t);
eq(text(), "abc", "bold on cursor toggles back off");
}
// ---- bold unwrap when whole span selected ----
{
const { t, text, sel } = target("x **bold** y", 2, 10);
toggleBold(t);
eq(text(), "x bold y", "bold unwraps when selection includes markers");
eq(sel().from, 2, "unwrapped selection anchor");
eq(sel().to, 6, "unwrapped selection head");
}
// ---- italic must not unwrap bold markers ----
{
const { t, text } = target("**bold**", 2, 6); // inside bold
toggleItalic(t);
eq(text(), "***bold***", "italic inside bold produces bold+italic, not a broken span");
const { t: t2, text: text2 } = target("*em*", 1, 3); // inside italic
toggleItalic(t2);
eq(text2(), "em", "italic toggles off when already inside italic markers");
const { t: t3, text: text3 } = target("*em*", 0, 4); // whole italic span
toggleItalic(t3);
eq(text3(), "em", "italic unwraps whole span");
}
// ---- strikethrough ----
{
const { t, text, sel } = target("strike this text", 0, 6);
toggleStrikethrough(t);
eq(text(), "~~strike~~ this text", "strikethrough wraps selection");
eq(sel().from, 2, "strikethrough selection from");
toggleStrikethrough(t);
eq(text(), "strike this text", "strikethrough toggles back off");
}
// ---- inline code ----
{
const { t, text } = target("run `npm` and code here", 5, 8);
toggleInlineCode(t);
eq(text(), "run npm and code here", "inline code toggles off when already in a code span");
const { t: t2, text: text2 } = target("var x = 1;", 0, 9);
toggleInlineCode(t2);
eq(text2(), "`var x = 1`;", "inline code wraps normal text");
}
// ---- links ----
{
const { t, text, sel } = target("visit example", 6, 13);
toggleLink(t);
eq(text(), "visit [example](url)", "link wraps selection with url placeholder");
eq(sel().from, 16, "link selects url placeholder (from)");
eq(sel().to, 19, "link selects url placeholder (to)");
}
{
const { t, text, sel } = target("ab", 1);
toggleLink(t);
eq(text(), "a[](url)b", "link on empty cursor inserts empty label");
eq(sel().from, 2, "link cursor inside label brackets");
}
{
const { t, text, sel } = target("see [docs](old-url) here", 4, 19);
toggleLink(t);
eq(text(), "see [docs](old-url) here", "link edit does not change text");
eq(sel().from, 11, "link edit selects url (from)");
eq(sel().to, 18, "link edit selects url (to)");
}
// ---- code: inline vs fenced ----
{
const { t, text } = target("var x = 1;", 0, 9);
toggleCode(t);
eq(text(), "`var x = 1`;", "code on single line uses inline markers");
}
{
const { t, text, select } = target("line one\nline two", 0, 16);
toggleCode(t);
eq(text(), "```\nline one\nline two\n```", "code on multiple lines uses a fenced block");
select(0, 25);
toggleCode(t);
eq(text(), "line one\nline two", "fenced code toggles back off");
}
{
const { t, text } = target("before\n```\ncode a\ncode b\n```\nafter", 7, 24);
toggleCode(t);
eq(text(), "before\ncode a\ncode b\nafter", "fenced code unwraps when selection starts at the fence");
}
{
const { t, text } = target("abc", 1);
toggleCode(t);
eq(text(), "a``bc", "code on empty cursor inserts inline markers");
}
// ---- headings ----
{
const { t, text } = target("Chapter one", 0);
setHeading(2)(t);
eq(text(), "## Chapter one", "setHeading adds level 2 marker");
setHeading(3)(t);
eq(text(), "### Chapter one", "setHeading changes existing level");
setHeading(3)(t);
eq(text(), "### Chapter one", "setHeading same level is a no-op");
setHeading(0)(t);
eq(text(), "Chapter one", "setHeading paragraph clears heading");
}
{
const { t } = target("# Title", 3);
ok(headingLevelAt(t.state, 3) === 1, "headingLevelAt detects level 1");
const { t: t2 } = target("plain", 2);
ok(headingLevelAt(t2.state, 2) === 0, "headingLevelAt returns 0 for plain line");
}
// ---- bullet list ----
{
const { t, text } = target("one\ntwo\nthree", 0, 13);
toggleBulletList(t);
eq(text(), "- one\n- two\n- three", "bullet list prefixes each line");
toggleBulletList(t);
eq(text(), "one\ntwo\nthree", "bullet list toggles back off");
}
{
const { t, text } = target("already listed", 0);
toggleBulletList(t);
eq(text(), "- already listed", "bullet list works with cursor only");
}
// ---- numbered list ----
{
const { t, text } = target("one\ntwo", 0, 7);
toggleOrderedList(t);
eq(text(), "1. one\n2. two", "numbered list numbers each line");
toggleOrderedList(t);
eq(text(), "one\ntwo", "numbered list toggles back off");
}
// ---- blockquote ----
{
const { t, text } = target("quoted words", 0);
toggleBlockquote(t);
eq(text(), "> quoted words", "blockquote prefixes line");
toggleBlockquote(t);
eq(text(), "quoted words", "blockquote toggles back off");
}
// ---- horizontal rule ----
{
const { t, text, sel } = target("", 0);
insertHorizontalRule(t);
eq(text(), "---\n\n", "hr inserts at doc start");
eq(sel().from, 5, "hr cursor after rule");
}
{
const { t, text } = target("para\n\nnext", 5); // start of the empty line
insertHorizontalRule(t);
eq(text(), "para\n\n---\n\nnext", "hr inserts on empty line");
}
{
const { t, text } = target("mid | line", 4);
insertHorizontalRule(t);
eq(text(), "mid \n\n---\n\n| line", "hr splits a line in the middle");
}
// ---- clear formatting ----
{
const { t, text } = target("## **Bold** and ~~strike~~", 0, 26);
clearFormatting(t);
eq(text(), "Bold and strike", "clear formatting strips heading, bold, strike");
}
{
const { t, text } = target("- item\n> quote\n1. thing\n`code` and [label](https://x.com)", 0, 57);
clearFormatting(t);
eq(text(), "item\nquote\nthing\ncode and label", "clear formatting handles lists, quote, code, links");
}
{
const { t, text } = target("==hi== and <u>under</u> and <span style=\"color:#e11d48\">red</span>", 0, 64);
clearFormatting(t);
eq(text(), "hi and under and red", "clear formatting strips highlight, underline, color");
}
// ---- underline ----
{
const { t, text, sel } = target("some text", 5, 9);
toggleUnderline(t);
eq(text(), "some <u>text</u>", "underline wraps selection");
eq(sel().from, 8, "underline selection from");
toggleUnderline(t);
eq(text(), "some text", "underline toggles back off");
}
{
const { t, text } = target("a <u>mid</u> b", 6); // cursor inside <u>…</u>
toggleUnderline(t);
eq(text(), "a mid b", "underline unwraps when cursor is inside");
}
{
const { t, text, sel } = target("ab", 1);
toggleUnderline(t);
eq(text(), "a<u></u>b", "underline on empty cursor inserts tag pair");
eq(sel().from, 4, "underline cursor between tags");
}
// ---- highlight (Obsidian ==) ----
{
const { t, text } = target("mark this", 0, 4);
toggleHighlight(t);
eq(text(), "==mark== this", "highlight wraps selection");
toggleHighlight(t);
eq(text(), "mark this", "highlight toggles back off");
}
// ---- text color ----
{
const { t, text, sel } = target("red text", 0, 3);
toggleTextColor("#e11d48")(t);
eq(text(), '<span style="color:#e11d48">red</span> text', "text color wraps selection");
eq(sel().from, 28, "text color keeps selection on text");
toggleTextColor("#e11d48")(t);
eq(text(), "red text", "text color toggles off with same color");
}
{
const { t, text } = target("change color", 0, 6);
toggleTextColor("#e11d48")(t);
toggleTextColor("#3b82f6")(t);
eq(text(), '<span style="color:#3b82f6">change</span> color', "text color replaces existing color");
}
// ---- highlight color ----
{
const { t, text } = target("mark this", 0, 4);
toggleHighlightColor("#fde047")(t);
eq(text(), '<span style="background-color:#fde047">mark</span> this', "highlight color wraps selection");
removeHighlight(t);
eq(text(), "mark this", "removeHighlight strips background color");
}
{
const { t, text } = target("==plain==", 0, 8);
removeHighlight(t);
eq(text(), "plain", "removeHighlight strips Obsidian markers");
}
{
const { t, text } = target("colored", 0, 7);
toggleTextColor("#e11d48")(t);
removeColor(t);
eq(text(), "colored", "removeColor strips text color");
}
// ---- checklist ----
{
const { t, text } = target("buy milk\nwrite note", 0, 19);
toggleChecklist(t);
eq(text(), "- [ ] buy milk\n- [ ] write note", "checklist prefixes lines");
toggleChecklist(t);
eq(text(), "- [x] buy milk\n- [x] write note", "checklist toggles to checked");
toggleChecklist(t);
eq(text(), "- [ ] buy milk\n- [ ] write note", "checklist toggles back unchecked");
}
{
const { t, text } = target("1. numbered item", 0, 16);
toggleChecklist(t);
eq(text(), "- [ ] numbered item", "checklist converts numbered item");
}
// ---- wikilink ----
{
const { t, text, sel } = target("see intro", 4, 9);
insertWikilink("Introduction")(t);
eq(text(), "see [[Introduction]]", "wikilink inserts target");
eq(sel().from, 20, "wikilink cursor after insertion");
}
{
const { t, text } = target("see intro", 4, 9);
insertWikilink("Introduction", "intro")(t);
eq(text(), "see [[Introduction|intro]]", "wikilink with alias label");
}
// ---- image ----
{
const { t, text, sel } = target("before", 6);
insertImage("alt", "https://x.com/a.png")(t);
eq(text(), "before![alt](https://x.com/a.png)", "image inserts markdown");
eq(sel().from, 13, "image selects url start");
eq(sel().to, 32, "image selects url end");
}
// ---- table ----
{
const { t, text } = target("para", 4);
insertTable(3, 2)(t);
eq(
text(),
"para\n| Col 1 | Col 2 |\n| --- | --- |\n| | |\n| | |\n",
"table inserts header, separator, body rows"
);
}
// ---- callout ----
{
const { t, text } = target("body here", 0);
insertCallout("note", "Tip")(t);
eq(text(), "> [!note] Tip\n> body here", "callout inserts Obsidian blockquote syntax");
}
// ---- alignment ----
{
const { t, text } = target("centered line", 0);
toggleAlign("center")(t);
eq(text(), '<div align="center">centered line</div>', "align wraps paragraph in HTML div");
toggleAlign("center")(t);
eq(text(), "centered line", "align toggles back off");
toggleAlign("center")(t);
toggleAlign("right")(t);
eq(text(), '<div align="right">centered line</div>', "align replaces alignment");
toggleAlign("left")(t);
eq(text(), "centered line", "align left clears alignment");
}
// ---- fenced code block ----
{
const { t, text } = target("single line", 0);
toggleFencedCode(t);
eq(text(), "```\nsingle line\n```", "code block wraps single line");
toggleFencedCode(t);
eq(text(), "single line", "code block toggles off");
}
// ---- detectFormatting ----
{
const { t } = target("**bold** and *italic* and <u>under</u> and ==hl==", 6);
const f = detectFormatting(t.state, 6);
ok(f.bold === true, "detectFormatting finds bold");
ok(f.italic === false, "detectFormatting bold not italic");
}
{
const { t } = target("# Heading\nplain", 3);
ok(detectFormatting(t.state, 3).heading === 1, "detectFormatting finds heading");
}
{
const { t } = target("- [ ] task", 3);
ok(detectFormatting(t.state, 3).list === "checklist", "detectFormatting finds checklist");
}
{
const { t } = target("- item", 3);
ok(detectFormatting(t.state, 3).list === "bullet", "detectFormatting finds bullet list");
}
{
const { t } = target("1. item", 3);
ok(detectFormatting(t.state, 3).list === "ordered", "detectFormatting finds ordered list");
}
// ---- format painter ----
{
const { t, text } = target("**bold source** plain target", 0, 16);
const fmt = { ...detectFormatting(t.state, 8), bold: true };
const { t: t2, text: text2 } = target("**bold source** plain target", 16, 28);
applyFormatPainter(t2, fmt);
eq(text2(), "**bold source** **plain target**", "format painter applies bold");
}
fs.rmSync(out, { force: true });
console.log("\nFormat tests: " + (failures === 0 ? "ALL PASSED" : failures + " FAILED"));
process.exit(failures === 0 ? 0 : 1);

195
tests/run-fullbook-test.mjs Normal file
View file

@ -0,0 +1,195 @@
// Runner for the native Full Book test. Builds a jsdom DOM, bundles the
// renderer assembly + Tiptap modules, and verifies that a book assembled from
// native chapters renders with correct chapter order, page-break anchors,
// reorder behavior, title injection, and Copy-All native fidelity.
import esbuild from "esbuild";
import fs from "fs";
import os from "os";
import path from "path";
import url from "url";
import { JSDOM } from "jsdom";
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
const { window } = dom;
const g = globalThis;
g.window = window;
g.document = window.document;
g.HTMLElement = window.HTMLElement;
g.Element = window.Element;
g.Node = window.Node;
g.MutationObserver = window.MutationObserver;
g.getComputedStyle = window.getComputedStyle.bind(window);
g.DOMParser = window.DOMParser;
g.requestAnimationFrame = (cb) => setTimeout(cb, 0);
g.cancelAnimationFrame = (id) => clearTimeout(id);
const out = path.join(os.tmpdir(), `folio-fullbook-${process.pid}.mjs`);
const entry = path.resolve("tests/.folio-fullbook-test-entry.ts");
await esbuild.build({
entryPoints: [entry],
bundle: true,
platform: "node",
format: "esm",
outfile: out,
write: true,
logLevel: "silent",
});
const mod = await import(url.pathToFileURL(out).href);
const {
buildFullBookDoc,
nativeNodeText,
createTiptapEditor,
setEditorDoc,
getEditorDoc,
ChapterBreak,
CURRENT_DOC_SCHEMA_VERSION,
} = mod;
let failures = 0;
function ok(cond, msg) {
if (cond) console.log(" ok -", msg);
else {
failures++;
console.error("FAIL -", msg);
}
}
function findNodes(node, type, acc = []) {
if (!node || typeof node !== "object") return acc;
if (node.type === type) acc.push(node);
for (const c of node.content ?? []) findNodes(c, type, acc);
return acc;
}
// A native chapter doc with a single paragraph of the given text.
function paraDoc(text) {
return {
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
};
}
function headingDoc(text) {
return {
type: "doc",
content: [{ type: "heading", attrs: { level: 1 }, content: [{ type: "text", text }] }],
};
}
function makeFullBookEditor() {
const host = window.document.createElement("div");
window.document.body.appendChild(host);
return createTiptapEditor(host, { onChange: () => undefined }, { editable: false, extensions: [ChapterBreak] });
}
// --- canonical chapter order / navigation anchors ---
const chapters = [
{ id: "c1", title: "One", markdown: "Body one", doc: paraDoc("Body one") },
{ id: "c2", title: "Two", markdown: "Body two", doc: paraDoc("Body two") },
{ id: "c3", title: "Three", markdown: "Body three", doc: paraDoc("Body three") },
];
const assembled = buildFullBookDoc(chapters);
ok(assembled.chapterEntries.length === 3, "three chapter entries produced");
ok(
assembled.chapterEntries.map((c) => c.id).join(",") === "c1,c2,c3",
"chapter order follows canonical chapterOrder"
);
ok(
findNodes(assembled.nativeDoc, "chapterBreak").length === 3,
"one chapterBreak anchor per chapter"
);
ok(
findNodes(assembled.nativeDoc, "chapterBreak")[0].attrs.first === true,
"first chapter break is marked first"
);
ok(
findNodes(assembled.nativeDoc, "chapterBreak").slice(1).every((n) => n.attrs.first === false),
"subsequent chapter breaks are not first (page breaks)"
);
// Render into the read-only editor and check DOM anchors in order.
const editor = makeFullBookEditor();
setEditorDoc(editor, assembled.nativeDoc);
const anchors = Array.from(editor.view.dom.querySelectorAll("[data-chapter-id]"));
ok(anchors.length === 3, "three DOM anchors rendered");
ok(
anchors.map((a) => a.getAttribute("data-chapter-id")).join(",") === "c1,c2,c3",
"DOM anchors preserve canonical order"
);
ok(anchors[0].classList.contains("first"), "first anchor carries first class");
ok(
anchors.slice(1).every((a) => !a.classList.contains("first")),
"subsequent anchors are plain chapter-break separators"
);
// --- reorder impact on Full Book output ---
const reordered = buildFullBookDoc([chapters[1], chapters[0], chapters[2]]);
const reAnchors = Array.from(
(() => {
const e = makeFullBookEditor();
setEditorDoc(e, reordered.nativeDoc);
return e.view.dom;
})().querySelectorAll("[data-chapter-id]")
);
ok(
reAnchors.map((a) => a.getAttribute("data-chapter-id")).join(",") === "c2,c1,c3",
"reordering chapterOrder reorders Full Book output"
);
// --- title injection fidelity ---
const withHeading = buildFullBookDoc([
{ id: "h1", title: "Title", markdown: "# Title\nrest", doc: headingDoc("Title") },
]);
ok(
findNodes(withHeading.nativeDoc, "heading").length === 1,
"chapter already starting with matching heading is not duplicated"
);
const withoutHeading = buildFullBookDoc([
{ id: "h2", title: "Title", markdown: "body", doc: paraDoc("body") },
]);
const heads = findNodes(withoutHeading.nativeDoc, "heading");
ok(heads.length === 1 && heads[0].attrs.level === 1, "missing title heading is injected as H1");
ok(
nativeNodeText(withoutHeading.nativeDoc).includes("Title"),
"injected title text appears in the assembled book"
);
// --- Copy All native fidelity (round trip) ---
const copyJson = JSON.stringify(getEditorDoc(editor));
const parsed = JSON.parse(copyJson);
const firstDoc = getEditorDoc(editor).doc;
ok(parsed.schemaVersion === CURRENT_DOC_SCHEMA_VERSION, "copied native doc has schema version");
ok(
findNodes(parsed.doc, "chapterBreak").length === 3,
"copied native doc preserves chapterBreak anchors"
);
const editor2 = makeFullBookEditor();
setEditorDoc(editor2, parsed.doc);
ok(
JSON.stringify(getEditorDoc(editor2).doc) === JSON.stringify(firstDoc),
"copied native doc reloads byte-identical to editor state"
);
ok(
nativeNodeText(parsed.doc).includes("Body one") &&
nativeNodeText(parsed.doc).includes("Body three"),
"copied native content preserves chapter text"
);
// --- word / char counts ---
const text = nativeNodeText(assembled.nativeDoc);
ok(text.includes("Body one") && text.includes("Body three"), "word/char source includes all chapters");
ok(text.split(/\s+/).filter(Boolean).length >= 3, "word count reflects chapter text");
// --- empty book ---
const empty = buildFullBookDoc([]);
ok(
empty.nativeDoc.type === "doc" && Array.isArray(empty.nativeDoc.content) && empty.nativeDoc.content.length === 0,
"empty book assembles to empty doc"
);
fs.rmSync(out, { force: true });
console.log("\nFull Book tests: " + (failures === 0 ? "ALL PASSED" : failures + " FAILED"));
process.exit(failures === 0 ? 0 : 1);

View file

@ -0,0 +1,303 @@
// Standalone test for Stage 2 of the rich-text migration:
// - native document model (Tiptap/ProseMirror JSON)
// - Markdown -> native conversion (headings, bold, italic, strike, code,
// lists, checklists, tables, images, links, wikilinks, callouts,
// highlight, blockquote, hr)
// - native persistence + schema versioning
// - migration + external recovery backup
// - idempotency (original .md untouched)
// Builds src/main/migration.ts with esbuild (no Electron needed).
// Run with: node tests/run-migration-test.mjs
import esbuild from "esbuild";
import fs from "fs";
import os from "os";
import path from "path";
import url from "url";
const out = path.join(os.tmpdir(), `folio-migration-${process.pid}.mjs`);
const entry = path.resolve(".folio-mig-test-entry.ts");
fs.writeFileSync(
entry,
`
export { migrateChapterToNative, migrateBookToNative, isNativeChapter } from "./src/main/migration.ts";
export { createBook, loadBook, saveMeta } from "./src/main/project.ts";
export { setChapterContent, getChapterContent } from "./src/main/chapters.ts";
export { markdownToNative } from "./src/main/markdown-to-native.ts";
export { parseChapter, serializeChapter, migrateDoc, getChapterDoc, nativeChapterFile, CURRENT_DOC_SCHEMA_VERSION, isValidDoc } from "./src/main/native-doc.ts";
export { RECOVERY_ROOT, _resetSessionBackupGuard } from "./src/main/backup.ts";
`
);
await esbuild.build({
entryPoints: [entry],
bundle: true,
platform: "node",
format: "esm",
outfile: out,
write: true,
logLevel: "silent",
});
fs.rmSync(entry, { force: true });
const mod = await import(url.pathToFileURL(out).href);
const {
createBook,
setChapterContent,
getChapterContent,
loadBook,
saveMeta,
migrateChapterToNative,
migrateBookToNative,
isNativeChapter,
getChapterDoc,
markdownToNative,
parseChapter,
serializeChapter,
migrateDoc,
CURRENT_DOC_SCHEMA_VERSION,
RECOVERY_ROOT,
_resetSessionBackupGuard,
} = mod;
let failures = 0;
function ok(cond, msg) {
if (cond) console.log(" ok -", msg);
else {
failures++;
console.error("FAIL -", msg);
}
}
function findNodes(node, type, acc = []) {
if (!node || typeof node !== "object") return acc;
if (node.type === type) acc.push(node);
for (const c of node.content ?? []) findNodes(c, type, acc);
return acc;
}
function findMark(node, type, acc = []) {
for (const m of node.marks ?? []) if (m.type === type) acc.push(m);
for (const c of node.content ?? []) findMark(c, type, acc);
return acc;
}
function allText(node, acc = "") {
if (node.text) acc += node.text;
for (const c of node.content ?? []) acc = allText(c, acc);
return acc;
}
// ---------------------------------------------------------------------------
// 1. Direct Markdown -> native conversion
// ---------------------------------------------------------------------------
const md = `# Title
A paragraph with **bold**, *italic*, ~~strike~~, \`code\` and ==highlight==.
<u>underline</u> and <span style="color:red">red</span> and <span style="background-color:#ff0">yellow bg</span>.
> [!note] Callout title
> Callout body text.
> A plain blockquote.
- bullet one
- bullet two
1. first
2. second
- [ ] todo
- [x] done
| Name | Age |
| ---- | --- |
| Ada | 36 |
![cat](https://example.com/cat.png)
A [link](https://example.com) and a [[Other Chapter]] wikilink.
\`\`\`js
const x = 1;
\`\`\`
---
Final paragraph.
`;
const doc = markdownToNative(md, {
targets: [{ id: "other-chapter-1", title: "Other Chapter" }],
});
ok(doc.schemaVersion === CURRENT_DOC_SCHEMA_VERSION, "converted doc has current schema version");
ok(doc.doc.type === "doc", "doc root is type 'doc'");
ok(findNodes(doc.doc, "heading").length === 1, "one heading converted");
ok(findNodes(doc.doc, "heading")[0]?.attrs?.level === 1, "heading is level 1");
ok(findNodes(doc.doc, "paragraph").length >= 5, "paragraphs converted");
ok(findMark(doc.doc, "bold").length === 1, "bold mark present");
ok(findMark(doc.doc, "italic").length === 1, "italic mark present");
ok(findMark(doc.doc, "strike").length === 1, "strike mark present");
ok(findMark(doc.doc, "code").length === 1, "inline code mark present");
ok(findMark(doc.doc, "highlight").length >= 2, "highlight marks present (== and bg span)");
ok(findMark(doc.doc, "textColor").length === 1, "textColor mark present");
ok(findMark(doc.doc, "underline").length === 1, "underline mark present");
ok(findNodes(doc.doc, "blockquote").length === 2, "two blockquotes (callout + quote)");
ok(
findNodes(doc.doc, "blockquote").some((b) => b.attrs?.callout === "note"),
"callout preserved as blockquote with callout type"
);
ok(allText(doc.doc).includes("Callout title"), "callout title preserved");
ok(findNodes(doc.doc, "bulletList").length === 1, "bullet list converted");
ok(findNodes(doc.doc, "orderedList").length === 1, "ordered list converted");
ok(findNodes(doc.doc, "taskList").length === 1, "checklist converted to taskList");
const checked = findNodes(doc.doc, "taskItem").filter((n) => n.attrs?.checked === true);
ok(checked.length === 1, "exactly one checklist item marked checked");
ok(findNodes(doc.doc, "table").length === 1, "table converted");
ok(findNodes(doc.doc, "tableRow").length === 2, "table has two rows");
ok(findNodes(doc.doc, "tableHeader").length === 2, "table header cells converted");
ok(findNodes(doc.doc, "image").length === 1, "image converted");
ok(
findNodes(doc.doc, "image")[0]?.attrs?.src === "https://example.com/cat.png",
"external image src kept as-is"
);
const links = findMark(doc.doc, "link");
ok(links.length === 2, "two links (standard + wikilink)");
ok(
links.some((l) => l.attrs?.href === "https://example.com"),
"standard link href preserved"
);
ok(
links.some((l) => l.attrs?.href === "folio:chapter/other-chapter-1"),
"wikilink resolved to folio:chapter/<id>"
);
ok(findNodes(doc.doc, "codeBlock").length === 1, "fenced code block converted");
ok(
findNodes(doc.doc, "codeBlock")[0]?.attrs?.language === "js",
"code block language preserved"
);
ok(findNodes(doc.doc, "horizontalRule").length === 1, "horizontal rule converted");
// ---------------------------------------------------------------------------
// 2. Round-trip serialize / parse / migrate
// ---------------------------------------------------------------------------
const reparsed = parseChapter(serializeChapter(doc));
ok(reparsed.schemaVersion === CURRENT_DOC_SCHEMA_VERSION, "reparsed doc keeps schema version");
ok(reparsed.doc.type === "doc", "reparsed doc root intact");
ok(
JSON.stringify(reparsed.doc) === JSON.stringify(doc.doc),
"serialize -> parse is lossless"
);
const oldDoc = { schemaVersion: 0, doc: { type: "doc", content: [] } };
const migrated = migrateDoc(oldDoc);
ok(migrated.schemaVersion === CURRENT_DOC_SCHEMA_VERSION, "migrateDoc bumps old schema version");
// ---------------------------------------------------------------------------
// 3. End-to-end migration of a real book (backup + persistence + idempotency)
// ---------------------------------------------------------------------------
const book = fs.mkdtempSync(path.join(os.tmpdir(), "folio-test-"));
_resetSessionBackupGuard();
const meta = createBook(book, "Migration Book");
const c1 = "chapter-one-1111";
const c2 = "chapter-two-2222";
meta.chapters[c1] = { id: c1, title: "Chapter One", file: `chapters/${c1}.md` };
meta.chapters[c2] = { id: c2, title: "Chapter Two", file: `chapters/${c2}.md` };
meta.chapterOrder = [c1, c2];
saveMeta(book, meta);
fs.mkdirSync(path.join(book, "chapters"), { recursive: true });
const md1 = "# One\n\nHello **world**.";
const md2 = "## Two\n\n- a\n- b\n\n![pic](https://example.com/p.png)";
fs.writeFileSync(path.join(book, `chapters/${c1}.md`), md1, "utf-8");
fs.writeFileSync(path.join(book, `chapters/${c2}.md`), md2, "utf-8");
const before1 = fs.readFileSync(path.join(book, `chapters/${c1}.md`), "utf-8");
const before2 = fs.readFileSync(path.join(book, `chapters/${c2}.md`), "utf-8");
const results = migrateBookToNative(book);
ok(
results.every((r) => r.status === "migrated"),
"both chapters migrated"
);
ok(fs.existsSync(path.join(book, `chapters/${c1}.json`)), "chapter json written");
ok(fs.existsSync(path.join(book, `chapters/${c2}.json`)), "chapter json written (2)");
ok(
fs.readFileSync(path.join(book, `chapters/${c1}.md`), "utf-8") === before1,
"ORIGINAL .md left untouched (1)"
);
ok(
fs.readFileSync(path.join(book, `chapters/${c2}.md`), "utf-8") === before2,
"ORIGINAL .md left untouched (2)"
);
const metaAfter = loadBook(book);
ok(metaAfter.docFormat === "folio-doc", "book marked as folio-doc");
ok(metaAfter.docSchemaVersion === CURRENT_DOC_SCHEMA_VERSION, "book has doc schema version");
ok(metaAfter.chapters[c1].file.endsWith(".json"), "chapter entry repointed to .json");
ok(isNativeChapter(book, c1), "isNativeChapter true after migration");
const readBack = getChapterDoc(book, c1);
ok(readBack.doc.type === "doc", "migrated chapter reads back as native doc");
ok(
findMark(readBack.doc, "bold").length === 1,
"migrated chapter preserved bold"
);
// Recovery backup exists under ~/Folio-Recovery and contains folio.json
const bookName = path.basename(book);
const backups = fs
.readdirSync(RECOVERY_ROOT)
.filter((d) => d.startsWith(bookName) && fs.statSync(path.join(RECOVERY_ROOT, d)).isDirectory());
ok(backups.length >= 1, "recovery backup created under ~/Folio-Recovery");
const backupDir = path.join(RECOVERY_ROOT, backups[0]);
ok(fs.existsSync(path.join(backupDir, "folio.json")), "backup contains folio.json");
ok(
fs.existsSync(path.join(backupDir, "chapters", `${c1}.md`)),
"backup preserves original .md"
);
// Idempotency: re-migrating does not rewrite .md and reports already-native
const results2 = migrateBookToNative(book);
ok(
results2.every((r) => r.status === "already-native"),
"re-migration is idempotent (already-native)"
);
ok(
fs.readFileSync(path.join(book, `chapters/${c1}.md`), "utf-8") === before1,
"ORIGINAL .md still untouched after re-migration"
);
// Image asset copy: a local image is copied into assets/ and rewritten.
const c3 = "chapter-three-3333";
const meta3 = loadBook(book);
meta3.chapters[c3] = { id: c3, title: "Chapter Three", file: `chapters/${c3}.md` };
meta3.chapterOrder.push(c3);
saveMeta(book, meta3);
fs.writeFileSync(path.join(book, `chapters/${c3}.md`), "![x](img/local.png)", "utf-8");
fs.mkdirSync(path.join(book, "img"), { recursive: true });
fs.writeFileSync(path.join(book, "img", "local.png"), "PNGDATA", "utf-8");
_resetSessionBackupGuard();
const r3 = migrateChapterToNative(book, c3);
ok(r3.status === "migrated", "third chapter (local image) migrated");
const doc3 = getChapterDoc(book, c3);
const img3 = findNodes(doc3.doc, "image")[0];
ok(
img3?.attrs?.src?.startsWith("assets/"),
"local image reference rewritten to assets/"
);
ok(
fs.existsSync(path.join(book, img3.attrs.src)),
"local image copied into assets/"
);
// ---------------------------------------------------------------------------
// Cleanup
// ---------------------------------------------------------------------------
fs.rmSync(out, { force: true });
fs.rmSync(book, { recursive: true, force: true });
for (const d of backups) {
fs.rmSync(path.join(RECOVERY_ROOT, d), { recursive: true, force: true });
}
console.log(
"\nMigration tests: " + (failures === 0 ? "ALL PASSED" : failures + " FAILED")
);
process.exit(failures === 0 ? 0 : 1);

View file

@ -1,7 +1,8 @@
// Standalone test for the DOCX/ODT Full Book export (office-export.ts). // Standalone test for the DOCX/ODT Full Book export (office-export.ts).
// Bundles the main-process module with esbuild and drives buildDocx/buildOdt // Bundles the main-process module with esbuild and drives buildDocx/buildOdt
// against a throwaway book, then inspects the produced ZIP contents to check // against a throwaway book whose chapters are stored as native Folio document
// that the manuscript, its chapter order, and its formatting survive. // JSON, then inspects the produced ZIP contents to check that the manuscript,
// its chapter order, and its formatting survive the native -> DOCX path.
// Run with: node tests/run-office-export-test.mjs // Run with: node tests/run-office-export-test.mjs
import esbuild from "esbuild"; import esbuild from "esbuild";
import fs from "fs"; import fs from "fs";
@ -30,68 +31,131 @@ const check = (name, ok) => {
if (!ok) failures += 1; if (!ok) failures += 1;
}; };
// --- fixture book ----------------------------------------------------------- // --- fixture book (native JSON chapters) ------------------------------------
const bookDir = fs.mkdtempSync(path.join(os.tmpdir(), "folio-office-test-")); const bookDir = fs.mkdtempSync(path.join(os.tmpdir(), "folio-office-test-"));
fs.mkdirSync(path.join(bookDir, "chapters"), { recursive: true }); fs.mkdirSync(path.join(bookDir, "chapters"), { recursive: true });
const chapters = {}; const chapters = {};
const order = []; const order = [];
const addChapter = (id, title, body) => {
const file = path.posix.join("chapters", `${id}.md`); // Build a native chapter file ({ schemaVersion, doc }).
const addChapter = (id, title, content) => {
const file = path.posix.join("chapters", `${id}.json`);
order.push(id); order.push(id);
chapters[id] = { id, title, file }; chapters[id] = { id, title, file };
fs.writeFileSync(path.join(bookDir, file), body); fs.writeFileSync(
path.join(bookDir, file),
JSON.stringify({ schemaVersion: 1, doc: { type: "doc", content } }, null, 2)
);
}; };
addChapter( // Inline helpers --------------------------------------------------------------
"ch1", const text = (t, marks = []) => ({ type: "text", text: t, marks });
"Alpha", const para = (content, attrs = {}) => ({ type: "paragraph", attrs, content });
[ const heading = (level, t) => ({ type: "heading", attrs: { level }, content: [text(t)] });
"# Alpha", const listItem = (content) => ({ type: "listItem", content });
"",
"**bold** *italic* ~~strike~~ ==highlight== <u>under</u>", addChapter("ch1", "Alpha", [
"", heading(1, "Alpha"),
'<span style="color:#e11d48">red</span> <span style="background-color:#fde047">marked</span>', para([
"", text("bold", [{ type: "bold" }]),
"- bullet one", text(" "),
"- bullet two", text("italic", [{ type: "italic" }]),
" - nested bullet", text(" "),
"", text("strike", [{ type: "strike" }]),
"1. numbered one", text(" "),
"2. numbered two", text("highlight", [{ type: "highlight" }]),
"", text(" "),
"> [!note] Callout title", text("under", [{ type: "underline" }]),
"> callout body", ]),
"", para([
"> plain quote", text("red", [{ type: "textColor", attrs: { color: "#e11d48" } }]),
"", text(" "),
"```js", text("marked", [{ type: "highlight", attrs: { color: "#fde047" } }]),
"const answer = 42;", ]),
"```", {
"", type: "bulletList",
"---", content: [
"", listItem([para([text("bullet one")])]),
"| Head A | Head B |", listItem([
"| --- | --- |", para([text("bullet two")]),
"| cell 1 | cell 2 |", {
"", type: "bulletList",
"[Folio](https://example.com)", content: [listItem([para([text("nested bullet")])])],
"", },
"[[Beta|Go to Beta]]", ]),
"", ],
'<div align="center">centered text</div>', },
].join("\n") {
type: "orderedList",
content: [
listItem([para([text("numbered one")])]),
listItem([para([text("numbered two")])]),
],
},
{
type: "blockquote",
attrs: { callout: "note" },
content: [
para([text("Callout title")], { class: "callout-title" }),
para([text("callout body")]),
],
},
{
type: "blockquote",
content: [para([text("plain quote")])],
},
{ type: "codeBlock", attrs: { language: "js" }, content: [text("const answer = 42;")] },
{ type: "horizontalRule" },
{
type: "table",
content: [
{
type: "tableRow",
content: [
{ type: "tableHeader", content: [para([text("Head A")])] },
{ type: "tableHeader", content: [para([text("Head B")])] },
],
},
{
type: "tableRow",
content: [
{ type: "tableCell", content: [para([text("cell 1")])] },
{ type: "tableCell", content: [para([text("cell 2")])] },
],
},
],
},
para([text("Folio", [{ type: "link", attrs: { href: "https://example.com" } }])]),
para([text("Go to Beta", [{ type: "link", attrs: { href: "folio:chapter/Beta" } }])]),
para([text("centered text")], { textAlign: "center" }),
]);
addChapter("ch2", "Beta", [para([text("This chapter has no heading of its own.")])]);
addChapter("ch3", "Gamma", [
heading(1, "Gamma"),
heading(2, "Subsection"),
para([text("trailing paragraph")]),
]);
addChapter("ch4", "Empty", []);
addChapter("ch5", "Unicode", [para([text("Café — naïve naïve 中文 📖")])]);
// Real image file so the DOCX embedding path (ImageRun) is exercised: a
// relative src must resolve against the book directory and the bytes survive.
fs.mkdirSync(path.join(bookDir, "assets"), { recursive: true });
fs.writeFileSync(
path.join(bookDir, "assets", "pic.png"),
Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "base64")
); );
addChapter("ch2", "Beta", "This chapter has no heading of its own."); addChapter("ch6", "Pics", [{ type: "image", attrs: { src: "assets/pic.png", alt: "a picture" } }]);
addChapter("ch3", "Gamma", `# Gamma\n\n## Subsection\n\ntrailing paragraph`);
addChapter("ch4", "Empty", "");
addChapter("ch5", "Unicode", "Café — naïve naïve 中文 📖");
fs.writeFileSync( fs.writeFileSync(
path.join(bookDir, "folio.json"), path.join(bookDir, "folio.json"),
JSON.stringify({ app: "folio", version: 1, title: "Office Test Book", chapterOrder: order, chapters }) JSON.stringify({ app: "folio", version: 1, title: "Office Test Book", chapterOrder: order, chapters })
); );
const origFiles = order.map((id) => chapters[id].file).map((f) => [f, fs.readFileSync(path.join(bookDir, f))]); const origFiles = order
.map((id) => chapters[id].file)
.map((f) => [f, fs.readFileSync(path.join(bookDir, f))]);
const origManifest = fs.readFileSync(path.join(bookDir, "folio.json")); const origManifest = fs.readFileSync(path.join(bookDir, "folio.json"));
const docxBuf = await mod.buildDocx(bookDir); const docxBuf = await mod.buildDocx(bookDir);
@ -113,11 +177,15 @@ const idxAlpha = docxText.indexOf("Alpha");
const idxBeta = docxText.indexOf("Beta"); const idxBeta = docxText.indexOf("Beta");
const idxGamma = docxText.indexOf("Gamma"); const idxGamma = docxText.indexOf("Gamma");
const idxUnicode = docxText.indexOf("Unicode"); const idxUnicode = docxText.indexOf("Unicode");
const idxPics = docxText.indexOf("Pics");
check( check(
"docx keeps chapter order", "docx keeps chapter order",
idxAlpha >= 0 && idxAlpha < idxBeta && idxBeta < idxGamma && idxGamma < idxUnicode idxAlpha >= 0 && idxAlpha < idxBeta && idxBeta < idxGamma && idxGamma < idxUnicode && idxUnicode < idxPics
); );
const mediaFiles = Object.keys(docxZip.files).filter((f) => f.startsWith("word/media/"));
check("docx embeds the image (no data loss)", mediaFiles.length >= 1);
check("docx has bold", /<w:b\/>/.test(docxXml)); check("docx has bold", /<w:b\/>/.test(docxXml));
check("docx has italic", /<w:i\/>/.test(docxXml)); check("docx has italic", /<w:i\/>/.test(docxXml));
check("docx has strike", /<w:strike\/>/.test(docxXml)); check("docx has strike", /<w:strike\/>/.test(docxXml));
@ -140,8 +208,8 @@ check("docx has 1.7 line spacing", /<w:spacing[^>]*w:line="408"[^>]*w:lineRule="
check("docx has blank-line paragraph spacing", /<w:spacing[^>]*w:after="408"/.test(docxXml)); check("docx has blank-line paragraph spacing", /<w:spacing[^>]*w:after="408"/.test(docxXml));
check("docx has chapter break before later chapters", (docxXml.match(/w:before="320"/g) || []).length >= 4); check("docx has chapter break before later chapters", (docxXml.match(/w:before="320"/g) || []).length >= 4);
check("docx has callout shading", /w:fill="F5F2ED"/.test(docxXml)); check("docx has callout shading", /w:fill="F5F2ED"/.test(docxXml));
check("docx renders ==highlight== (no raw markup)", !docxText.includes("==highlight==")); check("docx renders highlight (no raw markup)", !docxText.includes("==highlight=="));
check("docx renders **bold** (no raw markup)", !docxText.includes("**bold**")); check("docx renders bold (no raw markup)", !docxText.includes("**bold**"));
check("docx resolves wikilink label", docxText.includes("Go to Beta")); check("docx resolves wikilink label", docxText.includes("Go to Beta"));
check("docx has no raw wikilink brackets", !docxText.includes("[[Beta")); check("docx has no raw wikilink brackets", !docxText.includes("[[Beta"));
check("docx headingless chapter gets title", docxText.includes("Beta") && docxText.includes("no heading")); check("docx headingless chapter gets title", docxText.includes("Beta") && docxText.includes("no heading"));

145
tests/run-print-test.mjs Normal file
View file

@ -0,0 +1,145 @@
// Standalone test for the native -> print HTML renderer (print-format.ts).
// This is the path the PDF export uses (#printBook innerHTML). It bundles the
// renderer module and verifies that chapter breaks become print sections and
// that every rich-text feature survives the native -> HTML conversion.
// Run with: node tests/run-print-test.mjs
import esbuild from "esbuild";
import fs from "fs";
import os from "os";
import path from "path";
import url from "url";
const out = path.resolve(`tests/.tmp-folio-print-${process.pid}.mjs`);
await esbuild.build({
entryPoints: [path.resolve("src/renderer/print-format.ts")],
bundle: true,
platform: "node",
format: "esm",
external: ["electron"],
outfile: out,
write: true,
logLevel: "silent",
});
const mod = await import(url.pathToFileURL(out).href);
const renderPrintChaptersNative = mod.renderPrintChaptersNative;
let failures = 0;
const check = (name, ok) => {
console.log(`${ok ? "PASS" : "FAIL"} ${name}`);
if (!ok) failures += 1;
};
// --- native document with chapter breaks + every formatting feature ---------
const text = (t, marks = []) => ({ type: "text", text: t, marks });
const para = (content, attrs = {}) => ({ type: "paragraph", attrs, content });
const heading = (level, t) => ({ type: "heading", attrs: { level }, content: [text(t)] });
const listItem = (content) => ({ type: "listItem", content });
const doc = {
type: "doc",
content: [
{ type: "chapterBreak", attrs: { chapterId: "c1", title: "One", first: true } },
heading(1, "One"),
para([
text("bold", [{ type: "bold" }]),
text(" "),
text("italic", [{ type: "italic" }]),
text(" "),
text("underline", [{ type: "underline" }]),
text(" "),
text("hl", [{ type: "highlight" }]),
text(" "),
text("red", [{ type: "textColor", attrs: { color: "#e11d48" } }]),
text(" "),
text("mk", [{ type: "highlight", attrs: { color: "#fde047" } }]),
]),
para([text("center")], { textAlign: "center" }),
{ type: "bulletList", content: [listItem([para([text("bullet one")])])] },
{
type: "orderedList",
content: [
listItem([para([text("numbered one")])]),
listItem([para([text("numbered two")])]),
],
},
{
type: "taskList",
content: [
{ type: "taskItem", attrs: { checked: true }, content: [para([text("done task")])] },
{ type: "taskItem", attrs: { checked: false }, content: [para([text("todo task")])] },
],
},
{
type: "blockquote",
attrs: { callout: "note" },
content: [
para([text("Callout title")], { class: "callout-title" }),
para([text("callout body")]),
],
},
{ type: "codeBlock", attrs: { language: "js" }, content: [text("const answer = 42;")] },
{ type: "horizontalRule" },
{
type: "table",
content: [
{
type: "tableRow",
content: [
{ type: "tableHeader", content: [para([text("Head A")])] },
{ type: "tableHeader", content: [para([text("Head B")])] },
],
},
{
type: "tableRow",
content: [
{ type: "tableCell", content: [para([text("cell 1")])] },
{ type: "tableCell", content: [para([text("cell 2")])] },
],
},
],
},
{ type: "image", attrs: { src: "assets/pic.png", alt: "a picture" } },
para([text("Site", [{ type: "link", attrs: { href: "https://example.com" } }])]),
para([text("Go", [{ type: "link", attrs: { href: "folio:chapter/Two" } }])]),
{ type: "chapterBreak", attrs: { chapterId: "c2", title: "Two", first: false } },
heading(1, "Two"),
para([text("second chapter body")]),
],
};
const html = renderPrintChaptersNative(doc);
check("has two print-chapter sections", (html.match(/<section class="print-chapter">/g) || []).length === 2);
const iOne = html.indexOf("One");
const iTwo = html.indexOf("Two");
check("chapters appear in order", iOne >= 0 && iOne < iTwo);
check("first section contains chapter one", html.indexOf("<section") < iOne && html.indexOf("second chapter body") > iOne);
check("renders h1", /<h1>One<\/h1>/.test(html));
check("renders bold", /<strong>bold<\/strong>/.test(html));
check("renders italic", /<em>italic<\/em>/.test(html));
check("renders underline", /<u>underline<\/u>/.test(html));
check("renders highlight as mark", /<mark>hl<\/mark>/.test(html));
check("renders text color span", /<span style="color:#e11d48">red<\/span>/.test(html));
check("renders highlight color span", /<span style="background-color:#fde047">mk<\/span>/.test(html));
check("renders centered paragraph", /<p style="text-align:center">center<\/p>/.test(html));
check("renders bullet list", /<ul>[\s\S]*bullet one[\s\S]*<\/ul>/.test(html));
check("renders ordered list", /<ol>[\s\S]*numbered one[\s\S]*numbered two[\s\S]*<\/ol>/.test(html));
check("renders task list with checkboxes", html.includes("&#9746;") && html.includes("done task") && html.includes("&#9744;") && html.includes("todo task"));
check("renders callout", /<div class="callout callout-note">.*<p class="callout-title">Callout title<\/p>/s.test(html));
check("renders code block", /<pre><code>const answer = 42;<\/code><\/pre>/.test(html));
check("renders horizontal rule", /<hr>/.test(html));
check("renders table with header + cells", /<table class="folio-table">.*<th>Head A<\/th>.*<td>cell 1<\/td>.*<\/table>/s.test(html));
check("renders image", /<img src="assets\/pic.png" alt="a picture">/.test(html));
check("renders external link", /<a href="https:\/\/example.com">Site<\/a>/.test(html));
check("renders wikilink as internal anchor", /<a href="folio:chapter\/Two" class="wikilink internal">Go<\/a>/.test(html));
check("no raw wikilink brackets", !html.includes("[[Two"));
fs.rmSync(out, { force: true });
if (failures > 0) {
console.error(`\n${failures} check(s) failed.`);
process.exit(1);
}
console.log("\nAll print-format checks passed.");

View file

@ -1,966 +0,0 @@
/* Selftest scenario - runs inside the renderer via executeJavaScript.
Injected only when FOLIO_SELF_TEST=1. Exercises the full
renderer -> preload -> IPC -> chapters chain. */
const BOOK_DIR = __BOOKDIR_VALUE__;
const BOOK2_DIR = __BOOKDIR2_VALUE__;
const BOOK3_DIR = __BOOKDIR3_VALUE__;
(async () => {
const out = {};
try {
out.folioType = typeof window.folio;
out.recentsCount = (await window.folio.getRecents()).length;
out.openBook = await window.folio.openBookPath(BOOK_DIR);
out.metaTitle = (await window.folio.getBookMeta()).title;
const created = await window.folio.createChapter("Introduction");
out.created = created && !created.error ? { id: created.id, title: created.title } : created;
const cid = created.id;
out.contentEmpty = await window.folio.getChapterContent(cid);
out.listLen1 = (await window.folio.getChapterList()).length;
out.renamed = await window.folio.renameChapter(cid, "Intro");
const dup = await window.folio.duplicateChapter(cid);
out.dup = dup && !dup.error ? { id: dup.id, title: dup.title } : dup;
const did = dup.id;
out.listLen2 = (await window.folio.getChapterList()).length;
out.deleted = await window.folio.deleteChapter(cid);
const remaining = (await window.folio.getChapterList())[0];
out.listLen3 = (await window.folio.getChapterList()).length;
out.reorderOk = await window.folio.reorderChapters([remaining.id]);
out.reorderErr = await window.folio.reorderChapters(["bogus-id"]);
out.dupContent = await window.folio.getChapterContent(did);
// --- drag & drop reorder test ---
const a = (await window.folio.createChapter("Alpha")).id;
await new Promise((r) => setTimeout(r, 80));
const b = (await window.folio.createChapter("Bravo")).id;
await new Promise((r) => setTimeout(r, 80));
const c = (await window.folio.createChapter("Charlie")).id;
await new Promise((r) => setTimeout(r, 200));
const orderBefore = (await window.folio.getBookMeta()).chapterOrder;
out.orderBeforeDnD = orderBefore;
const rows = document.getElementById("chapterList").querySelectorAll(".chapter-row");
out.dndRowCount = rows.length;
const draggedId = rows[0].dataset.id;
const targetId = rows[2].dataset.id;
const dt = new DataTransfer();
rows[0].dispatchEvent(new DragEvent("dragstart", { dataTransfer: dt, bubbles: true }));
rows[2].dispatchEvent(new DragEvent("dragover", { dataTransfer: dt, bubbles: true }));
rows[2].dispatchEvent(new DragEvent("drop", { dataTransfer: dt, bubbles: true }));
rows[0].dispatchEvent(new DragEvent("dragend", { dataTransfer: dt, bubbles: true }));
await new Promise((r) => setTimeout(r, 250));
const orderAfter = (await window.folio.getBookMeta()).chapterOrder;
out.orderAfterDnD = orderAfter;
out.dndMoved =
orderAfter[0] === orderBefore[1] &&
orderAfter[2] === orderBefore[0] &&
orderAfter[3] === orderBefore[3];
// keyboard reorder: select first visible row, Alt+ArrowDown moves it down
const fresh1 = document.getElementById("chapterList").querySelectorAll(".chapter-row");
fresh1[0].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await new Promise((r) => setTimeout(r, 120));
const fresh2 = document.getElementById("chapterList").querySelectorAll(".chapter-row");
fresh2[0].focus();
fresh2[0].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", altKey: true, bubbles: true }));
await new Promise((r) => setTimeout(r, 250));
const orderKb = (await window.folio.getBookMeta()).chapterOrder;
out.orderAfterKb = orderKb;
out.kbMoved = orderKb[0] === orderAfter[1] && orderKb[1] === orderAfter[0];
// UI-driven chapter creation via the in-app modal (window.prompt is unsupported)
const countBefore = (await window.folio.getBookMeta()).chapterOrder.length;
document.getElementById("addChapterBtn").click();
await new Promise((r) => setTimeout(r, 80));
const modalInput = document.getElementById("modalInput");
out.modalShown = !!modalInput && !document.getElementById("modalBackdrop").classList.contains("hidden");
modalInput.value = "Via Modal";
document.getElementById("modalOk").click();
await new Promise((r) => setTimeout(r, 300));
const metaAfterUi = await window.folio.getBookMeta();
out.uiCreated = metaAfterUi.chapterOrder.length === countBefore + 1 &&
Object.values(metaAfterUi.chapters).some((c) => c.title === "Via Modal");
// CodeMirror editor: typing, cursor/selection, autosave persistence
const cm = window.__folioEditor;
out.hasEditor = !!cm;
out.editorLoaded = cm ? cm.state.doc.toString() : null;
if (cm) {
cm.dispatch({
changes: {
from: 0,
to: cm.state.doc.length,
insert: "# Heading One\n\n**bold** and *italic* text\n\n- bullet one\n- bullet two\n\n> quoted line\n\n[link](https://example.com) and `inline code`\n\n---",
},
});
cm.dispatch({ selection: { anchor: cm.state.doc.length } });
}
await new Promise((r) => setTimeout(r, 1200));
out.editorDocAfterType = cm ? cm.state.doc.toString() : null;
const typedId = metaAfterUi.chapterOrder[metaAfterUi.chapterOrder.length - 1];
const typedContent = await window.folio.getChapterContent(typedId);
out.editorAutosaved =
!!cm &&
cm.state.doc.toString().includes("**bold** and *italic*") &&
typedContent.includes("# Heading One") &&
typedContent.includes("- bullet two") &&
typedContent.includes("> quoted line") &&
typedContent.includes("[link](https://example.com)") &&
typedContent.includes("`inline code`");
// --- formatting toolbar + keyboard shortcuts ---
if (cm) {
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" }, selection: { anchor: 0 } });
cm.focus();
cm.dispatch({ changes: { from: 0, to: 0, insert: "Hello World" }, selection: { anchor: 0, head: 5 } });
document.querySelector('[data-cmd="bold"]').click();
await new Promise((r) => setTimeout(r, 50));
out.toolbarBoldOk = cm.state.doc.toString() === "**Hello** World";
out.focusAfterBold = document.activeElement === cm.contentDOM;
cm.dispatch({ selection: { anchor: 10, head: 15 } }); // "World"
cm.contentDOM.dispatchEvent(
new KeyboardEvent("keydown", { key: "b", ctrlKey: true, bubbles: true, cancelable: true })
);
await new Promise((r) => setTimeout(r, 50));
out.shortcutBoldOk = cm.state.doc.toString() === "**Hello** **World**";
const headingSel = document.getElementById("headingSelect");
headingSel.value = "2";
headingSel.dispatchEvent(new Event("change", { bubbles: true }));
await new Promise((r) => setTimeout(r, 50));
out.headingOk = cm.state.doc.toString().startsWith("## ");
document.querySelector('[data-cmd="undo"]').click();
await new Promise((r) => setTimeout(r, 50));
out.undoOk = cm.state.doc.toString() === "**Hello** **World**";
document.querySelector('[data-cmd="redo"]').click();
await new Promise((r) => setTimeout(r, 50));
out.redoOk = cm.state.doc.toString().startsWith("## ");
cm.dispatch({ selection: { anchor: 15, head: 20 } }); // "World"
cm.contentDOM.dispatchEvent(
new KeyboardEvent("keydown", {
key: "s",
ctrlKey: true,
shiftKey: true,
bubbles: true,
cancelable: true,
})
);
await new Promise((r) => setTimeout(r, 50));
out.strikeShortcutOk = cm.state.doc.toString().includes("~~World~~");
cm.dispatch({
changes: { from: 0, to: 0, insert: "line one\nline two\n" },
selection: { anchor: 0, head: 16 },
});
document.querySelector('[data-cmd="codeblock"]').click();
await new Promise((r) => setTimeout(r, 50));
out.fencedCodeOk = cm.state.doc.toString().startsWith("```\nline one\nline two\n```");
// underline shortcut (Ctrl+U)
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
cm.dispatch({ changes: { from: 0, to: 0, insert: "Under me" }, selection: { anchor: 0, head: 5 } });
cm.contentDOM.dispatchEvent(
new KeyboardEvent("keydown", { key: "u", ctrlKey: true, bubbles: true, cancelable: true })
);
await new Promise((r) => setTimeout(r, 50));
out.underlineOk = cm.state.doc.toString().startsWith("<u>Under</u>");
// task list via toolbar
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
cm.dispatch({ changes: { from: 0, to: 0, insert: "task one\ntask two" }, selection: { anchor: 0, head: 8 } });
document.querySelector('[data-cmd="checklist"]').click();
await new Promise((r) => setTimeout(r, 50));
out.checklistOk = cm.state.doc.toString().startsWith("- [ ] task one");
// highlight via toolbar
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
cm.dispatch({ changes: { from: 0, to: 0, insert: "cell text" }, selection: { anchor: 0, head: 4 } });
document.querySelector('[data-cmd="highlight"]').click();
await new Promise((r) => setTimeout(r, 50));
out.highlightOk = cm.state.doc.toString().startsWith("==cell==");
// table via toolbar dropdown config
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
document.getElementById("tableRows").value = "3";
document.getElementById("tableCols").value = "2";
document.querySelector('[data-action="table"]').click();
await new Promise((r) => setTimeout(r, 50));
out.tableOk = cm.state.doc.toString().startsWith("| Col 1 | Col 2 |\n| --- | --- |");
// format painter
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
cm.dispatch({ changes: { from: 0, to: 0, insert: "**src** dst" }, selection: { anchor: 3, head: 3 } });
document.querySelector('[data-cmd="painter"]').click();
cm.dispatch({ selection: { anchor: 8, head: 11 } });
document.querySelector('[data-cmd="painter"]').click();
await new Promise((r) => setTimeout(r, 50));
out.painterOk = cm.state.doc.toString() === "**src** **dst**";
// text color via the toolbar dropdown.
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
cm.dispatch({ changes: { from: 0, to: 0, insert: "color me" }, selection: { anchor: 0, head: 5 } });
cm.focus();
const colorToggle = document.querySelector('[data-dropdown="colorMenu"]');
colorToggle?.click();
await new Promise((r) => setTimeout(r, 50));
const colorMenuEl = document.getElementById("colorMenu");
const colorRect = colorMenuEl?.getBoundingClientRect();
out.colorMenuOpens =
!!colorRect && !colorMenuEl?.classList.contains("hidden") && colorRect.bottom <= window.innerHeight + 1;
// the dropdown is pinned to the viewport, so it must stay on screen even
// though the toolbar itself is a horizontal scroll container
out.colorMenuOnScreen = !!colorRect && colorRect.top >= 0 && colorRect.bottom <= window.innerHeight;
document.querySelector('[data-cmd="color:#e11d48"]').click();
await new Promise((r) => setTimeout(r, 50));
out.colorApplied = cm.state.doc.toString() === '<span style="color:#e11d48">color</span> me';
// custom color picker works
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "" } });
cm.dispatch({ changes: { from: 0, to: 0, insert: "pick me" }, selection: { anchor: 0, head: 4 } });
cm.focus();
colorToggle?.click();
await new Promise((r) => setTimeout(r, 50));
const customInput = document.getElementById("customTextColor");
customInput.value = "#0066cc";
customInput.dispatchEvent(new Event("change", { bubbles: true }));
await new Promise((r) => setTimeout(r, 50));
out.customColorApplied = cm.state.doc.toString() === '<span style="color:#0066cc">pick</span> me';
// leave some formatted content on disk for the autosave check
cm.dispatch({
changes: { from: 0, to: cm.state.doc.length, insert: "**Hello** World" },
selection: { anchor: 0 },
});
await new Promise((r) => setTimeout(r, 1200)); // wait for autosave debounce
out.formattingAutosaved =
(await window.folio.getChapterContent(typedId)).includes("**Hello**");
}
// --- full book view (fresh second book with known chapter contents) ---
await window.folio.openBookPath(BOOK2_DIR);
await new Promise((r) => setTimeout(r, 400));
const fb = window.__folioFullBookEditor;
const alphaId = (await window.folio.createChapter("Alpha")).id;
const betaId = (await window.folio.createChapter("Beta")).id;
const gammaId = (await window.folio.createChapter("Gamma")).id;
await new Promise((r) => setTimeout(r, 300));
await window.folio.saveChapter(alphaId, "# Alpha\n\nAlpha body text");
await window.folio.saveChapter(betaId, "Beta body without heading");
await window.folio.saveChapter(gammaId, "# Gamma's Own Title\n\nGamma text");
await new Promise((r) => setTimeout(r, 300));
document.getElementById("fullBookBtn").click();
await new Promise((r) => setTimeout(r, 300));
const expectedAssembled =
"# Alpha\n\nAlpha body text\n\n# Beta\n\nBeta body without heading\n\n# Gamma\n\n# Gamma's Own Title\n\nGamma text";
out.fullBookShown =
!document.getElementById("fullBookView").classList.contains("hidden") &&
document.getElementById("chapterView").classList.contains("hidden") &&
document.getElementById("fullBookBtn").classList.contains("active") &&
!document.getElementById("fullBookNav").classList.contains("hidden");
const assembled = fb.state.doc.toString();
out.assembled = assembled;
out.assembledOk = assembled === expectedAssembled;
const words = expectedAssembled.trim().split(/\s+/).length;
const statsEl = document.getElementById("fullBookStats");
out.fullBookStatsText = statsEl.textContent;
out.fullBookStatsOk =
statsEl.textContent === `words: ${words} · chars: ${expectedAssembled.length}`;
// --- chapter page breaks in Full Book ---
// Each chapter after the first gets a "chapter-break" marker on its first
// rendered line (a CSS border on screen, break-before: page when printed).
// The first chapter must never be marked, so no blank page precedes it.
const chapterBreakLines = () =>
Array.from(document.querySelectorAll("#fullBookHost .cm-line.chapter-break"));
const allContentLines = () =>
Array.from(document.querySelectorAll("#fullBookHost .cm-content .cm-line"));
const fbBreaks = chapterBreakLines();
const firstLine = allContentLines()[0];
out.chapterBreakCount = fbBreaks.length;
out.chapterBreakCountOk = fbBreaks.length === 2; // 3 chapters -> 2 breaks
out.firstChapterUnmarked = !!firstLine && !firstLine.classList.contains("chapter-break");
out.breaksAtChapterStarts =
fbBreaks.length === 2 &&
fbBreaks.every((el) => {
const t = el.textContent;
return t.startsWith("# Beta") || t.startsWith("# Gamma");
});
// print rules must be present in the stylesheet (fetch the raw CSS because
// Chromium's CSSOM normalizes the page-break-before alias into break-before)
const rawCss = await (await fetch("./index.css")).text();
out.printBreakRule = /break-before\s*:\s*page/.test(rawCss);
out.printBreakFallback = /page-break-before\s*:\s*always/.test(rawCss);
out.printBreakScoped = /\.print-chapter\s*{[^}]*break-before\s*:\s*page/.test(rawCss);
document.getElementById("copyAllBtn").click();
await new Promise((r) => setTimeout(r, 200));
const clip = await window.folio.getClipboardText();
out.clipboardLen = clip.length;
out.copyAllOk = clip === expectedAssembled;
out.copyStatusShown = document.getElementById("fullBookStatus").textContent === "Full book copied";
// Ctrl+A (routed through the renderer listener) selects the whole read-only doc
fb.focus();
fb.contentDOM.dispatchEvent(
new KeyboardEvent("keydown", { key: "a", ctrlKey: true, bubbles: true, cancelable: true })
);
const sel = fb.state.selection.main;
out.fullBookSelectAllOk =
fb.state.doc.length > 0 && sel.from === 0 && sel.to === fb.state.doc.length;
// back to normal chapter editing still works
document.querySelector("#chapterList .chapter-row").click();
await new Promise((r) => setTimeout(r, 250));
out.backToChapterOk =
document.getElementById("fullBookView").classList.contains("hidden") &&
!document.getElementById("chapterView").classList.contains("hidden") &&
!document.getElementById("fullBookBtn").classList.contains("active");
// --- requirement matrix: reorder, persistence, edit ops reflect in Full Book ---
const rowFor = (id) =>
Array.from(document.querySelectorAll("#chapterList .chapter-row")).find((r) => r.dataset.id === id);
const moveUp = async (id) => {
rowFor(id).dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowUp", altKey: true, bubbles: true })
);
await new Promise((r) => setTimeout(r, 250));
};
// current order: [Alpha, Beta, Gamma]; move Gamma to front -> [Gamma, Alpha, Beta]
rowFor(gammaId).click();
await new Promise((r) => setTimeout(r, 200));
await moveUp(gammaId);
await moveUp(gammaId);
const orderReordered = (await window.folio.getBookMeta()).chapterOrder;
out.orderReordered = orderReordered;
out.reorderOk =
orderReordered[0] === gammaId && orderReordered[1] === alphaId && orderReordered[2] === betaId;
// Full Book follows the new chapter order and keeps every chapter's content
document.getElementById("fullBookBtn").click();
await new Promise((r) => setTimeout(r, 300));
const assembledGAB = fb.state.doc.toString();
out.fbAfterReorder = assembledGAB;
out.fbAfterReorderOk =
assembledGAB.startsWith("# Gamma\n\n# Gamma's Own Title") &&
assembledGAB.indexOf("# Alpha") > assembledGAB.indexOf("# Gamma") &&
assembledGAB.indexOf("# Beta") > assembledGAB.indexOf("# Alpha");
out.fbHasAllContent =
assembledGAB.includes("Gamma text") &&
assembledGAB.includes("Alpha body text") &&
assembledGAB.includes("Beta body without heading");
// chapter breaks follow the reordered manuscript: still exactly 2 breaks,
// now marking Alpha and Beta (Gamma is first, so it stays unmarked)
const fbBreaksReordered = chapterBreakLines();
out.fbBreaksAfterReorder = fbBreaksReordered.map((el) => el.textContent);
out.fbBreakAfterReorderOk =
fbBreaksReordered.length === 2 &&
fbBreaksReordered.every((el) => {
const t = el.textContent;
return t.startsWith("# Alpha") || t.startsWith("# Beta");
}) &&
!allContentLines()[0].classList.contains("chapter-break");
// switching between an individual chapter and Full Book never shows stale data
document.querySelector("#chapterList .chapter-row").click();
await new Promise((r) => setTimeout(r, 200));
document.getElementById("fullBookBtn").click();
await new Promise((r) => setTimeout(r, 300));
out.fbNoStale = fb.state.doc.toString() === assembledGAB;
// the Full Book pane must be a bounded flex column whose editor can be
// scrolled, so every chapter is actually reachable on screen (a block
// layout here clips the manuscript to the first screenful)
const fbViewEl = document.getElementById("fullBookView");
const fbHost = document.getElementById("fullBookHost");
const fbEditor = fbHost.querySelector(".cm-editor");
const vs = getComputedStyle(fbViewEl);
out.fbViewDisplay = vs.display;
out.fbViewDir = vs.flexDirection;
out.fbEditorHeight = Math.round(fbEditor.getBoundingClientRect().height);
out.fbViewportHeight = window.innerHeight;
out.fbLayoutOk =
vs.display === "flex" &&
vs.flexDirection === "column" &&
fbEditor.getBoundingClientRect().height <= window.innerHeight;
// closing and reopening the book preserves the chapter order
await window.folio.openBookPath(BOOK2_DIR);
await new Promise((r) => setTimeout(r, 400));
const orderReopened = (await window.folio.getBookMeta()).chapterOrder;
out.orderAfterReopen = orderReopened;
out.reopenPersists = JSON.stringify(orderReopened) === JSON.stringify(orderReordered);
// deleting a chapter updates the sidebar and Full Book
rowFor(alphaId).querySelector('[data-act="del"]').click();
await new Promise((r) => setTimeout(r, 150));
document.getElementById("modalOk").click();
await new Promise((r) => setTimeout(r, 300));
out.orderAfterDelete = (await window.folio.getBookMeta()).chapterOrder;
out.deleteOk = !(await window.folio.getBookMeta()).chapterOrder.includes(alphaId);
document.getElementById("fullBookBtn").click();
await new Promise((r) => setTimeout(r, 300));
const assembledAfterDelete = fb.state.doc.toString();
out.fbAfterDelete = assembledAfterDelete;
out.fbAfterDeleteOk =
!assembledAfterDelete.includes("Alpha body text") && assembledAfterDelete.includes("Gamma text");
// renaming a chapter updates its Full Book heading
rowFor(betaId).querySelector('[data-act="rename"]').click();
await new Promise((r) => setTimeout(r, 150));
document.getElementById("modalInput").value = "Beta Renamed";
document.getElementById("modalOk").click();
await new Promise((r) => setTimeout(r, 300));
const assembledAfterRename = fb.state.doc.toString();
out.fbAfterRename = assembledAfterRename;
out.fbAfterRenameOk =
assembledAfterRename.includes("# Beta Renamed") &&
!assembledAfterRename.includes("# Beta\n\nBeta body without heading");
// a newly created empty chapter appears in Full Book with just its heading
const emptyId = (await window.folio.createChapter("Empty")).id;
await new Promise((r) => setTimeout(r, 300));
document.getElementById("fullBookBtn").click();
await new Promise((r) => setTimeout(r, 300));
const assembledWithEmpty = fb.state.doc.toString();
out.fbWithEmpty = assembledWithEmpty;
out.fbEmptyOk = assembledWithEmpty.includes("# Empty") && assembledWithEmpty.includes("Beta Renamed");
// --- Export of the Full Book (PDF / DOCX / ODT) ---
// The e2e harness sets FOLIO_TEST_PDF/DOCX/ODT, so the main process skips
// the native save dialog and writes to those paths, returning the byte
// count and whether the buffer starts with the right magic number. The
// Full Book view is on screen here, so the PDF is the assembled manuscript
// (the print stylesheet hides all app chrome).
const exportMenuToggle = document.getElementById("exportMenuToggle");
const exportMenu = document.getElementById("exportMenu");
out.exportMenuPresent = !!exportMenuToggle && !!exportMenu;
out.pdfExportPdfVisible = !document.getElementById("fullBookView").classList.contains("hidden");
exportMenuToggle.click();
await new Promise((r) => setTimeout(r, 50));
out.exportMenuOpened = !exportMenu.classList.contains("hidden");
const pdfItem = exportMenu.querySelector('[data-export="pdf"]');
out.pdfItemPresent = !!pdfItem;
pdfItem.click();
await new Promise((r) => setTimeout(r, 2500));
out.pdfStatusShown = document
.getElementById("fullBookStatus")
.textContent.startsWith("PDF saved to");
const pdfRes = await window.folio.exportPdf();
out.pdfExport =
pdfRes && "filePath" in pdfRes
? { filePath: pdfRes.filePath, bytes: pdfRes.bytes, pdfOk: pdfRes.pdfOk }
: pdfRes;
out.pdfExportOk =
!!pdfRes &&
!("error" in pdfRes) &&
"filePath" in pdfRes &&
pdfRes.filePath.endsWith(".pdf") &&
typeof pdfRes.bytes === "number" &&
pdfRes.bytes > 500 &&
pdfRes.pdfOk === true;
// The printed book comes from the plain-HTML #printBook container (the
// print stylesheet hides the whole app and shows #printBook instead), so
// the PDF can never be limited to the CodeMirror viewport. Verify the
// container is hidden on screen and carries every chapter.
const printBookEl = document.getElementById("printBook");
out.printBookHiddenOnScreen = getComputedStyle(printBookEl).display === "none";
const printChapters = () =>
Array.from(document.querySelectorAll("#printBook .print-chapter"));
const pcsAtSmallBook = printChapters();
out.printBookChapterCount = pcsAtSmallBook.length;
out.printBookHasAllChapters =
pcsAtSmallBook.length === 3 &&
pcsAtSmallBook.some((s) => s.textContent.includes("Gamma text")) &&
pcsAtSmallBook.some((s) => s.textContent.includes("Beta body without heading")) &&
pcsAtSmallBook.some((s) => s.querySelector("h1")?.textContent === "Empty");
// Long book: every line must reach the print DOM — the guard against the
// "PDF does not show the full book" bug where a long manuscript was cut
// off after the first screenful.
const longLines = Array.from(
{ length: 1500 },
(_, i) => `Line ${i} of a very long chapter that must all reach the printed book.`
);
const veryLongId = (await window.folio.createChapter("Very Long")).id;
await window.folio.saveChapter(veryLongId, longLines.join("\n"));
await new Promise((r) => setTimeout(r, 500));
document.getElementById("fullBookBtn").click();
await new Promise((r) => setTimeout(r, 400));
const printBookText = printBookEl.textContent;
out.printBookHasFirstLongLine = printBookText.includes("Line 0 of a very long chapter");
out.printBookHasLastLongLine = printBookText.includes("Line 1499 of a very long chapter");
out.printBookLongChapterCount = printChapters().length;
out.printBookLongFull =
out.printBookHasFirstLongLine &&
out.printBookHasLastLongLine &&
printChapters().length === 4;
const pdfLongRes = await window.folio.exportPdf();
out.pdfLongExport =
pdfLongRes && "filePath" in pdfLongRes
? { filePath: pdfLongRes.filePath, bytes: pdfLongRes.bytes, pdfOk: pdfLongRes.pdfOk }
: pdfLongRes;
out.pdfLongExportOk =
!!pdfLongRes &&
!("error" in pdfLongRes) &&
"filePath" in pdfLongRes &&
pdfLongRes.bytes > pdfRes.bytes * 2 &&
pdfLongRes.pdfOk === true;
// DOCX export: main process assembles the manuscript from the chapter
// files (all four chapters, including the very long one), writes a ZIP
// (PK magic) to FOLIO_TEST_DOCX, and reports its size. The deep content
// checks live in tests/run-office-export-test.mjs; here we verify the
// end-to-end IPC path, extension, magic number, and the status notice.
const docxRes = await window.folio.exportDocx();
out.docxExport =
docxRes && "filePath" in docxRes
? { filePath: docxRes.filePath, bytes: docxRes.bytes, docxOk: docxRes.docxOk }
: docxRes;
out.docxExportOk =
!!docxRes &&
!("error" in docxRes) &&
"filePath" in docxRes &&
docxRes.filePath.endsWith(".docx") &&
typeof docxRes.bytes === "number" &&
docxRes.bytes > 1000 &&
docxRes.docxOk === true;
const odtRes = await window.folio.exportOdt();
out.odtExport =
odtRes && "filePath" in odtRes
? { filePath: odtRes.filePath, bytes: odtRes.bytes, odtOk: odtRes.odtOk }
: odtRes;
out.odtExportOk =
!!odtRes &&
!("error" in odtRes) &&
"filePath" in odtRes &&
odtRes.filePath.endsWith(".odt") &&
typeof odtRes.bytes === "number" &&
odtRes.bytes > 1000 &&
odtRes.odtOk === true;
// Formatting must survive into the printed book: markdown-it is configured
// to pass through the toolbar's HTML (<u>, <span style=...>, <div>),
// ==highlight== becomes <mark>, callouts become styled divs, and
// wikilinks resolve to links — none of them may show up as raw source.
const fmtId = (await window.folio.createChapter("Formatting")).id;
await window.folio.saveChapter(
fmtId,
[
"**bold** *italic* ~~strike~~ ==highlight== <u>under</u>",
'<span style="color:#e11d48">red</span> <span style="background-color:#fde047">marked</span>',
"",
"> [!note] Callout title",
"> callout body",
"",
"[[Beta Renamed]]",
].join("\n")
);
await new Promise((r) => setTimeout(r, 500));
document.getElementById("fullBookBtn").click();
await new Promise((r) => setTimeout(r, 400));
const pbInner = document.getElementById("printBook").innerHTML;
const pbText = document.getElementById("printBook").textContent;
out.printFmtBold = /<strong>bold<\/strong>/.test(pbInner);
out.printFmtMark = /<mark>highlight<\/mark>/.test(pbInner);
out.printFmtUnderline = /<u>under<\/u>/.test(pbInner);
out.printFmtColor = /<span style="color:#e11d48">red<\/span>/.test(pbInner);
out.printFmtNoRawEq = !pbText.includes("==highlight==");
out.printFmtCallout = /class="callout callout-note"/.test(pbInner);
out.printFmtNoRawCallout = !pbText.includes("[!note]");
out.printFmtWikilink = /class="wikilink internal"/.test(pbInner);
out.printFmtNoRawWikilink = !pbText.includes("[[Beta Renamed]]");
out.printFmtOk =
out.printFmtBold &&
out.printFmtMark &&
out.printFmtUnderline &&
out.printFmtColor &&
out.printFmtNoRawEq &&
out.printFmtCallout &&
out.printFmtNoRawCallout &&
out.printFmtWikilink &&
out.printFmtNoRawWikilink;
// --- chapter navigation inside Full Book (third book: One/Two/Three/Four) ---
// Four chapters, each taller than the viewport, so every jump target lands
// its own heading near the top of the Full Book viewport.
await window.folio.openBookPath(BOOK3_DIR);
await new Promise((r) => setTimeout(r, 400));
const navBook = window.__folioFullBookEditor;
const navViewEl = document.getElementById("fullBookView");
const navSel = document.getElementById("fbChapterSelect");
const navPrev = document.getElementById("fbPrevBtn");
const navNext = document.getElementById("fbNextBtn");
document.getElementById("fullBookBtn").click();
await new Promise((r) => setTimeout(r, 300));
const navOptions = () => Array.from(navSel.options).map((o) => o.textContent);
const navDocStarts = () => {
const doc = navBook.state.doc.toString();
return ["One", "Two", "Three", "Four"].map((t) => doc.indexOf("# " + t));
};
// How far (px) the given chapter's heading sits below the top of the Full
// Book viewport. The jump is considered correct when it is near the top.
const chapterTopY = (title) => {
const doc = navBook.state.doc.toString();
const start = doc.indexOf("# " + title);
if (start < 0) return -1;
const rect = navBook.scrollDOM.getBoundingClientRect();
const c = navBook.coordsAtPos(start);
if (!c) return -1;
return c.top - rect.top;
};
const navTopChapter = () => {
const scroller = navBook.scrollDOM;
const rect = scroller.getBoundingClientRect();
const pos = navBook.posAtCoords({ x: rect.left + 4, y: rect.top + 30 });
if (pos === null || pos === undefined) return -1;
const starts = navDocStarts();
let idx = 0;
for (let i = 0; i < starts.length; i++) {
if (starts[i] >= 0 && starts[i] <= pos) idx = i;
else break;
}
return idx;
};
const navTopLine = () => {
const scroller = navBook.scrollDOM;
const rect = scroller.getBoundingClientRect();
const pos = navBook.posAtCoords({ x: rect.left + 4, y: rect.top + 30 });
if (pos === null || pos === undefined) return "";
return navBook.state.doc.lineAt(pos).text;
};
const navSnap = () => ({
value: navSel.value,
prevDisabled: navPrev.disabled,
nextDisabled: navNext.disabled,
topChapter: navTopChapter(),
topLine: navTopLine(),
fullBookVisible:
!navViewEl.classList.contains("hidden") &&
document.getElementById("chapterView").classList.contains("hidden"),
});
const navWait = (ms) => new Promise((r) => setTimeout(r, ms));
// 1. Full Book opens at Chapter One; Previous disabled.
const navStart = navSnap();
out.navOptions = navOptions();
out.navOptionsOk =
JSON.stringify(out.navOptions) === JSON.stringify(["One", "Two", "Three", "Four"]);
out.navOpenAtOne = navStart;
out.navOpenAtOneOk =
navStart.value === "ch1" &&
navStart.prevDisabled &&
!navStart.nextDisabled &&
navStart.fullBookVisible &&
navStart.topChapter === 0;
// 2. Next -> Chapter Two.
navNext.click();
await navWait(300);
const navTwo = navSnap();
out.navNextToTwo = navTwo;
out.navNextToTwoOk =
navTwo.value === "ch2" &&
!navTwo.prevDisabled &&
!navTwo.nextDisabled &&
navTwo.fullBookVisible &&
navTwo.topChapter === 1 &&
chapterTopY("Two") >= 0 &&
chapterTopY("Two") < 200;
// 3. Next -> Chapter Three.
navNext.click();
await navWait(300);
const navThree = navSnap();
out.navNextToThree = navThree;
out.navNextToThreeOk =
navThree.value === "ch3" &&
navThree.fullBookVisible &&
navThree.topChapter === 2 &&
chapterTopY("Three") >= 0 &&
chapterTopY("Three") < 200;
// 4. Previous -> back to Chapter Two.
navPrev.click();
await navWait(300);
const navBackTwo = navSnap();
out.navPrevToTwo = navBackTwo;
out.navPrevToTwoOk =
navBackTwo.value === "ch2" &&
navBackTwo.topChapter === 1 &&
chapterTopY("Two") >= 0 &&
chapterTopY("Two") < 200;
// 5. Dropdown -> Chapter Four (Next disabled at the last chapter).
navSel.value = "ch4";
navSel.dispatchEvent(new Event("change", { bubbles: true }));
await navWait(300);
const navFour = navSnap();
out.navDropToFour = navFour;
out.navDropToFourOk =
navFour.value === "ch4" &&
navFour.nextDisabled &&
navFour.fullBookVisible &&
chapterTopY("Four") >= 0 &&
chapterTopY("Four") < 200;
// 6. Manual scrolling updates the selector: scroll to the very bottom
// (last chapter) and to the very top (first chapter).
navBook.scrollDOM.scrollTop = navBook.scrollDOM.scrollHeight;
navBook.scrollDOM.dispatchEvent(new Event("scroll"));
await navWait(250);
const navScrolledBottom = navSnap();
out.navScrollBottomTracks =
navScrolledBottom.value === "ch4" && navScrolledBottom.nextDisabled;
navBook.scrollDOM.scrollTop = 0;
navBook.scrollDOM.dispatchEvent(new Event("scroll"));
await navWait(250);
const navScrolledTop = navSnap();
out.navScrollTopTracks = navScrolledTop.value === "ch1" && navScrolledTop.prevDisabled;
// 7. Reordering chapters in the sidebar must be reflected immediately, even
// while Full Book stays open (no restart). Drag the last row onto the first.
const navRows = document.getElementById("chapterList").querySelectorAll(".chapter-row");
const navDt = new DataTransfer();
navRows[3].dispatchEvent(new DragEvent("dragstart", { dataTransfer: navDt, bubbles: true }));
navRows[0].dispatchEvent(new DragEvent("dragover", { dataTransfer: navDt, bubbles: true }));
navRows[0].dispatchEvent(new DragEvent("drop", { dataTransfer: navDt, bubbles: true }));
navRows[3].dispatchEvent(new DragEvent("dragend", { dataTransfer: navDt, bubbles: true }));
await navWait(400);
const navOrderReordered = (await window.folio.getBookMeta()).chapterOrder;
out.navOrderReordered = navOrderReordered;
out.navReorderChanged =
JSON.stringify(navOrderReordered) === JSON.stringify(["ch4", "ch1", "ch2", "ch3"]);
const navOptionsAfter = navOptions();
out.navOptionsAfterReorder = navOptionsAfter;
out.navDropdownFollowsReorder =
JSON.stringify(navOptionsAfter) === JSON.stringify(["Four", "One", "Two", "Three"]);
// Full Book is re-rendered at the top: the new first chapter (Four) is
// current, Previous is disabled, and Next now moves to One.
const navAfterReorder = navSnap();
out.navAfterReorder = navAfterReorder;
out.navFollowsNewOrder =
navAfterReorder.value === "ch4" &&
navAfterReorder.prevDisabled &&
navAfterReorder.fullBookVisible &&
chapterTopY("Four") >= 0 &&
chapterTopY("Four") < 200;
navNext.click();
await navWait(300);
const navNextAfterReorder = navSnap();
out.navNextAfterReorder = navNextAfterReorder;
out.navNextAfterReorderOk =
navNextAfterReorder.value === "ch1" &&
navNextAfterReorder.fullBookVisible &&
chapterTopY("One") >= 0 &&
chapterTopY("One") < 200;
// 8. Page breaks still render after navigation. Break markers are
// decorations on the first line of every chapter after the first, and
// CodeMirror only puts rendered lines in the DOM, so navigate to a middle
// chapter and confirm its marked first line is present.
navSel.value = "ch2";
navSel.dispatchEvent(new Event("change", { bubbles: true }));
await navWait(300);
const navBreaks = chapterBreakLines();
out.navBreakCount = navBreaks.length;
out.navBreaksOk = navBreaks.length >= 1 && navBreaks.some((el) => el.textContent.startsWith("# Two"));
// --- theme system: selector, immediate switching, persistence ---
const themeSelect = document.getElementById("themeSelect");
const themeVar = (name) =>
getComputedStyle(document.documentElement).getPropertyValue(name).trim();
const docBeforeTheme = navBook.state.doc.toString();
out.themeSelectPresent = !!themeSelect;
out.themeSelectOptions = themeSelect
? Array.from(themeSelect.options).map((o) => o.value)
: [];
out.themeHasLight = out.themeSelectOptions.includes("light");
out.themeHasNeonflux = out.themeSelectOptions.includes("neonflux");
out.themeInitialStored = await window.folio.getTheme();
out.themeInitialAttr = document.documentElement.getAttribute("data-theme");
// Old saves may hold "default", which must resolve to the Light theme
// (migration). Valid saved themes must be restored exactly on startup.
out.themeInitialConsistent =
out.themeInitialStored === "default" || out.themeInitialStored === "light"
? out.themeInitialAttr === "light"
: out.themeInitialAttr === out.themeInitialStored;
// Switch to Neonflux: applies immediately and persists via IPC.
themeSelect.value = "neonflux";
themeSelect.dispatchEvent(new Event("change", { bubbles: true }));
await navWait(200);
out.themeNeonfluxAttr = document.documentElement.getAttribute("data-theme");
out.themeNeonfluxStored = await window.folio.getTheme();
const neonBg = themeVar("--color-bg");
const neonAccent = themeVar("--color-accent");
out.themeNeonfluxBg = neonBg;
out.themeNeonfluxAccent = neonAccent;
out.themeNeonfluxApplied =
out.themeNeonfluxAttr === "neonflux" &&
out.themeNeonfluxStored === "neonflux" &&
neonBg.toLowerCase() === "#0a0d14" &&
neonAccent.toLowerCase() === "#22d3ee";
// Neonflux is dark: computed background of the app should be near-black.
const neonBodyBg = getComputedStyle(document.body).backgroundColor;
out.themeNeonfluxDark = /rgb\((10|11|12|9|8),/.test(neonBodyBg);
// Switching themes must not touch the manuscript content.
out.themeContentUnchanged = navBook.state.doc.toString() === docBeforeTheme;
// Switch to Light: applies immediately and persists.
themeSelect.value = "light";
themeSelect.dispatchEvent(new Event("change", { bubbles: true }));
await navWait(200);
out.themeLightAttr = document.documentElement.getAttribute("data-theme");
out.themeLightStored = await window.folio.getTheme();
const lightBg = themeVar("--color-bg");
const lightAccent = themeVar("--color-accent");
out.themeLightBg = lightBg;
out.themeLightAccent = lightAccent;
out.themeLightApplied =
out.themeLightAttr === "light" &&
out.themeLightStored === "light" &&
lightBg.toLowerCase() === "#fffdf9" &&
lightAccent.toLowerCase() === "#2f6b5f";
// Light is light: computed background must be a warm off-white, never the
// near-black used by Neonflux.
const lightBodyBg = getComputedStyle(document.body).backgroundColor;
out.themeLightNotDark = !/rgb\((10|11|12|9|8),/.test(lightBodyBg);
out.themeContentUnchanged2 = navBook.state.doc.toString() === docBeforeTheme;
// Round-trip: back to Neonflux restores it exactly.
themeSelect.value = "neonflux";
themeSelect.dispatchEvent(new Event("change", { bubbles: true }));
await navWait(200);
out.themeRoundTripAttr = document.documentElement.getAttribute("data-theme");
out.themeRoundTripStored = await window.folio.getTheme();
out.themeRoundTripOk =
out.themeRoundTripAttr === "neonflux" &&
out.themeRoundTripStored === "neonflux" &&
themeVar("--color-bg").toLowerCase() === "#0a0d14" &&
themeVar("--color-accent").toLowerCase() === "#22d3ee";
// Leave the app on Light so subsequent runs start deterministically.
themeSelect.value = "light";
themeSelect.dispatchEvent(new Event("change", { bubbles: true }));
await navWait(200);
out.themeFinalAttr = document.documentElement.getAttribute("data-theme");
out.themeFinalStored = await window.folio.getTheme();
out.themeFinalLightOk = out.themeFinalAttr === "light" && out.themeFinalStored === "light";
out.themeContentUnchanged3 = navBook.state.doc.toString() === docBeforeTheme;
const okFlags = [
"dndMoved",
"kbMoved",
"modalShown",
"uiCreated",
"hasEditor",
"editorAutosaved",
"toolbarBoldOk",
"focusAfterBold",
"shortcutBoldOk",
"headingOk",
"undoOk",
"redoOk",
"strikeShortcutOk",
"fencedCodeOk",
"underlineOk",
"checklistOk",
"highlightOk",
"tableOk",
"painterOk",
"colorMenuOpens",
"colorMenuOnScreen",
"colorApplied",
"customColorApplied",
"formattingAutosaved",
"fullBookShown",
"assembledOk",
"fullBookStatsOk",
"chapterBreakCountOk",
"firstChapterUnmarked",
"breaksAtChapterStarts",
"printBreakRule",
"printBreakFallback",
"printBreakScoped",
"copyAllOk",
"copyStatusShown",
"fullBookSelectAllOk",
"backToChapterOk",
"reorderOk",
"fbAfterReorderOk",
"fbHasAllContent",
"fbBreakAfterReorderOk",
"fbNoStale",
"fbLayoutOk",
"reopenPersists",
"deleteOk",
"fbAfterDeleteOk",
"fbAfterRenameOk",
"fbEmptyOk",
"exportMenuPresent",
"exportMenuOpened",
"pdfItemPresent",
"pdfExportPdfVisible",
"pdfStatusShown",
"pdfExportOk",
"printBookHiddenOnScreen",
"printBookHasAllChapters",
"printBookLongFull",
"pdfLongExportOk",
"docxExportOk",
"odtExportOk",
"printFmtOk",
"navOptionsOk",
"navOpenAtOneOk",
"navNextToTwoOk",
"navNextToThreeOk",
"navPrevToTwoOk",
"navDropToFourOk",
"navScrollBottomTracks",
"navScrollTopTracks",
"navReorderChanged",
"navDropdownFollowsReorder",
"navFollowsNewOrder",
"navNextAfterReorderOk",
"navBreaksOk",
"themeSelectPresent",
"themeHasLight",
"themeHasNeonflux",
"themeInitialConsistent",
"themeNeonfluxApplied",
"themeNeonfluxDark",
"themeContentUnchanged",
"themeLightApplied",
"themeLightNotDark",
"themeContentUnchanged2",
"themeRoundTripOk",
"themeFinalLightOk",
"themeContentUnchanged3",
];
out.all = okFlags.every((f) => out[f] === true);
} catch (e) {
out.error = String((e && e.message) || e);
}
return out;
})();