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
BIN
FolioAppIcon.png
|
Before Width: | Height: | Size: 1.6 MiB |
BIN
FolioICON.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
30
START-HERE.md
Normal 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
|
||||||
0
Things to have changed for the App
Normal file
|
Before Width: | Height: | Size: 1 MiB After Width: | Height: | Size: 778 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 947 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 4 KiB |
258
docs/architecture-stage-1.md
Normal 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
28
package.json
|
|
@ -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
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 = "";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
566
src/main/markdown-to-native.ts
Normal 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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -27,9 +27,16 @@ export function buildMenu(
|
||||||
const fileMenu: MenuItem = {
|
const fileMenu: MenuItem = {
|
||||||
label: "File",
|
label: "File",
|
||||||
submenu: [
|
submenu: [
|
||||||
{ 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
|
|
@ -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
|
|
@ -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 };
|
||||||
190
src/main/native-to-markdown.ts
Normal 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
|
||||||
|
? ``
|
||||||
|
: ``;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
@ -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()),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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[] {
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
|
|
@ -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 = ``;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
82
src/renderer/native-assembly.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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, "&")
|
||||||
for (let i = start + 2; i < max - 1; i++) {
|
.replace(/</g, "<")
|
||||||
const ch = src.charCodeAt(i);
|
.replace(/>/g, ">")
|
||||||
if (ch === 0x0a /* newline */) return false;
|
.replace(/"/g, """);
|
||||||
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>";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CALLOUT_RE = /^\s*\[!([a-zA-Z0-9_-]+)\]\s*(.*)$/;
|
const ALIGN = (node?: FolioNode): string => {
|
||||||
|
const a = node?.attrs;
|
||||||
|
const parts: string[] = [];
|
||||||
|
const ta = a?.textAlign ? String(a.textAlign) : "";
|
||||||
|
if (ta && ta !== "left") parts.push(`text-align:${esc(ta)}`);
|
||||||
|
if (a?.lineHeight) parts.push(`line-height:${esc(String(a.lineHeight))}`);
|
||||||
|
return parts.length ? ` style="${parts.join(";")}"` : "";
|
||||||
|
};
|
||||||
|
|
||||||
// > [!note] Title followed by "> body" lines becomes a styled callout div.
|
// Wrap a string with the formatting marks on a text node. Order matters: link
|
||||||
// Runs after inline parsing so blockquote paragraphs already have children.
|
// is outermost, then font/size, then color/highlight, then underline/strike,
|
||||||
function calloutPlugin(md: MarkdownItInstance) {
|
// then bold/italic, with code innermost.
|
||||||
md.core.ruler.after("inline", "callout", (state: StateCore) => {
|
function applyMarksHtml(text: string, marks: FolioMark[] = []): string {
|
||||||
const tokens = state.tokens;
|
let out = text;
|
||||||
for (let i = 0; i < tokens.length; i++) {
|
const has = (t: string) => marks.some((m) => m.type === t);
|
||||||
const open = tokens[i];
|
if (has("code")) out = `<code>${out}</code>`;
|
||||||
if (open.type !== "blockquote_open") continue;
|
if (has("italic")) out = `<em>${out}</em>`;
|
||||||
let depth = 1;
|
if (has("bold")) out = `<strong>${out}</strong>`;
|
||||||
let closeIdx = -1;
|
if (has("strike")) out = `<s>${out}</s>`;
|
||||||
for (let j = i + 1; j < tokens.length; j++) {
|
if (has("underline")) out = `<u>${out}</u>`;
|
||||||
const t = tokens[j];
|
const hl = marks.find((m) => m.type === "highlight");
|
||||||
if (t.type === "blockquote_open") depth += 1;
|
if (hl) {
|
||||||
else if (t.type === "blockquote_close") {
|
const color = hl.attrs?.color ? String(hl.attrs.color) : "";
|
||||||
depth -= 1;
|
out = color ? `<span style="background-color:${esc(color)}">${out}</span>` : `<mark>${out}</mark>`;
|
||||||
if (depth === 0) {
|
}
|
||||||
closeIdx = j;
|
const tc = marks.find((m) => m.type === "textColor");
|
||||||
break;
|
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 (closeIdx === -1) continue;
|
if (fsz?.attrs?.fontSize) out = `<span style="font-size:${esc(String(fsz.attrs.fontSize))}">${out}</span>`;
|
||||||
const paraOpen = tokens[i + 1];
|
const link = marks.find((m) => m.type === "link");
|
||||||
if (!paraOpen || paraOpen.type !== "paragraph_open") continue;
|
if (link?.attrs?.href) {
|
||||||
const inline = tokens[i + 2];
|
const href = String(link.attrs.href);
|
||||||
if (!inline || inline.type !== "inline" || !inline.children) continue;
|
const internal = href.startsWith("folio:chapter/");
|
||||||
const first = inline.children[0];
|
const cls = internal ? ' class="wikilink internal"' : "";
|
||||||
if (!first || first.type !== "text") continue;
|
out = `<a href="${esc(href)}"${cls}>${out}</a>`;
|
||||||
const m = CALLOUT_RE.exec(first.content);
|
}
|
||||||
if (!m) continue;
|
return out;
|
||||||
|
|
||||||
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 inlineToHtml(nodes: FolioNode[] = []): string {
|
||||||
chapters: string[],
|
let out = "";
|
||||||
targets: WikilinkTarget[]
|
for (const n of nodes) {
|
||||||
): string {
|
if (n.type === "text") out += applyMarksHtml(n.text ?? "", n.marks);
|
||||||
const md = new MarkdownIt({ html: true });
|
else if (n.type === "hard_break") out += "<br>";
|
||||||
md.use(wikilinkPlugin, { chapters: targets });
|
else if (n.type === "image") {
|
||||||
md.use(highlightPlugin);
|
const src = esc(String(n.attrs?.src ?? ""));
|
||||||
md.use(calloutPlugin);
|
const alt = esc(String(n.attrs?.alt ?? ""));
|
||||||
return chapters
|
out += `<img src="${src}" alt="${alt}">`;
|
||||||
.map((c) => `<section class="print-chapter">${md.render(c)}</section>`)
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockToHtml(node: FolioNode): string {
|
||||||
|
switch (node.type) {
|
||||||
|
case "heading": {
|
||||||
|
const level = Number(node.attrs?.level ?? 1);
|
||||||
|
return `<h${level}${ALIGN(node)}>${inlineToHtml(node.content)}</h${level}>`;
|
||||||
|
}
|
||||||
|
case "paragraph": {
|
||||||
|
const isTitle = String(node.attrs?.class ?? "") === "callout-title";
|
||||||
|
const cls = isTitle ? ' class="callout-title"' : "";
|
||||||
|
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 ? "☒ " : "☐ ") : "";
|
||||||
|
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("");
|
||||||
}
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
573
src/renderer/tiptap-editor.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
tests/.folio-editor-test-entry.ts
Normal 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";
|
||||||
14
tests/.folio-fullbook-test-entry.ts
Normal 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";
|
||||||
|
|
@ -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
|
|
@ -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);
|
||||||
|
|
@ -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", "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
|
|
@ -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);
|
||||||
303
tests/run-migration-test.mjs
Normal 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 |
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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";
|
||||||
|
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`), "", "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);
|
||||||
|
|
@ -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,94 +31,161 @@ 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);
|
||||||
const docxZip = await JSZip.loadAsync(docxBuf);
|
const docxZip = await JSZip.loadAsync(docxBuf);
|
||||||
const docxXml = await docxZip.file("word/document.xml").async("string");
|
const docxXml = await docxZip.file("word/document.xml").async("string");
|
||||||
const docxStyles = await docxZip.file("word/styles.xml").async("string");
|
const docxStyles = await docxZip.file("word/styles.xml").async("string");
|
||||||
|
|
||||||
check("docx is a zip (PK magic)", docxBuf.subarray(0, 2).toString("utf-8") === "PK");
|
check("docx is a zip (PK magic)", docxBuf.subarray(0, 2).toString("utf-8") === "PK");
|
||||||
check("docx has [Content_Types].xml", !!docxZip.file("[Content_Types].xml"));
|
check("docx has [Content_Types].xml", !!docxZip.file("[Content_Types].xml"));
|
||||||
check("docx has word/document.xml", !!docxZip.file("word/document.xml"));
|
check("docx has word/document.xml", !!docxZip.file("word/document.xml"));
|
||||||
|
|
||||||
const stripTags = (xml) => xml.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
const stripTags = (xml) => xml.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
|
||||||
const docxText = stripTags(docxXml);
|
const docxText = stripTags(docxXml);
|
||||||
check("docx contains book title", docxText.includes("Office Test Book"));
|
check("docx contains book title", docxText.includes("Office Test Book"));
|
||||||
check("docx contains byline", docxText.includes("A book compiled with Folio."));
|
check("docx contains byline", docxText.includes("A book compiled with Folio."));
|
||||||
|
|
||||||
const idxAlpha = docxText.indexOf("Alpha");
|
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"));
|
||||||
|
|
@ -190,4 +258,4 @@ if (failures > 0) {
|
||||||
console.error(`\n${failures} check(s) failed.`);
|
console.error(`\n${failures} check(s) failed.`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
console.log("\nAll office-export checks passed.");
|
console.log("\nAll office-export checks passed.");
|
||||||
|
|
|
||||||
145
tests/run-print-test.mjs
Normal 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("☒") && html.includes("done task") && html.includes("☐") && 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.");
|
||||||
|
|
@ -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;
|
|
||||||
})();
|
|
||||||