diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1b1131a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: CI + +on: + push: + branches: + - '**' + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + + - name: Install Playwright browser + run: npx playwright install --with-deps chromium + + - name: Typecheck + run: npm run typecheck + + - name: Lint + run: npm run lint + + - name: Unit tests + run: npm test + + - name: E2E tests + run: npm run test:e2e + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index da6a9b1..fcdbf61 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,43 @@ permissions: attestations: write jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + + - name: Install Playwright browser + run: npx playwright install --with-deps chromium + + - name: Typecheck + run: npm run typecheck + + - name: Lint + run: npm run lint + + - name: Unit tests + run: npm test + + - name: E2E tests + run: npm run test:e2e + build: + needs: test runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 diff --git a/.gitignore b/.gitignore index d0e4477..7520a57 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ local/ # Browser-test harness build output (see scripts/build-harness.mjs) dist-harness/ /.harness +# Playwright E2E output (see playwright.config.ts) +/test-results/ +/playwright-report/ diff --git a/.zcodeignore b/.zcodeignore new file mode 100644 index 0000000..6e07890 --- /dev/null +++ b/.zcodeignore @@ -0,0 +1,49 @@ +node_modules/ +dist/ +local/ +*.log +.playwright-mcp/ +.npmrc +.obsidian +.DS_Store +.inscriptum-log.jsonl +/.zcode +/docs/superpowers/ +/export +# Local registry +/kley.lock +/.kley +# Browser-test harness build output (see scripts/build-harness.mjs) +dist-harness/ +/.harness + +# ===== ↑ 以上同步自 .gitignore(「从 .gitignore 同步」只重写以上部分)===== +.git/ +.hg/ +.svn/ +bower_components/ +jspm_packages/ +__pycache__/ +site-packages/ +venv/ +coverage/ +htmlcov/ +lcov-report/ +cmakefiles/ +cmake-build-*/ +bazel-*/ +pods/ +deriveddata/ +storybook-static/ +playwright-report/ +test-results/ +allure-results/ +allure-report/ +cdk.out/ +*.egg-info/ +*.dist-info/ +eggs/ +pip-wheel-metadata/ +wheels/ +# ----- ↑ 以上为 ZCode 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)----- +# 自定义规则写在下方(本行提示可删除) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bf8576..a5e010d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- New document type: notepad (`.um`) — a single file that holds an ordered list of pages, edited one under another like one document. Pages can be added, duplicated, deleted (with confirmation and undo), renamed inline and reordered by drag & drop; each page can be collapsed, and its expanded/collapsed state is remembered. +- The first page is the notepad's title page — a fixed cover (title + summary) that can't be deleted, moved or renamed. Other pages don't need a title: their display title comes from the first line. +- Notepad navigation drawer (☰): lists all pages and jumps to the clicked one (smooth scrolling). +- Notebook-wide search (`Cmd/Ctrl+F`): finds across all pages, including collapsed ones, and jumps to the match. +- Images added inside a notepad are packed into the `.um` file itself, so the notepad is self-contained; other attachments stay as regular vault files. +- Pages created in a newer version of the plugin open with a "created in a newer version" notice instead of an editor and are preserved untouched on save. +- "New notepad" command and a Type selector (Note/Notepad) in the "New inscriptum" modal. + +### Fixed + +- Saving a `.um` container can no longer clobber its own manifest or note documents: asset descriptors naming a protected archive path (e.g. a hand-edited registry) are skipped at write time instead of overwriting the required entries. +- Assets are no longer garbage-collected while a notepad has a page this editor cannot interpret (newer version, unknown family, failed migration): the page is preserved verbatim, so its media stays in the archive even if its references use a shape this editor does not know. +- Opening a `.um` file is size-guarded against zip bombs: archives over a compressed-size cap are rejected before being read into memory, and decompression is bounded by a cap on the total inflated size — a hostile `.um` fails cleanly with a "too large" error instead of exhausting memory. +- Notepad saves no longer lose edits made while a save is in flight. +- Image layouts in notepads are scoped to their page: "full width" no longer spans the whole window, and wrap-layout images no longer float into the following pages. +- Toggling the plugin off/on without an app restart no longer breaks images/attachments. + ## [0.9.0] - 2026-09-18 ### Added diff --git a/README.md b/README.md index 3bf4d7d..06b65a9 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,34 @@ Run tests: npm test ``` +### E2E tests + +Browser tests run the real editor (ProseMirror + extensions) in Chromium via +Playwright, against a harness page with a shimmed `obsidian` module +(`src/browser-harness/`). Install the browser once, then run: + +```bash +npx playwright install chromium +npm run test:e2e +``` + +Useful variants: `npm run test:e2e:headed` (visible browser) and +`npm run test:e2e:ui` (Playwright UI mode). CI runs the same suite on every +push (see `.github/workflows/ci.yml`). + +Type-check the codebase (`vite build` does not check types; CI runs this on +every push, so keep it clean): + +```bash +npm run typecheck +``` + +For a continuous check while refactoring: + +```bash +npm run typecheck:watch +``` + ## Release Releases are built automatically by GitHub Actions. diff --git a/eslint.config.mts b/eslint.config.mts index ba11c50..a02618e 100644 --- a/eslint.config.mts +++ b/eslint.config.mts @@ -36,7 +36,7 @@ export default defineConfig( }, parserOptions: { projectService: { - allowDefaultProject: ["eslint.config.mts"], + allowDefaultProject: ["eslint.config.mts", "playwright.config.ts"], }, tsconfigRootDir: import.meta.dirname, }, @@ -115,6 +115,15 @@ export default defineConfig( "obsidianmd/hardcoded-config-path": "off", }, }, + { + // The Playwright config is a Node script (process.env.CI), not plugin code. + files: ["playwright.config.ts"], + languageOptions: { + globals: { + ...globals.node, + }, + }, + }, { // The review does not report unused disable directives (only missing // descriptions via `eslint-comments/require-description`), so disable diff --git a/package-lock.json b/package-lock.json index 5fdbc24..8dd8f3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@web-companions/gfc": "2.5.0", "@web-companions/h": "0.3.4", "@web-companions/lit": "0.2.1", + "fflate": "0.8.3", "highlight.js": "11.11.1", "linkifyjs": "4.3.3", "lit-html": "2.8.0", @@ -36,6 +37,7 @@ "@biomejs/biome": "2.5.13", "@eslint/js": "9.39.5", "@eslint/json": "0.14.0", + "@playwright/test": "1.63.0", "@types/node": "26.1.2", "babel-plugin-transform-jsx-to-tt": "0.5.0", "eslint": "9.39.5", @@ -1402,6 +1404,22 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -3961,6 +3979,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -5890,6 +5914,22 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/playwright-core": { "version": "1.63.0", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", diff --git a/package.json b/package.json index 6bf6425..447ff46 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,18 @@ "scripts": { "dev": "vite build --watch --mode development", "build": "vite build", + "typecheck": "tsc --noEmit", + "typecheck:watch": "tsc --noEmit --watch --preserveWatchOutput", "android:debug": "node scripts/android-debug.mjs", "ios:debug": "node scripts/ios-debug.mjs", "deploy": "cp dist/main.js manifest.json dist/styles.css .obsidian/plugins/inscriptum/", "release": "node scripts/release.mjs", "test": "vitest run", "test:watch": "vitest", + "build:harness": "node scripts/build-harness.mjs", + "test:e2e": "npm run build:harness && playwright test", + "test:e2e:headed": "npm run build:harness && playwright test --headed", + "test:e2e:ui": "npm run build:harness && playwright test --ui", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "biome format --write .", @@ -22,6 +28,7 @@ "@web-companions/gfc": "2.5.0", "@web-companions/h": "0.3.4", "@web-companions/lit": "0.2.1", + "fflate": "0.8.3", "highlight.js": "11.11.1", "linkifyjs": "4.3.3", "lit-html": "2.8.0", @@ -52,6 +59,7 @@ "@biomejs/biome": "2.5.13", "@eslint/js": "9.39.5", "@eslint/json": "0.14.0", + "@playwright/test": "1.63.0", "@types/node": "26.1.2", "babel-plugin-transform-jsx-to-tt": "0.5.0", "eslint": "9.39.5", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..60216f8 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,31 @@ +import { defineConfig, devices } from "@playwright/test"; + +const PORT = 4599; +const baseURL = `http://127.0.0.1:${PORT}`; + +export default defineConfig({ + testDir: "tests/e2e", + timeout: 30_000, + expect: { timeout: 5_000 }, + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI + ? [["github"], ["html", { open: "never" }]] + : [["list"]], + use: { + baseURL, + screenshot: "only-on-failure", + trace: "retain-on-failure", + }, + projects: [ + // The editor uses standard DOM APIs; Chromium covers the e2e surface. + { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + ], + webServer: { + command: "node scripts/e2e-serve.mjs", + url: `${baseURL}/tests/e2e/harness/index.html`, + reuseExistingServer: !process.env.CI, + timeout: 15_000, + }, +}); diff --git a/scripts/build-harness.mjs b/scripts/build-harness.mjs index f637cce..7ca4cc0 100644 --- a/scripts/build-harness.mjs +++ b/scripts/build-harness.mjs @@ -34,6 +34,9 @@ await build({ define: { "process.env.NODE_ENV": JSON.stringify("production"), "process.env.EDITOR_VERSION": JSON.stringify("harness"), + // src/tags.ts reads this too — without the define the bundle keeps a + // bare `process.env.EDITOR_BUILD_TAG` and throws in the browser. + "process.env.EDITOR_BUILD_TAG": JSON.stringify(""), }, resolve: { alias: [{ find: /^obsidian$/, replacement: obsidianShim }], @@ -66,6 +69,7 @@ await build({ define: { "process.env.NODE_ENV": JSON.stringify("production"), "process.env.EDITOR_VERSION": JSON.stringify("harness"), + "process.env.EDITOR_BUILD_TAG": JSON.stringify(""), }, resolve: { alias: [{ find: /^obsidian$/, replacement: obsidianShim }], diff --git a/scripts/e2e-serve.mjs b/scripts/e2e-serve.mjs new file mode 100644 index 0000000..24aaf24 --- /dev/null +++ b/scripts/e2e-serve.mjs @@ -0,0 +1,45 @@ +// Static file server for the Playwright E2E suite (no dependencies). +// Serves the repo root so /tests/e2e/harness/index.html and the built +// /dist-harness/* assets resolve under one origin. Started automatically +// by playwright.config.ts (webServer); also usable standalone: +// node scripts/e2e-serve.mjs +import { readFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const port = Number(process.env.E2E_PORT ?? 4599); + +const MIME = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".woff2": "font/woff2", +}; + +createServer(async (req, res) => { + try { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + const rel = decodeURIComponent(url.pathname).replace(/^\/+/, ""); + const file = path.normalize(path.join(root, rel === "" ? "index.html" : rel)); + if (file !== root && !file.startsWith(root + path.sep)) { + throw new Error("path traversal"); + } + const body = await readFile(file); + res.writeHead(200, { + "content-type": MIME[path.extname(file)] ?? "application/octet-stream", + "cache-control": "no-store", + }); + res.end(body); + } catch { + res.writeHead(404, { "content-type": "text/plain" }); + res.end("not found"); + } +}).listen(port, "127.0.0.1", () => { + console.log(`e2e server: http://127.0.0.1:${port}/tests/e2e/harness/index.html`); +}); diff --git a/src/NoteView.ts b/src/NoteView.ts index 3e85ef9..16bdb57 100644 --- a/src/NoteView.ts +++ b/src/NoteView.ts @@ -18,6 +18,7 @@ import { logDiagEvent, } from "./storage/noteStorage"; import { FileChangedModal } from "./ui/FileChangedModal"; +import { NOTE_ICON_NAME } from "./components/icons/iconSprite"; import { BUILD_TAG, TAG_VERSION } from "./tags"; import { getDesiredFileName } from "./storage/fileNaming"; import { @@ -26,13 +27,9 @@ import { } from "./storage/attachments"; import { getExtensions, type ExtensionHooks } from "./texto/getExtensions"; import { - getFoldedHeadingPositions, - restoreFoldedHeadings, -} from "./texto/extensions/heading/folding"; -import { - getFoldedTaskPositions, - restoreFoldedTasks, -} from "./texto/extensions/task-item-folding"; + createFoldPersistence, + type FoldPersistence, +} from "./storage/foldPersistence"; import { handleAddImg, imageOnSetViewProps, @@ -128,13 +125,9 @@ export class NoteView extends FileView { private keyboardViewportCleanup: (() => void) | null = null; private leafContentWithNoteClass: HTMLElement | null = null; private searchEl: HTMLElement | null = null; - /** Last fold positions saved to localStorage, per fold kind; guards - * redundant writes. */ - private lastSavedFolds: { heading: number[] | null; task: number[] | null } = - { - heading: null, - task: null, - }; + /** Device-local fold persistence (headings + task items), created lazily + * with a live scope — see src/storage/foldPersistence.ts. */ + private foldStore: FoldPersistence | null = null; // ── External file change watching ── /** Whether the editor holds changes not yet written to disk. */ @@ -164,7 +157,7 @@ export class NoteView extends FileView { } getIcon(): string { - return "notebook-pen"; + return NOTE_ICON_NAME; } canAcceptExtension(extension: string): boolean { @@ -462,8 +455,8 @@ export class NoteView extends FileView { this.dirty = true; this.scheduleSave(); }, - onTransaction: ({ transaction }) => { - this.syncFoldState(transaction); + onTransaction: () => { + this.syncFoldState(); }, extensions: getExtensions( this.buildExtensionHooks(file, editorRef, ctx), @@ -993,144 +986,30 @@ export class NoteView extends FileView { }, AUTOSAVE_DELAY); } - /** localStorage key for a note's fold state of the given kind. Mirrors the - * storage approach of Obsidian's own foldManager (`note-fold-`): the - * fold state is vault-local metadata, never part of the note document. - * Kind prefixes: `inscriptum-note-fold-` (headings, existing key), - * `inscriptum-task-fold-` (task subtasks). */ - private foldStorageKey(kind: "heading" | "task"): string | null { - const path = this.file?.path; - if (!path) return null; - const prefix = - kind === "heading" ? "inscriptum-note-fold-" : "inscriptum-task-fold-"; - return `${prefix}${path}`; - } - - private loadFoldStorage(key: string): unknown { - // app.loadLocalStorage exists since v1.8.7 (typed API), but minAppVersion - // is older — access it structurally so both paths are used and the - // version rule stays satisfied. It namespaces the value per vault like - // Obsidian's own foldManager does. - const app = this.app as unknown as { - loadLocalStorage?: (k: string) => unknown; - }; - if (typeof app.loadLocalStorage === "function") { - return app.loadLocalStorage(key); - } - try { - const raw = window.localStorage.getItem(key); - return raw ? JSON.parse(raw) : null; - } catch { - return null; - } - } - - private saveFoldStorage(key: string, data: unknown): void { - const app = this.app as unknown as { - saveLocalStorage?: (k: string, value: unknown) => void; - }; - if (typeof app.saveLocalStorage === "function") { - app.saveLocalStorage(key, data); - return; - } - try { - if (data == null) { - window.localStorage.removeItem(key); - } else { - window.localStorage.setItem(key, JSON.stringify(data)); - } - } catch { - // localStorage unavailable (private mode etc.) — folds just won't - // persist, the editor itself is unaffected. + /** Device-local fold persistence for this note, created lazily. The scope + * (file path) is resolved live, so a rename-to-title keeps landing on the + * current key. */ + private folds(): FoldPersistence { + if (this.foldStore == null) { + this.foldStore = createFoldPersistence( + this.app, + () => this.file?.path ?? null, + ); } + return this.foldStore; } /** Persist folded positions for both foldable node kinds (headings and * task items). Called from the editor's onTransaction; the folding * extensions keep the current positions in their storages. */ - private syncFoldState(_transaction: unknown): void { + private syncFoldState(): void { if (this.isMobileView() || !this.editor) return; - - this.syncFoldTarget("heading", "headingFolding"); - this.syncFoldTarget("task", "taskItemFolding"); - } - - private syncFoldTarget(kind: "heading" | "task", storageName: string): void { - if (!this.editor) return; - - const positions = ( - this.editor.storage[storageName] as { positions?: number[] } | undefined - )?.positions; - if (positions == null || positions === this.lastSavedFolds[kind]) return; - - const key = this.foldStorageKey(kind); - if (!key) return; - - // Same guard as Obsidian's foldManager: an empty fold list clears the - // stored value instead of persisting `[]`. - this.lastSavedFolds[kind] = positions; - this.saveFoldStorage( - key, - positions.length > 0 ? { folds: positions } : null, - ); + this.folds().sync(this.editor); } /** Restore folds saved for this note into a freshly created editor. */ private restoreFoldState(editor: Editor): void { - this.restoreFoldTarget( - editor, - "heading", - "headingFolding", - "heading", - (view, positions) => restoreFoldedHeadings(view, positions), - ); - this.restoreFoldTarget( - editor, - "task", - "taskItemFolding", - "taskItem", - (view, positions) => restoreFoldedTasks(view, positions), - ); - } - - private restoreFoldTarget( - editor: Editor, - kind: "heading" | "task", - storageName: string, - nodeTypeName: string, - restore: (view: Editor["view"], positions: number[]) => void, - ): void { - const key = this.foldStorageKey(kind); - if (!key) return; - - const saved = this.loadFoldStorage(key) as - | { folds?: number[] } - | null - | undefined; - const folds = saved?.folds; - if (!Array.isArray(folds) || folds.length === 0) return; - - // Positions saved from a previous session may not match this doc if the - // note was edited elsewhere; keep only positions that still point at the - // expected node kind. (The plugin also drops them on later edits via - // mapping.) - const valid = folds.filter((pos) => { - if (typeof pos !== "number" || !Number.isFinite(pos)) return false; - const node = editor.state.doc.nodeAt(pos); - return node?.type.name === nodeTypeName; - }); - if (valid.length === 0) return; - - restore(editor.view, valid); - const positions = - kind === "heading" - ? getFoldedHeadingPositions(editor.state) - : getFoldedTaskPositions(editor.state); - const storage = editor.storage[storageName] as - | { positions?: number[] } - | undefined; - if (storage) storage.positions = positions; - this.lastSavedFolds[kind] = positions; + this.folds().restore(editor); } private async flushSave(trigger = "autosave"): Promise { diff --git a/src/NotepadView.ts b/src/NotepadView.ts new file mode 100644 index 0000000..c94092f --- /dev/null +++ b/src/NotepadView.ts @@ -0,0 +1,1990 @@ +import { + FileView, + Menu, + Notice, + Platform, + setIcon, + TFile, + WorkspaceLeaf, +} from "obsidian"; +import { CellSelection, isInTable } from "prosemirror-tables"; +import { Editor, isTextSelection } from "./texto/core"; +import { + createPhysicalShortcutPlugin, + isForwardedShortcut, + markForwardedShortcut, + matchPressedCommand, + nameToKeyboardEvent, +} from "./tools/isPressedCommand"; +import { + createDocumentSearchPlugin, + documentSearchKey, + findDocumentMatches, +} from "./search/documentSearch"; +import { + searchNotepad, + type NotepadSearchMatch, +} from "./notepad/notepadSearch"; +import { getExtensions, type ExtensionHooks } from "./texto/getExtensions"; +import { + createFoldPersistence, + type FoldPersistence, +} from "./storage/foldPersistence"; +import { readUmFile, umFingerprint, writeUmFile } from "./storage/um/umVault"; +import { UmNotepad } from "./storage/um/umNotepad"; +import { UmError, UM_SCHEMA_TITLE } from "./storage/um/umTypes"; +import { FileChangedModal } from "./ui/FileChangedModal"; +import { ConfirmModal } from "./ui/ConfirmModal"; +import { + saveAttachmentFile, + deleteAttachmentFile, +} from "./storage/attachments"; +import { + handleAddImgContainer, + imageOnSetViewPropsContainer, +} from "./notepad/imageTools"; +import type { ImageToolContext } from "./tools/image"; +import { elTag } from "./tags"; +import { NoteElement } from "./components/note/note.element"; +import { ToolbarElement } from "./components/toolbar/toolbar.element"; +import { BubbleMenuBarElement } from "./components/bubble-menu-bar/bubble-menu-bar.element"; +import { TableBubbleMenuElement } from "./components/bubble-menu-bar/table-bubble-menu-bar.element"; +import { MediaBubbleMenuElement } from "./components/bubble-menu-bar/media-bubble-menu-bar.element"; +import { isMediaNodeSelection } from "./components/bubble-menu-bar/mediaMenuState"; +import { + bubbleMenuPlugin, + type BubbleMenuView, + type ShouldShowProps, +} from "./texto/extensions/bubble-menu"; +import type { JSONContent } from "./texto/core/@types"; +import "./components/note/note.element"; +import "./components/toolbar/toolbar.element"; +import "./components/bubble-menu-bar/bubble-menu-bar.element"; +import "./components/bubble-menu-bar/table-bubble-menu-bar.element"; +import "./components/bubble-menu-bar/media-bubble-menu-bar.element"; +import "./styles/notepad.css"; + +export const NOTEPAD_VIEW_TYPE = "notepad-view"; + +/** The strip the open nav drawer occupies: 300px wide including its 1px + * right border (box-sizing: border-box in the app theme). Must stay in + * sync with the scroller margin-left in notepad.css. */ +const NAV_WIDTH = 300; + +const AUTOSAVE_DELAY = 500; +/** Coalescing window for vault "modify" events (mirrors NoteView). */ +const EXTERNAL_CHANGE_DEBOUNCE = 300; + +/** True while a forwarded `editor.commands` dispatch is in flight (see + * handleEditorShortcut). */ +let dispatchingEditorShortcut = false; + +/** + * Construct a versioned custom element, surviving a plugin reload without + * an app restart. tags.ts versions element tags so a fresh bundle can + * register fresh classes — but after a reload the registry still holds the + * PREVIOUS build's class, and `new FreshClass()` is an Illegal constructor. + * Falling back to createElement picks up whatever class the registry holds + * (structurally identical DOM and props), so the view keeps working until + * the app restarts. + */ +function createCustomElement(baseTag: string, make: () => T): T { + try { + return make(); + } catch { + return document.createElement(elTag(baseTag)) as unknown as T; + } +} + +const makeNoteElement = () => + createCustomElement("texto-editor", () => new NoteElement()); +const makeToolbarElement = () => + createCustomElement( + "note-toolbar", + () => new ToolbarElement(), + ); +const makeBubbleMenuBarElement = () => + createCustomElement("bubble-menu-bar", () => new BubbleMenuBarElement()); +const makeTableBubbleMenuElement = () => + createCustomElement( + "table-bubble-menu-bar", + () => new TableBubbleMenuElement(), + ); +const makeMediaBubbleMenuElement = () => + createCustomElement( + "media-bubble-menu-bar", + () => new MediaBubbleMenuElement(), + ); + +/** The blog draft view's ☰ (tabler menu-2: 38×38, hairline stroke 1). */ +function menuSvg(): SVGSVGElement { + const ns = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(ns, "svg"); + svg.setAttribute("class", "icon-menu"); + svg.setAttribute("width", "38"); + svg.setAttribute("height", "38"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", "1"); + svg.setAttribute("stroke-linecap", "round"); + svg.setAttribute("stroke-linejoin", "round"); + for (const y of ["6", "12", "18"]) { + const line = document.createElementNS(ns, "line"); + line.setAttribute("x1", "4"); + line.setAttribute("y1", y); + line.setAttribute("x2", "20"); + line.setAttribute("y2", y); + svg.appendChild(line); + } + return svg; +} + +/** The ✕ that replaces ☰ while the drawer is open. */ +function closeSvg(): SVGSVGElement { + const ns = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(ns, "svg"); + svg.setAttribute("class", "icon-close"); + svg.setAttribute("width", "38"); + svg.setAttribute("height", "38"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", "1"); + svg.setAttribute("stroke-linecap", "round"); + svg.setAttribute("stroke-linejoin", "round"); + for (const [x1, y1, x2, y2] of [ + ["7", "7", "17", "17"], + ["17", "7", "7", "17"], + ]) { + const line = document.createElementNS(ns, "line"); + line.setAttribute("x1", x1); + line.setAttribute("y1", y1); + line.setAttribute("x2", x2); + line.setAttribute("y2", y2); + svg.appendChild(line); + } + return svg; +} + +/** Chevron toggle icon for a section gutter — copied from the blog draft + * view: 44×44 tabler chevron, hairline 0.5 stroke (folded state; CSS + * rotates it when expanded). */ +function chevronSvg(): SVGSVGElement { + const ns = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(ns, "svg"); + svg.setAttribute("width", "44"); + svg.setAttribute("height", "44"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", "0.5"); + svg.setAttribute("stroke-linecap", "round"); + svg.setAttribute("stroke-linejoin", "round"); + const polyline = document.createElementNS(ns, "polyline"); + polyline.setAttribute("points", "9 6 15 12 9 18"); + svg.appendChild(polyline); + return svg; +} + +interface SectionHandle { + id: string; + root: HTMLElement; + /** Bordered page frame: holds the content plus the controls row, so the + * bottom separator visually groups the controls with their page. */ + body: HTMLElement; + /** The part that swaps between the collapsed title row and the editor. */ + content: HTMLElement; + noteEl: NoteElement | null; + editor: Editor | null; + editorRef: { current: Editor | null }; + bubbleEls: HTMLElement[]; + /** Device-local persistence of this page's in-document fold state + * (headings + task items), created lazily — see foldPersistence.ts. */ + folds: FoldPersistence | null; +} + +/** + * View for `.um` containers (UM spec): a flat document of collapsed + * sections, one section per note, in manifest order. Sections expand lazily + * — a ProseMirror editor is created only for the expanded note, and its + * content is written back into the in-memory notepad on collapse and on + * save. Images inside notepad notes are packed into the container as + * assets; other attachments stay external vault files. + */ +export class NotepadView extends FileView { + /** Called whenever a page editor is created; lets the plugin host sync + * the set of physically intercepted shortcut keys (see main.ts and + * src/tools/isPressedCommand.ts). */ + static onEditorCreated: ((editor: Editor) => void) | null = null; + + private notepad: UmNotepad | null = null; + private sections = new Map(); + /** Expansion state lives on the note descriptors in the manifest + * (`expanded`, persisted with the container); a note without the flag is + * collapsed — the spec's initial default (section 14). */ + /** Expansion builds in flight, to prevent double editor creation for the + * same note. */ + private pendingExpansions = new Set(); + + private scrollerEl: HTMLElement | null = null; + private sectionsEl: HTMLElement | null = null; + private toolbarHost: HTMLElement | null = null; + private toolbarEl: ToolbarElement | null = null; + + /** The page editor the user last focused — hotkey routing and the + * toolbar target it. */ + private focusedEditorValue: Editor | null = null; + + // ── Notepad-wide search ── + private searchPanelEl: HTMLElement | null = null; + private searchInputEl: HTMLInputElement | null = null; + private searchCountEl: HTMLElement | null = null; + private searchMatches: NotepadSearchMatch[] = []; + private searchIndex = 0; + + // ── Pages navigation sidebar (blog-style, hidden by default) ── + private navEl: HTMLElement | null = null; + private navListEl: HTMLElement | null = null; + private navOpen = false; + /** Guards the one-time default-open decision (open when the drawer + * fits); later resizes never override the user's own choice. */ + private navBooted = false; + private navResize: ResizeObserver | null = null; + /** Settler of the in-flight nav-flip scrollbar guard (see + * flipContentLayout); null when no flip is animating. */ + private flipSettle: (() => void) | null = null; + /** Title signature the nav list currently renders (see + * navTitleSignature); the early exit for save-time refreshes. */ + private navTitles: string | null = null; + + // ── Drag & drop reorder ── + private drag: { + id: string; + pointerId: number; + startX: number; + startY: number; + active: boolean; + } | null = null; + private dropLineEl: HTMLElement | null = null; + private suppressGutterToggle = false; + + private dirty = false; + private saveTimer: number | null = null; + private externalChangeTimer: number | null = null; + private conflictModalOpen = false; + + /** The page editor the user last focused; hotkey routing and command + * patching target it (see main.ts routeToNoteView). */ + get focusedEditor(): Editor | null { + if (this.focusedEditorValue?.isDestroyed) return null; + return this.focusedEditorValue; + } + + constructor(leaf: WorkspaceLeaf) { + super(leaf); + } + + getViewType(): string { + return NOTEPAD_VIEW_TYPE; + } + + getDisplayText(): string { + return this.file?.basename ?? "Notepad"; + } + + getIcon(): string { + return "notepad"; + } + + canAcceptExtension(extension: string): boolean { + return extension === "um"; + } + + onPaneMenu(menu: Menu, source: string): void { + super.onPaneMenu(menu, source); + menu.addItem((item) => + item + .setTitle("Find in notepad") + .setIcon("search") + .onClick(() => this.openNotepadSearch()), + ); + menu.addItem((item) => + item + .setTitle("Add note") + .setIcon("plus") + .onClick(() => this.addNote()), + ); + } + + /** Hotkey routing into the focused page's editor (see main.ts and + * tools/isPressedCommand). Returns false when the event was consumed. */ + handleEditorShortcut(event: KeyboardEvent): false | undefined { + if (this.app.workspace.getActiveViewOfType(NotepadView) !== this) return; + if (isForwardedShortcut(event)) return; + if (dispatchingEditorShortcut) return; + const editor = this.focusedEditorValue; + if (!editor) return; + + // Find owns Mod+F (opens the notepad-wide search). + if ( + event.code === "KeyF" && + (event.metaKey || event.ctrlKey) && + !event.altKey && + !event.shiftKey + ) { + event.preventDefault(); + event.stopPropagation(); + this.openNotepadSearch(); + return false; + } + + // Mod+K opens the link layer: re-dispatch a Latin-keyed synthetic event + // so the bubble menu's layout-dependent check works on any layout. + if ( + event.code === "KeyK" && + (event.metaKey || event.ctrlKey) && + !event.altKey && + !event.shiftKey + ) { + event.preventDefault(); + event.stopPropagation(); + this.forwardLatinShortcut(event); + return false; + } + + const shortcut = matchPressedCommand(event, editor.registeredShortcuts); + if (!shortcut) return; + + event.preventDefault(); + event.stopPropagation(); + dispatchingEditorShortcut = true; + try { + const reDispatch = nameToKeyboardEvent(shortcut); + if (reDispatch) { + markForwardedShortcut(reDispatch); + editor.view.someProp("handleKeyDown", (f) => + f(editor.view, reDispatch), + ); + } + } finally { + dispatchingEditorShortcut = false; + } + return false; + } + + private forwardLatinShortcut(event: KeyboardEvent): void { + const forwarded = new KeyboardEvent("keydown", { + key: "k", + code: "KeyK", + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + altKey: event.altKey, + shiftKey: event.shiftKey, + bubbles: true, + cancelable: true, + }); + markForwardedShortcut(forwarded); + document.dispatchEvent(forwarded); + } + + async onOpen(): Promise { + this.contentEl.empty(); + this.contentEl.addClass("notepad-view-container"); + // Mirrors NoteView: the CSS uses the class to scope desktop-only rules + // (e.g. the fixed page column) away from phones/tablets. + if (Platform.isMobile) { + this.contentEl.addClass("is-mobile"); + if (Platform.isPhone) this.contentEl.addClass("is-phone"); + } + + this.toolbarHost = this.contentEl.createDiv("notepad-toolbar-host"); + // Blog-style pages navigation drawer (hidden by default, ☰ toggle). + this.buildNavSidebar(); + + this.scrollerEl = this.contentEl.createDiv("notepad-scroller"); + this.sectionsEl = this.scrollerEl.createDiv("notepad-sections"); + + // Overlay case (the column does not fit beside the drawer, and mobile): + // a click on the content dismisses the open drawer — there the drawer + // covers the column. Capture phase, so an editor-internal stopPropagation + // cannot suppress the dismissal. + this.scrollerEl.addEventListener( + "click", + () => { + if (this.navOpen && !this.navFits()) + this.setNavOpen(false, { animate: true }); + }, + true, + ); + + // The boot decision must see the real view width; the observer's + // initial callback re-runs it after the view is laid out (a 0-width + // container at onOpen would measure wrong). + this.navResize = new ResizeObserver(() => this.updateNavMode()); + this.navResize.observe(this.contentEl); + this.updateNavMode(); + + // Watch for external modifications (git sync etc.) — same contract as + // NoteView, with a fingerprint instead of a raw string diff. + this.registerEvent( + this.app.vault.on("modify", (file) => { + if (file === this.file) this.onFileMaybeExternallyChanged(); + }), + ); + + this.registerEvent( + this.app.workspace.on("active-leaf-change", async (leaf) => { + if (leaf?.view !== this) await this.flushSave("leaf-blur"); + }), + ); + } + + async onLoadFile(file: TFile): Promise { + this.destroyAllSections(); + const notepad = await this.readNotepadWithRetries(file); + if (notepad == null) { + this.renderUnreadableFileState(file); + return; + } + this.notepad = notepad; + notepad.savedFingerprint = await umFingerprint(this.app.vault, file); + // Expansion state comes from the manifest (`expanded` per note); notes + // without the flag start collapsed (spec section 14). + this.render(); + } + + async onUnloadFile(_file: TFile): Promise { + await this.flushSave("unload-file"); + this.destroyAllSections(); + } + + async onClose(): Promise { + await this.flushSave("close"); + this.navResize?.disconnect(); + this.navResize = null; + this.destroyAllSections(); + this.contentEl.empty(); + this.notepad?.destroy(); + this.notepad = null; + } + + // ── Loading ── + + private async readNotepadWithRetries( + file: TFile, + attempts = 3, + delayMs = 400, + ): Promise { + for (let i = 0; i < attempts; i++) { + try { + return await readUmFile(file, this.app.vault); + } catch (err) { + if (err instanceof UmError) { + // A container that fails manifest validation stays broken — + // retrying cannot fix it. + console.error(`Failed to open notepad "${file.path}":`, err); + return null; + } + console.error( + `Failed to read notepad "${file.path}" (attempt ${i + 1}/${attempts}):`, + err, + ); + if (i < attempts - 1) { + await new Promise((resolve) => window.setTimeout(resolve, delayMs)); + } + } + } + return null; + } + + private renderUnreadableFileState(file: TFile): void { + this.contentEl.empty(); + const box = this.contentEl.createDiv({ cls: "inscriptum-unreadable-note" }); + box.createEl("p", { + cls: "inscriptum-unreadable-note-title", + text: "This notepad could not be opened.", + }); + box.createEl("p", { + text: + `The file "${file.path}" is not a valid UM container. ` + + "Nothing was loaded or saved — the file on disk was not modified.", + }); + } + + // ── Rendering ── + + private render(): void { + if (!this.notepad || !this.sectionsEl) return; + // Keep unsaved editor content before tearing the sections down. + for (const handle of this.sections.values()) { + this.flushSectionToNotepad(handle); + } + this.destroyAllSections(); + this.sectionsEl.empty(); + + for (const descriptor of this.notepad.notes()) { + this.sectionsEl.appendChild( + this.buildSection( + descriptor.id, + descriptor.title ?? "", + descriptor.order, + ), + ); + } + // Per-section controls cover add-after everywhere; this row closes the + // list so the end of the notepad is always a one-click add. Shown even + // when pages exist — the owner's call (2026-09-27); the empty-notepad + // dead-end guard is just its first use. + this.sectionsEl.appendChild(this.buildAddNoteRow()); + + // Re-expand notes marked expanded in the manifest (state survives + // reloads and moves with the file). The title page is the exception + // (spec 9.1): it is always expanded, whatever the manifest says — and + // pages this editor cannot open never mount an editor at all. + for (const descriptor of this.notepad.notes()) { + if (!this.notepad.isNoteOpenable(descriptor.id)) continue; + if (descriptor.order === 0 || this.notepad.isExpanded(descriptor.id)) { + void this.expandSection(descriptor.id, { focus: false }); + } + } + this.rebuildNavList(); + } + + private buildSection(id: string, title: string, order: number): HTMLElement { + const root = createDiv("notepad-section"); + // The title page (order 0) is the fixed cover: no fold chevron, no + // duplicate/delete, no left frame rule. Pages this editor cannot + // interpret (newer schema version, unknown family, failed migration) + // show a notice instead of content and are preserved verbatim. + const isTitlePage = order === 0; + const schemaState = this.notepad?.noteSchemaState(id); + const isUnsupported = + schemaState != null && schemaState.kind !== "openable"; + if (isTitlePage) root.addClass("is-title"); + if (isUnsupported) root.addClass("is-unsupported"); + + // Left margin: page order (the first page is unlabeled, like the blog + // draft view) + the fold chevron for foldable pages. + const margin = root.createDiv("notepad-section-margin"); + const orderLabel = margin.createDiv("notepad-section-order"); + if (order > 0) orderLabel.setText(String(order)); + if (!isTitlePage && !isUnsupported) { + const gutter = createEl("button", { cls: "notepad-section-gutter" }); + gutter.setAttribute("aria-label", "Toggle note"); + gutter.appendChild(chevronSvg()); + // Click toggles the fold; a pointer drag reorders the page. + gutter.addEventListener("click", () => { + if (this.suppressGutterToggle) { + this.suppressGutterToggle = false; + return; + } + this.toggleSection(id); + }); + gutter.addEventListener("pointerdown", (event) => + this.onGutterPointerDown(event, id), + ); + margin.appendChild(gutter); + } + + const body = root.createDiv("notepad-section-body"); + const content = body.createDiv("notepad-section-content"); + + // Ghost controls under the section content but INSIDE the page frame + // (above its bottom separator) — so they visibly belong to this page. + // The title page carries no duplicate/delete control — it anchors the + // notepad (spec 9.1); unopenable pages are preserved as-is. + const controls = body.createDiv("notepad-section-controls"); + const addBtn = createEl("button", { cls: "notepad-section-control" }); + addBtn.setAttribute("aria-label", "Add note after"); + setIcon(addBtn, "plus"); + addBtn.addEventListener("click", () => this.addNoteAfter(id)); + controls.appendChild(addBtn); + if (!isTitlePage && !isUnsupported) { + const copyBtn = createEl("button", { cls: "notepad-section-control" }); + copyBtn.setAttribute("aria-label", "Duplicate note"); + setIcon(copyBtn, "copy"); + copyBtn.addEventListener("click", () => this.duplicateNote(id)); + controls.appendChild(copyBtn); + const delBtn = createEl("button", { + cls: "notepad-section-control is-danger", + }); + delBtn.setAttribute("aria-label", "Delete note"); + setIcon(delBtn, "trash-2"); + delBtn.addEventListener("click", () => this.deleteNote(id)); + controls.appendChild(delBtn); + } + + const handle: SectionHandle = { + id, + root, + body, + content, + noteEl: null, + editor: null, + editorRef: { current: null }, + bubbleEls: [], + folds: null, + }; + this.sections.set(id, handle); + + if (isUnsupported) { + this.renderUnsupportedNotice(handle, schemaState); + } else if (!isTitlePage) { + // The title page is always expanded — its content is built by + // expandSection right away; no collapsed row exists for it. + this.renderCollapsedTitle(handle, title); + } + return root; + } + + /** The notice replacing a page this editor cannot open (spec 8.6.2). */ + private renderUnsupportedNotice( + handle: SectionHandle, + state: NonNullable>, + ): void { + handle.content.empty(); + const box = handle.content.createDiv("notepad-section-unsupported"); + const title = box.createDiv("notepad-section-unsupported-title"); + const text = box.createDiv("notepad-section-unsupported-text"); + if (state.kind === "invalid") { + title.setText("This page could not be converted for editing"); + text.setText("Its content is kept unchanged in the file."); + } else { + title.setText("This page was created in a newer version"); + text.setText( + "Update Inscriptum to open and edit it. The content is kept unchanged in the file.", + ); + } + } + + /** The one-line collapsed representation: the note title, or a muted + * placeholder when empty. The only click action is expanding — renaming + * happens by editing the page's first line (the title source of truth, + * um-title-sync); a dedicated rename control was removed (owner's call, + * 2026-09-27). */ + private renderCollapsedTitle(handle: SectionHandle, title: string): void { + handle.content.empty(); + const row = handle.content.createDiv("notepad-section-collapsed"); + const label = row.createDiv("notepad-section-title"); + if (title.trim().length > 0) { + label.setText(title); + } else { + label.addClass("is-empty"); + label.setText("Untitled"); + } + row.addEventListener("click", () => + this.toggleSection(handle.id, { expand: true }), + ); + } + + /** Re-render the collapsed title text after inline editing. */ + private renderSectionTitleOnly(handle: SectionHandle): void { + if (this.notepad?.isExpanded(handle.id)) return; + const descriptor = this.notepad?.note(handle.id); + this.renderCollapsedTitle(handle, descriptor?.title ?? ""); + } + + /** Non-interactive header row for the title page when its editor could + * not be created: no fold toggle, no rename — just the title text. */ + private renderStaticTitleRow(handle: SectionHandle): void { + handle.content.empty(); + const descriptor = this.notepad?.note(handle.id); + const title = descriptor?.title ?? ""; + const row = handle.content.createDiv("notepad-section-collapsed is-static"); + const label = row.createDiv("notepad-section-title"); + if (title.trim().length > 0) { + label.setText(title); + } else { + label.addClass("is-empty"); + label.setText("Untitled"); + } + } + + private buildAddNoteRow(): HTMLElement { + const row = createEl("button", { cls: "notepad-add-note" }); + row.setText("Add note"); + row.addEventListener("click", () => this.addNote()); + return row; + } + + // ── Expand / collapse ── + + private toggleSection(id: string, opts?: { expand?: boolean }): void { + // The title page is the fixed cover: never folded, never expanded by + // the fold toggle (spec 9.1). + const descriptor = this.notepad?.note(id); + if (!descriptor || descriptor.order === 0) return; + const isFolded = !this.notepad?.isExpanded(id); + if (!isFolded && opts?.expand === true) return; // already expanded + if (isFolded) void this.expandSection(id, { focus: true }); + else void this.collapseSection(id); + } + + private async expandSection( + id: string, + opts?: { focus?: boolean; autofocus?: boolean }, + ): Promise { + const notepad = this.notepad; + const handle = this.sections.get(id); + if (!notepad || !handle) return; + // Pages this editor cannot interpret (newer version, unknown family, + // failed migration) show a notice instead of an editor (spec 8.6.2). + if (!notepad.isNoteOpenable(id)) return; + const content = notepad.noteContent(id); + if (content == null) return; + // Already expanded (or expansion in flight) — nothing to build. + if (handle.editor != null || this.pendingExpansions.has(id)) { + notepad.setExpanded(id, true); + this.scheduleSave(); + if (opts?.focus) handle.editor?.view.focus(); + return; + } + + this.pendingExpansions.add(id); + try { + notepad.setExpanded(id, true); + this.scheduleSave(); + handle.root.addClass("is-expanded"); + handle.content.empty(); + + const noteEl = makeNoteElement(); + noteEl.addClass("notepad-note-host"); + handle.content.appendChild(noteEl); + handle.noteEl = noteEl; + + const editor = await this.createEditorForSection(handle, content, { + autofocus: opts?.autofocus, + }); + if (editor == null) { + handle.noteEl = null; + noteEl.remove(); + if (notepad.note(handle.id)?.order === 0) { + // The title page never folds (spec 9.1): keep the section open + // and show a static header row instead of the collapsed title. + this.renderStaticTitleRow(handle); + return; + } + // The editor container never rendered — fold the section back + // instead of showing a dead body. + handle.root.removeClass("is-expanded"); + notepad.setExpanded(id, false); + this.scheduleSave(); + this.renderSectionTitleOnly(handle); + return; + } + handle.editor = editor; + if (opts?.focus) editor.view.focus(); + } finally { + this.pendingExpansions.delete(id); + } + } + + private async collapseSection(id: string): Promise { + const handle = this.sections.get(id); + if (!handle || this.notepad?.isExpanded(id) !== true) return; + + // Flush the editor content into the in-memory notepad first. + this.flushSectionToNotepad(handle); + + const ownedToolbar = this.toolbarEl?.props.editor === handle.editor; + if (this.focusedEditorValue === handle.editor) { + this.focusedEditorValue = null; + } + + this.notepad.setExpanded(id, false); + this.scheduleSave(); + handle.root.removeClass("is-expanded"); + for (const el of handle.bubbleEls) el.remove(); + handle.bubbleEls = []; + // Detach the dying editor before rebinding: the toolbar must not be + // handed to an editor that is about to be destroyed. + handle.editor = null; + handle.editorRef.current = null; + if (ownedToolbar) this.rebindOrIdleToolbar(); + // Removing the element destroys the editor (NoteElement cleanup). + handle.noteEl?.remove(); + handle.noteEl = null; + handle.content.empty(); + + const descriptor = this.notepad?.note(id); + this.renderCollapsedTitle(handle, descriptor?.title ?? ""); + // The flush above may have synced a title edited in the page's first + // line — the nav list must show it right away, not after the next + // rebuild (nav open / add / delete). + this.rebuildNavList(); + } + + /** Copy the live editor JSON into the notepad model (change-gated, so + * idempotent flushes don't mark the notepad dirty). Notes that were + * removed from the manifest are skipped — their editor flush must not + * resurrect them. */ + private flushSectionToNotepad(handle: SectionHandle): void { + if (!this.notepad || !handle.editor || handle.editor.isDestroyed) return; + if (!this.notepad.note(handle.id)) return; + const json = handle.editor.getJSON(); + const current = this.notepad.noteContent(handle.id); + if (current != null && JSON.stringify(current) === JSON.stringify(json)) { + return; + } + this.notepad.setNoteContent(handle.id, json); + } + + private destroyAllSections(): void { + for (const handle of this.sections.values()) { + for (const el of handle.bubbleEls) el.remove(); + handle.noteEl?.remove(); + } + this.sections.clear(); + this.pendingExpansions.clear(); + // Removing the hosts destroys their editors (NoteElement cleanup). + this.focusedEditorValue = null; + if (this.sectionsEl) this.sectionsEl.empty(); + this.showIdleToolbar(); + } + + // ── Editor creation ── + + private async createEditorForSection( + handle: SectionHandle, + content: JSONContent, + opts?: { autofocus?: boolean }, + ): Promise { + const notepad = this.notepad; + if (!notepad) return null; + + // The gfc custom element renders its container asynchronously — + // retry like NoteView does for the main editor host. + const editorEl = await this.waitForEditorElement(handle.noteEl); + if (editorEl == null) return null; + // The section was collapsed or the view torn down while waiting for + // the container: building now would bind a detached editor to the + // shared toolbar and resurrect a dead handle. + if (handle.noteEl == null || this.sections.get(handle.id) !== handle) { + return null; + } + + const editorRef = handle.editorRef; + const ctx: ImageToolContext = { + app: this.app, + noteFile: this.file as TFile, + }; + const isMobile = Platform.isMobile; + // The schema family picks the editor profile (spec 8.6): title pages + // get the title/summary header, regular pages a title-less top node. + const schemaState = notepad.noteSchemaState(handle.id); + if (schemaState.kind !== "openable") return null; + const profile = schemaState.family === UM_SCHEMA_TITLE ? "title" : "plain"; + + const editor = new Editor({ + element: editorEl, + content, + onError: (err) => console.error("Notepad editor creation failed:", err), + onUpdate: () => { + this.dirty = true; + this.scheduleSave(); + }, + onTransaction: () => { + // In-document fold persistence (headings + task items). Fold toggles + // carry no doc change, so this never dirties the container. + if (!isMobile) this.syncSectionFolds(handle); + }, + extensions: getExtensions( + this.buildExtensionHooks(notepad, editorRef, ctx), + { isMobileView: isMobile, profile }, + ), + // Search jumps mount pages in the background and must not steal + // focus from the search input. + autofocus: opts?.autofocus === false ? false : "start", + }); + + if (editor.view == null) { + console.error("Notepad editor init failed for note", handle.id); + return null; + } + editorRef.current = editor; + // NoteElement's cleanup destroys props.editor when the host element is + // removed (collapse, page removal, view close) — without this the + // editor instance and its plugins outlive the page. + if (handle.noteEl != null) { + handle.noteEl.props.editor = editor; + } + + editor.on("focus", () => { + this.focusedEditorValue = editor; + if (!isMobile) this.ensureToolbar(editor); + this.updateNavActive(); + }); + + editor.registerPlugin(createDocumentSearchPlugin()); + // Non-Latin keyboard layouts: resolve Cmd/Ctrl+letter combos by physical + // key code and route them through the view (see tools/isPressedCommand). + editor.registerPlugin( + createPhysicalShortcutPlugin({ + getCommands: () => editor.registeredShortcuts, + handleShortcut: (event) => this.handleEditorShortcut(event) === false, + }), + ); + + if (!isMobile) { + // The toolbar is always visible: bind it to the most recently + // expanded page right away (focus re-binds it later). + this.ensureToolbar(editor); + this.createBubbleMenus(editor, handle); + // Restore heading/task folds saved for this page (desktop only — + // folding is disabled on mobile; mirrors NoteView's device-local + // fold persistence). + this.sectionFolds(handle).restore(editor); + } + NotepadView.onEditorCreated?.(editor); + return editor; + } + + /** Device-local fold persistence for a page, created lazily. The scope is + * `/` — a page has no file of its own, and a file + * path can never have a segment below it, so the join is unambiguous; + * the container path is resolved live (rename-safe). */ + private sectionFolds(handle: SectionHandle): FoldPersistence { + if (handle.folds == null) { + handle.folds = createFoldPersistence(this.app, () => + this.file ? `${this.file.path}/${handle.id}` : null, + ); + } + return handle.folds; + } + + /** Persist the page editor's fold positions (no-op before the editor is + * fully built — onTransaction can fire during editor construction). */ + private syncSectionFolds(handle: SectionHandle): void { + const editor = handle.editorRef.current; + if (!editor || editor.isDestroyed) return; + this.sectionFolds(handle).sync(editor); + } + + private async waitForEditorElement( + noteEl: NoteElement | null, + tries = 120, + ): Promise { + for (let i = 0; i < tries; i++) { + const el = noteEl?.props.editorContainerEl?.value; + if (el != null) return el; + await new Promise((resolve) => window.setTimeout(resolve, 50)); + } + return null; + } + + private buildExtensionHooks( + notepad: UmNotepad, + editorRef: { current: Editor | null }, + ctx: ImageToolContext, + ): ExtensionHooks { + const app = this.app; + return { + state: { + onAdd: (node, deco) => { + if (node.type.name !== "image") return; + const key = (deco.spec as { id: string }).id; + handleAddImgContainer({ ...node.attrs, key }, editorRef, notepad); + }, + onRemove: () => { + // Packed assets are garbage-collected at save time; external + // attachment files are removed via their explicit delete button + // (attachment.onDeleteFile), not on node removal — matching the + // plain-note policy of not touching files on undo/cut. + }, + }, + image: { + onSetViewProps: (props, update) => + imageOnSetViewPropsContainer(props, update, ctx, notepad), + }, + attachment: { + onFileSelected: async (file, update) => { + if (!file) return; + update({ + state: { + fileStatus: "loading", + text: "Loading…", + subtext: "", + preparedData: undefined, + }, + data: undefined, + }); + try { + const saved = await saveAttachmentFile( + app, + this.file as TFile, + file, + ); + update({ + state: { + fileStatus: "attached", + src: saved.src, + text: saved.filename, + subtext: "", + preparedData: undefined, + }, + data: { + id: saved.id, + size: saved.size, + filename: saved.filename, + }, + }); + } catch (err) { + update({ + state: { + fileStatus: "none", + text: String(err), + preparedData: undefined, + }, + }); + } + }, + onDeleteFile: (attrs) => { + void deleteAttachmentFile(app, attrs.data?.id); + }, + onRemove: (attrs) => { + void deleteAttachmentFile(app, attrs.data?.id); + }, + }, + }; + } + + // ── Toolbar (always visible; follows the focused section) ── + + private ensureToolbar(editor: Editor): void { + if (this.toolbarEl != null && this.toolbarEl.props.editor === editor) + return; + this.rebuildToolbar(editor); + } + + private rebuildToolbar(editor: Editor): void { + this.destroyToolbar(); + if (!this.toolbarHost) return; + const toolbarEl = makeToolbarElement(); + toolbarEl.addClass("notepad-toolbar"); + toolbarEl.setAttribute("data-ignore-swipe", "true"); + toolbarEl.props.editor = editor; + this.toolbarEl = toolbarEl; + this.toolbarHost.appendChild(toolbarEl); + } + + private destroyToolbar(): void { + this.toolbarEl?.remove(); + this.toolbarEl = null; + this.toolbarHost?.querySelector(".note-toolbar--idle")?.remove(); + } + + /** When the section owning the toolbar collapses, hand the toolbar to + * another expanded section; with none left, keep the bar visible as an + * empty idle strip so the row never disappears. */ + private rebindOrIdleToolbar(): void { + for (const handle of this.sections.values()) { + if (handle.editor != null && !handle.editor.isDestroyed) { + this.rebuildToolbar(handle.editor); + return; + } + } + this.showIdleToolbar(); + } + + private showIdleToolbar(): void { + this.destroyToolbar(); + if (!this.toolbarHost) return; + if (this.toolbarHost.querySelector(".note-toolbar--idle") == null) { + this.toolbarHost.createDiv("note-toolbar note-toolbar--idle"); + } + } + + // ── Pages navigation sidebar (blog-style drawer) ── + + private buildNavSidebar(): void { + const nav = this.contentEl.createDiv("notepad-nav"); + const toggle = nav.createEl("button", { cls: "notepad-nav-toggle" }); + // ☰ when closed, ✕ when open (CSS morphs between the two glyphs). + toggle.appendChild(menuSvg()); + toggle.appendChild(closeSvg()); + toggle.setAttribute("aria-label", "Pages"); + toggle.addEventListener("click", () => this.toggleNav()); + this.navListEl = nav.createDiv("notepad-nav-list"); + this.navEl = nav; + this.rebuildNavList(); + } + + private setNavOpen(open: boolean, opts?: { animate?: boolean }): void { + this.navOpen = open; + this.applyNavOpen(open, opts?.animate === true); + if (open) this.rebuildNavList(); + } + + /** Flip the content column's layout spot behind `mutate` with a FLIP: + * measure before/after, paint the column at its old spot via a + * transient transform, then release it into the CSS transform + * transition. Used for the centered ↔ hugging re-anchor, which is an + * auto-margin change CSS cannot interpolate. Rapid re-flips retarget + * from the current visual spot (the stale transform cancels out of + * both measurements). */ + private flipContentLayout(mutate: () => void): void { + const sections = this.sectionsEl; + const view = this.contentEl; + if (!sections || !view || view.clientWidth === 0) { + mutate(); + return; + } + const before = sections.getBoundingClientRect().left; + mutate(); + const after = sections.getBoundingClientRect().left; + const delta = before - after; + if (Math.abs(delta) < 0.5) return; + // Paint the column at its pre-layout spot (the helper class suspends + // the transition), then release it into the base transform transition. + sections.addClass("nav-flip"); + sections.style.setProperty("--nav-flip-x", `${delta}px`); + void sections.offsetWidth; + sections.removeClass("nav-flip"); + // While the released transform animates, the fixed-width column pokes + // past the scroller edge — the is-flipping class pins the horizontal + // scrollbar away until it settles (see notepad.css). + const scroller = this.scrollerEl; + if (scroller) { + // A fast re-flip supersedes the previous guard: settle it at once + // (same task — no paint in between, no scrollbar can flash). + this.flipSettle?.(); + scroller.addClass("is-flipping"); + const sectionsLocal = sections; + let settled = false; + const settle = (): void => { + if (settled) return; + settled = true; + this.flipSettle = null; + scroller.removeClass("is-flipping"); + sectionsLocal.removeEventListener("transitionend", onEnd); + }; + const onEnd = (event: TransitionEvent): void => { + // transitionend bubbles — only our own transform transition counts. + if (event.target !== sections || event.propertyName !== "transform") { + return; + } + settle(); + }; + this.flipSettle = settle; + sections.addEventListener("transitionend", onEnd); + // transitionend never fires when the tab is hidden or a re-flip + // cancelled the transform — time out just past the 0.35s CSS. + window.setTimeout(settle, 400); + } + } + + private applyNavOpen(open: boolean, animate: boolean): void { + const mutate = () => { + this.navEl?.toggleClass("is-open", open); + // Container-level mirror of the open state: the push-mode CSS + // (scroller margin, column re-anchor) applies only while open. + this.contentEl?.toggleClass("notepad-nav-open", open); + }; + if (animate) this.flipContentLayout(mutate); + else mutate(); + if (open) this.rebuildNavList(); + } + + private toggleNav(): void { + this.setNavOpen(!this.navOpen, { animate: true }); + } + + /** True when the whole content column fits right of the OPEN drawer — + * drives push-vs-overlay, the boot default-open decision and the + * click-on-content dismissal. Mobile never fits. The check counts only + * the LEFT scroller padding (the visual gap to the drawer): the right + * padding is empty while pushed (styles/notepad.css drops it) and must + * not steal the fit. The vertical scrollbar is measured and subtracted — + * it eats the pushed scroller's content box, and a fit that ignores it + * shrinks the column. The column itself never shrinks for the drawer's + * sake: when it would not fit, the drawer overlays it instead (shadow), + * per the owner's call (2026-09-26). */ + private navFits(): boolean { + if (Platform.isMobile) return false; + const scroller = this.scrollerEl; + const sections = this.sectionsEl; + if (!scroller || !sections) return false; + const max = parseFloat(getComputedStyle(sections).maxWidth); + if (!Number.isFinite(max)) return false; + const pad = getComputedStyle(scroller); + const padLeft = parseFloat(pad.paddingLeft) || 0; + // The container itself must not hold side padding either — it is zeroed + // in styles/notepad.css, but the check reads the live value so a future + // theme change cannot silently skew the fit. + const container = getComputedStyle(this.contentEl); + const containerPadX = + (parseFloat(container.paddingLeft) || 0) + + (parseFloat(container.paddingRight) || 0); + return ( + this.contentEl.clientWidth - + containerPadX - + NAV_WIDTH - + padLeft - + this.scrollbarWidth() >= + max + ); + } + + /** Layout width of a vertical scrollbar in this renderer (0 for overlay + * scrollbars). The live scroller only shows it once its content + * overflows — which is exactly what the fit check must predict: pushing + * narrows the column, the reflow grows taller, the scrollbar appears and + * eats the width it was not reserved. Measured on a hidden probe inside + * the scroller, so any scoped scrollbar styling is inherited. */ + private scrollbarWidth(): number { + const scroller = this.scrollerEl; + if (!scroller) return 0; + // Measured on a hidden probe inside the live scroller, so any scoped + // scrollbar styling is inherited by the measurement. + const probe = scroller.createDiv("notepad-scrollbar-probe"); + const width = probe.offsetWidth - probe.clientWidth; + probe.remove(); + return width; + } + + /** Keep push vs overlay in step with the live view width (window + * resizes, leaf splits); the observer's initial callback re-runs this + * after layout, so the boot decision never sees a 0-width container. + * The first call also applies the default state: open when the column + * fits, closed otherwise. Later resizes never override the user's + * choice — but an open drawer switching modes glides its column. */ + private updateNavMode(): void { + const fits = this.navFits(); + if (this.navOpen && this.contentEl?.hasClass("notepad-nav-push") !== fits) { + this.flipContentLayout(() => + this.contentEl?.toggleClass("notepad-nav-push", fits), + ); + } else { + this.contentEl?.toggleClass("notepad-nav-push", fits); + } + if (!this.navBooted) { + this.navBooted = true; + if (fits) this.setNavOpen(true); + } + } + + private rebuildNavList(): void { + if (!this.navListEl || !this.notepad) return; + this.navListEl.empty(); + for (const descriptor of this.notepad.notes()) { + const item = this.navListEl.createDiv("notepad-nav-item"); + item.setAttribute("data-id", descriptor.id); + const numb = item.createDiv("notepad-nav-numb"); + if (descriptor.order > 0) numb.setText(String(descriptor.order)); + const header = item.createDiv("notepad-nav-header"); + const title = (descriptor.title ?? "").trim(); + if (title) header.setText(title); + else { + header.addClass("is-empty"); + header.setText("Untitled"); + } + if (!this.notepad.isNoteOpenable(descriptor.id)) { + item.addClass("is-unsupported"); + } + if (this.isNavActive(descriptor.id)) item.addClass("is-active"); + item.addEventListener("click", () => this.jumpToPage(descriptor.id)); + } + this.navTitles = this.navTitleSignature(); + } + + /** Signature of the note titles as the nav list should render them. + * Comparing this against the rendered snapshot (navTitles) lets a + * save-time refresh exit early — the rebuild is O(pages) and replaces + * nodes (hover state), the compare is microseconds. */ + private navTitleSignature(): string { + return JSON.stringify( + this.notepad?.notes().map((n) => [n.id, (n.title ?? "").trim()]), + ); + } + + /** Refresh the nav list when flushed titles drifted from what it + * renders — a page renamed in its first line must reach the nav while + * it stays expanded (the drawer is open by default on wide screens). */ + private syncNavTitles(): void { + if (this.navTitleSignature() === this.navTitles) return; + this.rebuildNavList(); + } + + private isNavActive(id: string): boolean { + const focused = this.focusedEditorValue; + if (focused == null) return false; + for (const handle of this.sections.values()) { + if (handle.editor === focused) return handle.id === id; + } + return false; + } + + private updateNavActive(): void { + const items = this.navListEl?.querySelectorAll(".notepad-nav-item"); + if (!items) return; + for (const item of items) { + const id = item.getAttribute("data-id"); + item.classList.toggle("is-active", id != null && this.isNavActive(id)); + } + } + + /** Programmatic jumps (toc clicks, search matches) glide like the blog's + * toc; users who prefer reduced motion get instant jumps. */ + private scrollBehavior(): ScrollBehavior { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth"; + } + + /** Open (if needed) a page and scroll its frame to the top of the view. */ + private jumpToPage(id: string): void { + const handle = this.sections.get(id); + if (!handle || !this.notepad) return; + if (handle.editor == null) { + // No mount autofocus: the smooth scroll below must own the movement — + // an editor scrolling itself into view would snap the jump. + void this.expandSection(id, { focus: false, autofocus: false }); + } + window.setTimeout(() => { + const scroller = this.scrollerEl; + const root = handle.root; + if (!scroller || !root.isConnected) return; + const sRect = scroller.getBoundingClientRect(); + const rRect = root.getBoundingClientRect(); + scroller.scrollTo({ + top: scroller.scrollTop + rRect.top - sRect.top - 12, + behavior: this.scrollBehavior(), + }); + this.updateNavActive(); + }, 60); + // Focus the page once the glide has settled: an immediate focus() + // scrolls instantly and kills the animation. The glide ends with the + // page frame at the top of the view, so this focus scrolls nothing. + window.setTimeout(() => { + const editor = handle.editor; + if (editor && !editor.isDestroyed) editor.view.focus(); + }, 680); + } + + private scrollContainerTo(container: Element, docTop: number): void { + const rect = container.getBoundingClientRect(); + container.scrollTo({ + top: container.scrollTop + docTop - (rect.top + rect.height / 3), + behavior: this.scrollBehavior(), + }); + } + + // ── Drag & drop reorder (pointer drag on the section gutter) ── + + private onGutterPointerDown(event: PointerEvent, id: string): void { + if (event.button !== 0) return; + this.drag = { + id, + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + active: false, + }; + window.addEventListener("pointermove", this.onDragPointerMove); + window.addEventListener("pointerup", this.onDragPointerUp); + } + + private onDragPointerMove = (event: PointerEvent): void => { + if (!this.drag) return; + const handle = this.sections.get(this.drag.id); + if (!handle) return; + + if (!this.drag.active) { + const moved = Math.hypot( + event.clientX - this.drag.startX, + event.clientY - this.drag.startY, + ); + if (moved < 6) return; + this.drag.active = true; + handle.root.addClass("is-dragging"); + this.dropLineEl = this.sectionsEl?.createDiv("notepad-drop-line") ?? null; + } + this.updateDropLine(event.clientY); + }; + + private onDragPointerUp = (event: PointerEvent): void => { + window.removeEventListener("pointermove", this.onDragPointerMove); + window.removeEventListener("pointerup", this.onDragPointerUp); + const drag = this.drag; + this.drag = null; + this.dropLineEl?.remove(); + this.dropLineEl = null; + if (!drag) return; + + const handle = this.sections.get(drag.id); + handle?.root.removeClass("is-dragging"); + if (!drag.active) return; // plain click — the gutter click toggles + // Swallow the click that follows a completed drag. + this.suppressGutterToggle = true; + window.setTimeout(() => { + this.suppressGutterToggle = false; + }, 0); + + const dropIndex = this.computeDropIndex(event.clientY, drag.id); + if (dropIndex == null) return; + if (this.notepad?.moveNote(drag.id, dropIndex)) { + this.render(); + void this.flushSave("drag-reorder"); + } + }; + + /** The title page's note id (order 0) — excluded from drag geometry. */ + private titleSectionId(): string | null { + return this.notepad?.notes().find((d) => d.order === 0)?.id ?? null; + } + + private computeDropIndex(clientY: number, dragId: string): number | null { + const titleId = this.titleSectionId(); + const others = [...this.sections.values()] + .filter((h) => h.id !== dragId && h.id !== titleId) + .map((h) => ({ id: h.id, rect: h.root.getBoundingClientRect() })) + .sort((a, b) => a.rect.top - b.rect.top); + if (others.length === 0) return null; + let index = 0; + for (const other of others) { + if (clientY > other.rect.top + other.rect.height / 2) index++; + } + // The title page is fixed at index 0 — a drop never lands above page 1 + // (spec 9.1: the cover cannot be reordered). + return Math.max(index, 1); + } + + private updateDropLine(clientY: number): void { + // Capture locally: the filter closure below runs after the null checks + // and TS cannot see that `this.drag` hasn't changed in between. + const drag = this.drag; + if (!this.dropLineEl || !drag || !this.sectionsEl) return; + const titleId = this.titleSectionId(); + const others = [...this.sections.values()] + .filter((h) => h.id !== drag.id && h.id !== titleId) + .map((h) => ({ root: h.root })) + .sort((a, b) => a.root.offsetTop - b.root.offsetTop); + let top: number | null = null; + for (const other of others) { + const rect = other.root.getBoundingClientRect(); + if (clientY < rect.top + rect.height / 2) { + top = other.root.offsetTop - 4; + break; + } + } + if (top == null) { + const last = others[others.length - 1]; + top = last ? last.root.offsetTop + last.root.offsetHeight - 2 : 0; + } + this.dropLineEl.style.top = `${top}px`; + } + + // ── Notepad-wide search ── + + openNotepadSearch(): void { + this.buildSearchPanel(); + const input = this.searchInputEl; + if (!input) return; + input.focus(); + input.select(); + if (input.value) this.recomputeSearch(); + } + + private closeNotepadSearch(): void { + this.searchPanelEl?.remove(); + this.searchPanelEl = null; + this.searchInputEl = null; + this.searchCountEl = null; + this.searchMatches = []; + this.setSearchQueryOnExpandedEditors(null); + } + + private buildSearchPanel(): void { + if (this.searchPanelEl) return; + const panel = this.contentEl.createDiv("notepad-search"); + this.searchPanelEl = panel; + + const input = panel.createEl("input", { + cls: "notepad-search-input", + attr: { + type: "text", + placeholder: "Search notepad", + spellcheck: "false", + }, + }); + this.searchInputEl = input; + input.addEventListener("input", () => this.recomputeSearch()); + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + this.moveSearchMatch(event.shiftKey ? -1 : 1); + } else if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + this.closeNotepadSearch(); + } + }); + + this.searchCountEl = panel.createDiv("notepad-search-count"); + + const prev = panel.createEl("button", { cls: "notepad-search-btn" }); + setIcon(prev, "chevron-up"); + prev.setAttribute("aria-label", "Previous match"); + prev.addEventListener("click", () => this.moveSearchMatch(-1)); + + const next = panel.createEl("button", { cls: "notepad-search-btn" }); + setIcon(next, "chevron-down"); + next.setAttribute("aria-label", "Next match"); + next.addEventListener("click", () => this.moveSearchMatch(1)); + + const close = panel.createEl("button", { cls: "notepad-search-btn" }); + setIcon(close, "x"); + close.setAttribute("aria-label", "Close search"); + close.addEventListener("click", () => this.closeNotepadSearch()); + } + + private recomputeSearch(): void { + const notepad = this.notepad; + const query = this.searchInputEl?.value ?? ""; + if (!notepad) return; + // Unsaved editor changes must be visible to the search. + for (const handle of this.sections.values()) { + this.flushSectionToNotepad(handle); + } + this.searchMatches = searchNotepad( + notepad + .notes() + // Pages this editor cannot interpret keep their content verbatim — + // searching raw unknown-schema JSON would only produce junk jumps. + .filter((descriptor) => notepad.isNoteOpenable(descriptor.id)) + .map((descriptor) => ({ + id: descriptor.id, + title: descriptor.title ?? "", + doc: notepad.noteContent(descriptor.id) ?? { + type: "noteDoc" as const, + }, + })), + query, + ); + this.searchIndex = 0; + this.updateSearchCount(); + this.setSearchQueryOnExpandedEditors(query || null); + this.jumpToCurrentMatch(); + } + + private setSearchQueryOnExpandedEditors(query: string | null): void { + for (const handle of this.sections.values()) { + const editor = handle.editor; + if (!editor || editor.isDestroyed) continue; + editor.view.dispatch( + editor.state.tr.setMeta( + documentSearchKey, + query ? { query, activeIndex: 0 } : { clear: true }, + ), + ); + } + } + + private updateSearchCount(): void { + if (!this.searchCountEl) return; + this.searchCountEl.setText( + this.searchMatches.length > 0 + ? `${this.searchIndex + 1}/${this.searchMatches.length}` + : "0 results", + ); + } + + private moveSearchMatch(direction: number): void { + if (this.searchMatches.length === 0) return; + this.searchIndex = + (this.searchIndex + direction + this.searchMatches.length) % + this.searchMatches.length; + this.updateSearchCount(); + this.jumpToCurrentMatch(); + } + + private jumpToCurrentMatch(): void { + const match = this.searchMatches[this.searchIndex]; + if (!match) return; + const handle = this.sections.get(match.noteId); + if (!handle) return; + if (handle.editor != null) { + this.revealSearchMatch(match); + return; + } + // Mount the page quietly (no autofocus): the search input keeps focus. + void this.expandSection(match.noteId, { + focus: false, + autofocus: false, + }).then(() => this.revealSearchMatch(match)); + } + + /** Browser-search behavior: scroll to the match and mark it with the + * active highlight. NO selection and NO focus change — the search input + * keeps the focus so the user can keep typing / navigating. */ + private revealSearchMatch(match: NotepadSearchMatch): void { + const handle = this.sections.get(match.noteId); + const editor = handle?.editor; + if (!editor || editor.isDestroyed) return; + + // The ordinal of this match among its page's matches drives the + // active-decoration of the page's search plugin. + let localIndex = 0; + for (let i = 0; i < this.searchIndex; i++) { + if (this.searchMatches[i].noteId === match.noteId) localIndex++; + } + const query = this.searchInputEl?.value ?? ""; + editor.view.dispatch( + editor.state.tr.setMeta(documentSearchKey, { + query, + activeIndex: localIndex, + }), + ); + + try { + const coords = editor.view.coordsAtPos(match.from); + const editorScroller = editor.view.dom.closest(".texto-editor"); + if (editorScroller) this.scrollContainerTo(editorScroller, coords.top); + if (this.scrollerEl) this.scrollContainerTo(this.scrollerEl, coords.top); + } catch (err) { + // Position drift after edits — the highlight set stays as-is. + console.error("Failed to scroll to search match:", err); + } + } + + /** Scroll a scrollable ancestor so the viewport coordinate `docTop` sits + * about a third from the container's top. */ + // ── Bubble menus (desktop, per expanded section) ── + + private createBubbleMenus(editor: Editor, handle: SectionHandle): void { + const bubbleMenuBarEl = makeBubbleMenuBarElement(); + const tableBubbleMenuEl = makeTableBubbleMenuElement(); + const mediaBubbleMenuEl = makeMediaBubbleMenuElement(); + bubbleMenuBarEl.addClass("bubble-menu-bar-host"); + tableBubbleMenuEl.addClass("table-bubble-menu-bar-host"); + mediaBubbleMenuEl.addClass("bubble-menu-bar-host"); + handle.bubbleEls = [bubbleMenuBarEl, tableBubbleMenuEl, mediaBubbleMenuEl]; + this.contentEl.appendChild(bubbleMenuBarEl); + this.contentEl.appendChild(tableBubbleMenuEl); + this.contentEl.appendChild(mediaBubbleMenuEl); + bubbleMenuBarEl.props.editor = editor; + tableBubbleMenuEl.props.editor = editor; + mediaBubbleMenuEl.props.editor = editor; + // Required prop of the media menu (open/delete actions route through + // the app); without it the element's generator never starts. + mediaBubbleMenuEl.props.app = this.app; + + editor.registerPlugin( + bubbleMenuPlugin({ + pluginKey: `nb-text-${handle.id}`, + editor, + element: bubbleMenuBarEl, + shouldShow: function ( + this: BubbleMenuView, + { editor, state, from, to }: ShouldShowProps, + ) { + const selection = state.selection; + const hasFocus = + editor.view.hasFocus() || + (this.tippy?.popper ?? this.element).contains( + document.activeElement, + ); + if (!hasFocus || !editor.isEditable || this.isMousePressed) + return false; + if (isMediaNodeSelection(state)) return false; + const inTable = isInTable(state); + // Selected text — the text menu serves it even inside a table. + if (inTable && !selection.empty && isTextSelection(selection)) + return true; + if (selection instanceof CellSelection) return false; + if (inTable) return false; + if (selection.empty) return false; + return state.doc.textBetween(from, to).length > 0; + }, + tippyOptions: { placement: "top", offset: [0, 8] }, + }), + ); + + editor.registerPlugin( + bubbleMenuPlugin({ + pluginKey: `nb-table-${handle.id}`, + editor, + element: tableBubbleMenuEl, + shouldShow: function ( + this: BubbleMenuView, + { editor, state }: ShouldShowProps, + ) { + const hasFocus = + editor.view.hasFocus() || + (this.tippy?.popper ?? this.element).contains( + document.activeElement, + ); + if (!hasFocus || !editor.isEditable || this.isMousePressed) + return false; + const selection = state.selection; + return ( + isInTable(state) && + (selection.empty || selection instanceof CellSelection) + ); + }, + tippyOptions: { placement: "top", offset: [0, 8] }, + }), + ); + + editor.registerPlugin( + bubbleMenuPlugin({ + pluginKey: `nb-media-${handle.id}`, + editor, + element: mediaBubbleMenuEl, + shouldShow: function ( + this: BubbleMenuView, + { editor, state }: ShouldShowProps, + ) { + const hasFocus = + editor.view.hasFocus() || + (this.tippy?.popper ?? this.element).contains( + document.activeElement, + ); + if (!hasFocus || !editor.isEditable || this.isMousePressed) + return false; + return isMediaNodeSelection(state); + }, + tippyOptions: { placement: "top", offset: [0, 8] }, + }), + ); + } + + // ── Notes ── + + addNote(): void { + const notepad = this.notepad; + if (!notepad) return; + const notes = notepad.notes(); + const lastId = notes[notes.length - 1]?.id; + const added = notepad.addNote(lastId, ""); + // Open the new page for editing right away and remember it expanded. + notepad.setExpanded(added.id, true); + this.render(); + void this.flushSave("add-note"); + } + + /** Insert a new note right after the given section (per-section + control). */ + private addNoteAfter(id: string): void { + const notepad = this.notepad; + if (!notepad || !notepad.note(id)) return; + const added = notepad.addNote(id, ""); + notepad.setExpanded(added.id, true); + this.render(); + void this.flushSave("add-note"); + } + + /** Deep-copy a page right after the original (image assets stay shared). + * The title page cannot be duplicated (spec 9.1); neither can a page + * this editor cannot interpret — its content is preserved as-is. */ + private duplicateNote(id: string): void { + const notepad = this.notepad; + const descriptor = notepad?.note(id); + if (!notepad || !descriptor) return; + if (descriptor.order === 0 || !notepad.isNoteOpenable(id)) return; + const copy = notepad.duplicateNote(id); + if (!copy) return; + this.render(); + void this.flushSave("duplicate-note"); + } + + /** Remove a section's note after an explicit confirmation. Right after the + * deletion an Undo notice offers to put it back (the save chain keeps the + * delete and undo writes in order). The title page cannot be deleted + * (spec 9.1); neither can a page this editor cannot interpret. */ + private deleteNote(id: string): void { + const notepad = this.notepad; + const descriptor = notepad?.note(id); + if (!notepad || !descriptor) return; + if (descriptor.order === 0 || !notepad.isNoteOpenable(id)) return; + + new ConfirmModal(this.app, { + title: "Delete note", + message: "The page will be deleted. Continue?", + confirmText: "Delete", + cancelText: "Cancel", + onConfirm: () => { + if (this.notepad == null || this.notepad.note(id) == null) return; + const removed = this.notepad.removeNote(id); + if (removed == null) return; + this.pendingExpansions.delete(id); + this.render(); + void this.flushSave("delete-note"); + + const name = removed.descriptor.title?.trim(); + const notice = new Notice( + name ? `Note "${name}" deleted` : "Note deleted", + 7000, + ); + this.addNoticeAction(notice, "Undo", () => { + if (this.notepad == null) return; + // The restored descriptor carries its persisted expanded flag. + this.notepad.restoreNote(removed); + this.render(); + void this.flushSave("undo-delete"); + }); + }, + }).open(); + } + + /** Attach an action button to a notice: the native Notice.addAction when + * the runtime has it, otherwise a plain button inside the notice. */ + private addNoticeAction(notice: Notice, title: string, cb: () => void): void { + const withAction = notice as unknown as { + addAction?: ( + icon: string, + title: string, + cb: (evt: MouseEvent) => unknown, + ) => HTMLElement; + }; + if (typeof withAction.addAction === "function") { + withAction.addAction("undo", title, cb); + return; + } + const btn = notice.noticeEl.createEl("button", { + text: title, + cls: "notepad-notice-action", + }); + btn.addEventListener("click", () => { + cb(); + notice.hide(); + }); + } + + // ── Saving ── + + /** Saves are chained: two flushes racing (e.g. delete's write still in + * flight when Undo fires) must land on disk in trigger order, or the + * older snapshot can overwrite the newer one. */ + private saveChain: Promise = Promise.resolve(); + + private scheduleSave(): void { + if (this.saveTimer != null) window.clearTimeout(this.saveTimer); + this.saveTimer = window.setTimeout(() => { + this.saveTimer = null; + void this.flushSave(); + }, AUTOSAVE_DELAY); + } + + private async flushSave(trigger = "autosave", force = false): Promise { + const run = this.saveChain.then(() => + this.writePendingChanges(trigger, force), + ); + this.saveChain = run.catch(() => {}); + return run; + } + + private async writePendingChanges( + trigger: string, + force = false, + ): Promise { + const notepad = this.notepad; + const file = this.file; + if (!notepad || !file) return; + if (this.saveTimer != null) { + window.clearTimeout(this.saveTimer); + this.saveTimer = null; + } + + for (const handle of this.sections.values()) { + this.flushSectionToNotepad(handle); + } + // The flush above synced titles edited in expanded pages — the nav + // must follow on save, without waiting for a collapse. The signature + // compare skips the rebuild when nothing actually changed. + this.syncNavTitles(); + if (!force && notepad.dirtyNotes.size === 0 && !notepad.structureChanged) { + this.dirty = false; + return; + } + + // Clear the flags before the write, not after: writeUmFile serializes + // synchronously, so the snapshot covers exactly this dirty state, while + // an edit landing during the disk wait (e.g. flushed by a collapse) + // keeps its flag and is written by the next save. + const dirtySnapshot = new Set(notepad.dirtyNotes); + const structureSnapshot = notepad.structureChanged; + notepad.dirtyNotes.clear(); + notepad.structureChanged = false; + this.dirty = false; + + try { + await writeUmFile(file, this.app.vault, notepad, trigger); + } catch (err) { + // Nothing reached the disk — restore the flags so the next flush + // retries the same content. + for (const id of dirtySnapshot) notepad.dirtyNotes.add(id); + notepad.structureChanged = structureSnapshot || notepad.structureChanged; + this.dirty = true; + new Notice(`Failed to save notepad: ${String(err)}`); + } + } + + // ── External changes ── + + private onFileMaybeExternallyChanged(): void { + if (this.externalChangeTimer != null) { + window.clearTimeout(this.externalChangeTimer); + } + this.externalChangeTimer = window.setTimeout(() => { + this.externalChangeTimer = null; + void this.processExternalChange(); + }, EXTERNAL_CHANGE_DEBOUNCE); + } + + /** Decide what an observed file modification means: + * - fingerprint matches our last write/load → our own save, no-op; + * - no unsaved local edits → silently reload from disk; + * - unsaved local edits (conflict) → ask the user which version wins. */ + private async processExternalChange(): Promise { + const notepad = this.notepad; + const file = this.file; + if (!notepad || !file || this.conflictModalOpen) return; + + const fingerprint = await umFingerprint(this.app.vault, file); + if (fingerprint === notepad.savedFingerprint) return; // our own save + + if (notepad.dirtyNotes.size > 0 || notepad.structureChanged || this.dirty) { + this.conflictModalOpen = true; + new FileChangedModal(this.app, { + onKeepLocal: () => { + void this.writeNow("conflict-keep-local"); + }, + onTakeDisk: () => { + void this.reloadFromDisk(); + }, + onClose: () => { + this.conflictModalOpen = false; + }, + }).open(); + return; + } + + await this.reloadFromDisk(); + } + + /** Unconditional write (conflict resolution: local version wins). Chained + * like every other save: an in-flight autosave must not land after it + * and overwrite the chosen version. */ + private writeNow(trigger: string): Promise { + return this.flushSave(trigger, true); + } + + /** Replace the in-memory notepad and the UI with the on-disk state. */ + private async reloadFromDisk(): Promise { + const file = this.file; + if (!file) return; + try { + const fresh = await readUmFile(file, this.app.vault); + fresh.savedFingerprint = await umFingerprint(this.app.vault, file); + this.destroyAllSections(); + this.notepad?.destroy(); + this.notepad = fresh; + this.dirty = false; + // Keep the user's expansion state across the reload. + this.render(); + } catch (err) { + console.error("Failed to reload notepad from disk:", err); + } + } +} diff --git a/src/__mocks__/obsidian.ts b/src/__mocks__/obsidian.ts index c4622b8..fde2336 100644 --- a/src/__mocks__/obsidian.ts +++ b/src/__mocks__/obsidian.ts @@ -1,4 +1,4 @@ -import type { TFolder } from "obsidian"; +import type { FileStats, TFolder, Vault as ObsidianVault } from "obsidian"; import { vi } from "vitest"; export class Vault { @@ -14,16 +14,21 @@ export class Vault { rename: vi.fn(), remove: vi.fn(), append: vi.fn(), + getFullPath: vi.fn(), }; } +/** Structural twin of the real Obsidian TFile (declared via the type-only + * import below, erased at runtime — the mock never loads the real module), + * so mock instances pass as arguments into production functions typed + * against the real class. */ export class TFile { + vault: ObsidianVault; path: string; name: string; basename: string; extension: string; - stat: { ctime: number; mtime: number; size: number }; - vault: Vault | null; + stat: FileStats; parent: TFolder | null; constructor(path: string) { @@ -35,7 +40,9 @@ export class TFile { this.basename = dotIndex > 0 ? filename.slice(0, dotIndex) : filename; this.extension = dotIndex > 0 ? filename.slice(dotIndex + 1) : ""; this.stat = { ctime: 0, mtime: 0, size: 0 }; - this.vault = null; + // Mock files are never attached to a vault — but the field must be + // declared with the real Vault type for structural compatibility. + this.vault = null as unknown as ObsidianVault; this.parent = null; } } diff --git a/src/browser-harness/harness.ts b/src/browser-harness/harness.ts index b646eaf..af2a100 100644 --- a/src/browser-harness/harness.ts +++ b/src/browser-harness/harness.ts @@ -1,7 +1,9 @@ // Browser harness entry for the drag & drop tests (built with vite, // see scripts/build-harness.mjs). Provides the Obsidian globals and the // `obsidian` module shim (Platform only) that src/texto needs at runtime. +import type { JSONContent } from "../texto/core/@types"; import { Editor } from "../texto/core/Editor"; +import { getExtensions } from "../texto/getExtensions"; import { headingFoldingKey } from "../texto/extensions/heading/folding"; import { taskFoldingKey } from "../texto/extensions/task-item-folding/taskFoldingPlugin"; @@ -124,3 +126,98 @@ defineHelper("empty", (el) => { export * from "../texto/getExtensions"; export { Editor, headingFoldingKey, taskFoldingKey }; + +// ── Programmatic E2E API (window.__e2e) ───────────────────────────────── +// Installed at import time and driven by the Playwright suite through the +// committed page tests/e2e/harness/index.html. The manual MCP page +// (.harness/index.html) keeps its own window.__harness — a different +// global, so the two never interfere. + +interface E2EApi { + /** Mount a fresh editor with the given doc JSON (replaces the previous one). */ + mount: (doc: unknown) => string; + /** The editor mounted last (null before the first mount). */ + readonly editor: Editor | null; + /** Doc JSON of the current editor. */ + docJson: () => JSONContent | null; + /** Start positions of the top-level blocks. */ + positions: () => number[]; + /** Folded heading positions (plugin state). */ + headingFolds: () => number[]; + /** Folded task-item positions (plugin state). */ + taskFolds: () => number[]; + /** The last error reported through the editor's onError hook. */ + lastError: () => unknown; +} + +let currentEditor: Editor | null = null; +let lastError: unknown = null; + +function appContainer(): HTMLElement { + const existing = document.getElementById("app"); + if (existing != null) { + return existing; + } + const app = document.createElement("div"); + app.id = "app"; + document.body.appendChild(app); + return app; +} + +function mountEditor(doc: unknown): string { + if (currentEditor != null) { + currentEditor.destroy(); + currentEditor = null; + } + lastError = null; + + const app = appContainer(); + app.replaceChildren(); + const note = document.createElement("div"); + note.className = "note"; + const article = document.createElement("article"); + article.className = "texto-editor"; + note.appendChild(article); + app.appendChild(note); + + currentEditor = new Editor({ + element: article, + content: doc as JSONContent, + extensions: getExtensions({}, {}), + editable: true, + onError: (err) => { + lastError = err; + window.dispatchEvent(new ErrorEvent("error", { error: err })); + }, + }); + return "editor"; +} + +const e2eApi: E2EApi = { + mount: mountEditor, + get editor() { + return currentEditor; + }, + docJson: () => + (currentEditor?.state.doc.toJSON() ?? null) as JSONContent | null, + positions: () => { + const out: number[] = []; + currentEditor?.state.doc.forEach((_, pos) => out.push(pos)); + return out; + }, + headingFolds: () => + currentEditor == null + ? [] + : Array.from( + headingFoldingKey.getState(currentEditor.view.state)?.folded ?? [], + ), + taskFolds: () => + currentEditor == null + ? [] + : Array.from( + taskFoldingKey.getState(currentEditor.view.state)?.folded ?? [], + ), + lastError: () => lastError, +}; + +g.__e2e = e2eApi; diff --git a/src/components/bubble-menu-bar/bubbleMenuState.test.ts b/src/components/bubble-menu-bar/bubbleMenuState.test.ts index 83fe850..f7b52ed 100644 --- a/src/components/bubble-menu-bar/bubbleMenuState.test.ts +++ b/src/components/bubble-menu-bar/bubbleMenuState.test.ts @@ -7,8 +7,22 @@ function makeEditor( attrs: Record> = {}, ): Pick { return { - isActive(name: string, a?: { level?: number }) { - if (name === "heading") return active[`heading${a?.level}`] ?? false; + // Editor.isActive has two overloads: (name, attributes?) and (attributes). + // The mock accepts both; only the (name, attributes) form is exercised. + isActive( + nameOrAttributes: string | object, + attributesOrUndefined?: object, + ) { + const name = typeof nameOrAttributes === "string" ? nameOrAttributes : ""; + const a = + typeof nameOrAttributes === "string" + ? attributesOrUndefined + : nameOrAttributes; + if (name === "heading") + return ( + active[`heading${(a as { level?: number } | undefined)?.level}`] ?? + false + ); return active[name] ?? false; }, getAttributes(name: string) { diff --git a/src/components/bubble-menu-bar/media-bubble-menu-bar.element.tsx b/src/components/bubble-menu-bar/media-bubble-menu-bar.element.tsx index a9c301c..b473b07 100644 --- a/src/components/bubble-menu-bar/media-bubble-menu-bar.element.tsx +++ b/src/components/bubble-menu-bar/media-bubble-menu-bar.element.tsx @@ -64,7 +64,12 @@ export const MediaBubbleMenuElement = litView.element({ const tippy = getTippy(); if (tippy && !wiredTippies.has(tippy)) { wiredTippies.add(tippy); - tippy.setProps({ onShow: () => window.requestAnimationFrame(syncCaret) }); + // onShow must return void — a bare arrow would leak the rAF id + tippy.setProps({ + onShow: () => { + window.requestAnimationFrame(syncCaret); + }, + }); } }; diff --git a/src/components/icons/iconSprite.ts b/src/components/icons/iconSprite.ts index 099a08e..0679957 100644 --- a/src/components/icons/iconSprite.ts +++ b/src/components/icons/iconSprite.ts @@ -19,6 +19,11 @@ const SPRITE_ID = "inscriptum-icon-sprite"; +/** Icon shown in Obsidian chrome (ribbon, tab, file menus, explorer button) + * for our notes. Must be a Lucide name bundled with Obsidian: "notebook-pen" + * exists there, "notepad-pen" does not — so domain renames must not touch it. */ +export const NOTE_ICON_NAME = "notebook-pen"; + const ICON_PATHS: Record = { file: ``, fileUnknown: ``, diff --git a/src/components/note/note.element.tsx b/src/components/note/note.element.tsx index 3b40ab3..79789a4 100644 --- a/src/components/note/note.element.tsx +++ b/src/components/note/note.element.tsx @@ -84,3 +84,7 @@ export const NoteElement = litView.element({ } } })(elTag("texto-editor")); + +/** The factory below is only a value (a custom element constructor); this + * alias gives consumers the instance type, with its typed `props`. */ +export type NoteElement = InstanceType; diff --git a/src/components/toolbar/icon.svgnode.tsx b/src/components/toolbar/icon.svgnode.tsx index 2fa1bf3..6e6e322 100644 --- a/src/components/toolbar/icon.svgnode.tsx +++ b/src/components/toolbar/icon.svgnode.tsx @@ -29,9 +29,8 @@ function makeToolbarIconNode(name: ToolbarIconName) { })(); } -export const toolbarIconNodes: Record< - ToolbarIconName, - ReturnType -> = Object.fromEntries( +// Object.fromEntries widens to an index signature; the keys are exactly +// TOOLBAR_ICON_NAMES by construction, so the Record cast is safe. +export const toolbarIconNodes = Object.fromEntries( TOOLBAR_ICON_NAMES.map((name) => [name, makeToolbarIconNode(name)]), -); +) as Record>; diff --git a/src/components/toolbar/toolbar.element.tsx b/src/components/toolbar/toolbar.element.tsx index 9a2318b..c949701 100644 --- a/src/components/toolbar/toolbar.element.tsx +++ b/src/components/toolbar/toolbar.element.tsx @@ -411,3 +411,7 @@ export const ToolbarElement = litView.element({ } } })(elTag("note-toolbar")); + +/** The factory above is only a value (a custom element constructor); this + * alias gives consumers the instance type, with its typed `props`. */ +export type ToolbarElement = InstanceType; diff --git a/src/components/toolbar/toolbarState.test.ts b/src/components/toolbar/toolbarState.test.ts index 206d460..211ac60 100644 --- a/src/components/toolbar/toolbarState.test.ts +++ b/src/components/toolbar/toolbarState.test.ts @@ -4,8 +4,23 @@ import { getToolbarState } from "./toolbarState"; function makeEditor(active: Record): Pick { return { - isActive(name: string, attrs?: { level?: number }) { - if (name === "heading") return active[`heading${attrs?.level}`] ?? false; + // Editor.isActive has two overloads: (name, attributes?) and (attributes). + // The mock accepts both; only the (name, attributes) form is exercised. + isActive( + nameOrAttributes: string | object, + attributesOrUndefined?: object, + ) { + const name = typeof nameOrAttributes === "string" ? nameOrAttributes : ""; + const attrs = + typeof nameOrAttributes === "string" + ? attributesOrUndefined + : nameOrAttributes; + if (name === "heading") + return ( + active[ + `heading${(attrs as { level?: number } | undefined)?.level}` + ] ?? false + ); return active[name] ?? false; }, }; diff --git a/src/export/extensions.ts b/src/export/extensions.ts index 2853955..80821d0 100644 --- a/src/export/extensions.ts +++ b/src/export/extensions.ts @@ -8,7 +8,12 @@ import { HljsCodeBlockRow, HljsMark, } from "../texto/extensions/code-block-hljs"; -import { Table, TableCell, TableHeader, TableRow } from "../texto/extensions/table"; +import { + Table, + TableCell, + TableHeader, + TableRow, +} from "../texto/extensions/table"; import { TaskList } from "../texto/extensions/task-list"; import { TaskItem } from "../texto/extensions/task-item"; import { Blockquote } from "../texto/extensions/blockquote"; diff --git a/src/export/postprocess.ts b/src/export/postprocess.ts index b851690..645b528 100644 --- a/src/export/postprocess.ts +++ b/src/export/postprocess.ts @@ -58,9 +58,7 @@ function figureWidthStyle(width: string | null): string { function languageLabel(codeClass: string | null): string { for (const cls of (codeClass ?? "").split(/\s+/)) { if (!cls.startsWith(LANGUAGE_CLASS_PREFIX)) continue; - const name = aliasToLanguage.get( - cls.slice(LANGUAGE_CLASS_PREFIX.length), - ); + const name = aliasToLanguage.get(cls.slice(LANGUAGE_CLASS_PREFIX.length)); if (name != null) return name; } return "auto"; @@ -93,10 +91,15 @@ function svgIcon(doc: Document, shapes: IconShape[]): SVGElement { } const COPY_ICON: IconShape[] = [ - { tag: "rect", attrs: { x: "9", y: "9", width: "12", height: "12", rx: "2" } }, + { + tag: "rect", + attrs: { x: "9", y: "9", width: "12", height: "12", rx: "2" }, + }, { tag: "path", - attrs: { d: "M5 15h-1a2 2 0 0 1 -2 -2v-8a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v1" }, + attrs: { + d: "M5 15h-1a2 2 0 0 1 -2 -2v-8a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v1", + }, }, ]; const CHECK_ICON: IconShape[] = [ diff --git a/src/export/preview.ts b/src/export/preview.ts index 0e6278f..f12dc8d 100644 --- a/src/export/preview.ts +++ b/src/export/preview.ts @@ -30,10 +30,7 @@ function plainText(node: JSONContent | undefined): string { } /** Recursively finds the first node of the given type. */ -function findFirstNode( - node: JSONContent, - type: string, -): JSONContent | null { +function findFirstNode(node: JSONContent, type: string): JSONContent | null { if (node.type === type) return node; for (const child of node.content ?? []) { const found = findFirstNode(child, type); diff --git a/src/export/slug.ts b/src/export/slug.ts index 35c7670..ec57bef 100644 --- a/src/export/slug.ts +++ b/src/export/slug.ts @@ -13,9 +13,10 @@ export function transliterate(text: string, engToRus = false): string { "щ ш ч ц ю я ё ж ъ ы э а б в г д е з и й к л м н о п р с т у ф х ь".split( / +/g, ); - const eng = "shh sh ch cz yu ya yo zh `` y' e` a b v g d e z i j k l m n o p r s t u f x `".split( - / +/g, - ); + const eng = + "shh sh ch cz yu ya yo zh `` y' e` a b v g d e z i j k l m n o p r s t u f x `".split( + / +/g, + ); for (let x = 0; x < rus.length; x++) { text = text diff --git a/src/main.ts b/src/main.ts index 8a77d52..19a4133 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,8 +11,11 @@ import { WorkspaceLeaf, } from "obsidian"; import { NoteView, NOTE_VIEW_TYPE } from "./NoteView"; -import { installIconSprite } from "./components/icons/iconSprite"; +import { NotepadView, NOTEPAD_VIEW_TYPE } from "./NotepadView"; +import { installIconSprite, NOTE_ICON_NAME } from "./components/icons/iconSprite"; import { createEmptyNote, setWriteLogEnabled } from "./storage/noteStorage"; +import { createUmFile } from "./storage/um/umVault"; +import { UmNotepad } from "./storage/um/umNotepad"; import { NewNoteModal } from "./ui/NewNoteModal"; import { findCommandsCollidingWith, @@ -62,6 +65,13 @@ export default class NotesPlugin extends Plugin { (leaf: WorkspaceLeaf) => new NoteView(leaf), ); + this.registerExtensions(["um"], NOTEPAD_VIEW_TYPE); + + this.registerView( + NOTEPAD_VIEW_TYPE, + (leaf: WorkspaceLeaf) => new NotepadView(leaf), + ); + // Obsidian's own command hotkeys (e.g. "Toggle bold" on Mod+b) match by // `event.key` and swallow Cmd/Ctrl+letter combos before they reach the // editor DOM — on macOS the key under Meta is always Latin, so this hits @@ -76,12 +86,20 @@ export default class NotesPlugin extends Plugin { this.restorePatchedCommands(); }); + // Notepad page editors participate in the same hotkey routing. + NotepadView.onEditorCreated = (editor) => { + this.patchCollidingCommands(editor.registeredShortcuts); + }; + this.register(() => { + NotepadView.onEditorCreated = null; + }); + NoteView.onExportRequested = (view) => void this.exportNoteAsWebsite(view); this.register(() => { NoteView.onExportRequested = null; }); - this.addRibbonIcon("notebook-pen", "New inscriptum", () => { + this.addRibbonIcon(NOTE_ICON_NAME, "New inscriptum", () => { this.createNewNote(); }); @@ -93,6 +111,14 @@ export default class NotesPlugin extends Plugin { }, }); + this.addCommand({ + id: "create-new-notepad", + name: "New notepad (.um)", + callback: () => { + this.createNewNotepad(); + }, + }); + this.addCommand({ id: "find-in-note", name: "Find in current note", @@ -124,7 +150,7 @@ export default class NotesPlugin extends Plugin { menu.addItem((item) => item .setTitle("New inscriptum") - .setIcon("notebook-pen") + .setIcon(NOTE_ICON_NAME) .onClick(() => this.createNewNote(file.path)), ); }), @@ -206,13 +232,24 @@ export default class NotesPlugin extends Plugin { * when the combo was consumed (or is claimable while checking). */ private routeToNoteView(ownedNames: string[], checking: boolean): boolean { const view = this.app.workspace.getActiveViewOfType(NoteView); - if (!view?.hasEditor) return false; - if (checking) return true; - for (const name of ownedNames) { - const event = nameToKeyboardEvent(name); - if (event) view.handleEditorShortcut(event); + if (view?.hasEditor) { + if (checking) return true; + for (const name of ownedNames) { + const event = nameToKeyboardEvent(name); + if (event) view.handleEditorShortcut(event); + } + return true; + } + const notepad = this.app.workspace.getActiveViewOfType(NotepadView); + if (notepad?.focusedEditor) { + if (checking) return true; + for (const name of ownedNames) { + const event = nameToKeyboardEvent(name); + if (event) notepad.handleEditorShortcut(event); + } + return true; } - return true; + return false; } private restorePatchedCommands(): void { @@ -240,7 +277,7 @@ export default class NotesPlugin extends Plugin { cls: "clickable-icon nav-action-button inscriptum-nav-new-note", attr: { "aria-label": "New inscriptum", type: "button" }, }); - setIcon(button, "notebook-pen"); + setIcon(button, NOTE_ICON_NAME); button.addEventListener("click", (event) => { event.preventDefault(); this.createNewNote(); @@ -315,7 +352,8 @@ export default class NotesPlugin extends Plugin { } } - private createNewNote(initialFolderPath?: string) { const activeFile = this.app.workspace.getActiveFile(); + private createNewNote(initialFolderPath?: string) { + const activeFile = this.app.workspace.getActiveFile(); const defaultFolder = initialFolderPath !== undefined ? (this.app.vault.getFolderByPath(initialFolderPath) ?? @@ -331,29 +369,84 @@ export default class NotesPlugin extends Plugin { async (result) => { if (!result) return; - const { name, folderPath } = result; - const newFilePath = `${name}.note`; - + const { name, folderPath, kind } = result; try { - const path = normalizePath( - folderPath ? `${folderPath}/${newFilePath}` : newFilePath, - ); - const initialContent = JSON.stringify( - createNoteWithTitle(name), - null, - 2, + if (kind === "notepad") { + await this.createNotepadFile(name, folderPath); + } else { + await this.createNoteFile(name, folderPath); + } + } catch (error) { + new Notice( + `Failed to create: ${error instanceof Error ? error.message : String(error)}`, ); - const file = await this.app.vault.create(path, initialContent); - await this.app.workspace.getLeaf("tab").openFile(file); + console.error("Failed to create note/notepad:", error); + } + }, + ).open(); + } + + /** "New notepad (.um)": the creation modal with Notepad preselected. */ + private createNewNotepad() { + const activeFile = this.app.workspace.getActiveFile(); + const defaultFolder = + activeFile?.parent ?? + this.app.fileManager.getNewFileParent("", "Untitled.um"); + + new NewNoteModal( + this.app, + this.app.vault.getAllFolders(true), + defaultFolder?.path ?? "", + async (result) => { + if (!result) return; + try { + if (result.kind === "notepad") { + await this.createNotepadFile(result.name, result.folderPath); + } else { + await this.createNoteFile(result.name, result.folderPath); + } } catch (error) { new Notice( - `Failed to create note: ${error instanceof Error ? error.message : String(error)}`, + `Failed to create: ${error instanceof Error ? error.message : String(error)}`, ); - console.error("Failed to create note:", error); + console.error("Failed to create note/notepad:", error); } }, + { kind: "notepad", namePlaceholder: "Notepad name" }, ).open(); } + + /** Create a plain `.note` with the title filled from the name, then open it. */ + private async createNoteFile( + name: string, + folderPath: string, + ): Promise { + const path = normalizePath( + folderPath ? `${folderPath}/${name}.note` : `${name}.note`, + ); + const file = await this.app.vault.create( + path, + JSON.stringify(createNoteWithTitle(name), null, 2), + ); + await this.app.workspace.getLeaf("tab").openFile(file); + } + + /** Create a `.um` container with one starter note whose manifest title + * matches the notepad name, then open it. */ + private async createNotepadFile( + name: string, + folderPath: string, + ): Promise { + const notepad = UmNotepad.empty(); + notepad.addNote(undefined, name); + // The starter page lands expanded (persisted in the manifest). + notepad.setExpanded(notepad.notes()[0]?.id, true); + const path = normalizePath( + folderPath ? `${folderPath}/${name}.um` : `${name}.um`, + ); + const file = await createUmFile(this.app.vault, path, notepad); + await this.app.workspace.getLeaf("tab").openFile(file); + } } /** diff --git a/src/notepad/editorSmoke.test.ts b/src/notepad/editorSmoke.test.ts new file mode 100644 index 0000000..f2afd84 --- /dev/null +++ b/src/notepad/editorSmoke.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { Editor } from "../texto/core"; +import { getExtensions } from "../texto/getExtensions"; +import { createEmptyNote } from "../storage/noteStorage"; +import type { JSONContent } from "../texto/core/@types"; + +function build(content: JSONContent): { ok: boolean; error?: string } { + const el = document.createElement("div"); + document.body.appendChild(el); + let error: string | undefined; + const editor = new Editor({ + element: el, + content, + onError: (err) => { + error = String((err as Error)?.stack ?? err); + }, + extensions: getExtensions({}, { isMobileView: false }), + autofocus: "start", + }); + const ok = editor.view != null; + if (editor.view != null) editor.destroy(); + el.remove(); + return { ok, error }; +} + +describe("editor smoke: content shapes used by NotepadView", () => { + it("builds an editor for createEmptyNote() (new notepad note)", () => { + const r = build(createEmptyNote()); + expect({ ok: r.ok, error: r.error?.slice(0, 300) }).toMatchObject({ + ok: true, + }); + }); + + it("builds an editor for a note with title text", () => { + const doc = createEmptyNote(); + const title = doc.content?.find((n) => n.type === "noteTitle"); + if (title) title.content = [{ type: "text", text: "Hello" }]; + const r = build(doc); + expect({ ok: r.ok, error: r.error?.slice(0, 300) }).toMatchObject({ + ok: true, + }); + }); + + it("builds an editor for an image note without a key attr", () => { + const doc = createEmptyNote(); + doc.content?.push({ + type: "image", + attrs: { data: { id: "01TESTIMG", size: "70", filename: "dot.png" } }, + }); + const r = build(doc); + expect({ ok: r.ok, error: r.error?.slice(0, 300) }).toMatchObject({ + ok: true, + }); + }); +}); diff --git a/src/notepad/imageTools.ts b/src/notepad/imageTools.ts new file mode 100644 index 0000000..dafb0b7 --- /dev/null +++ b/src/notepad/imageTools.ts @@ -0,0 +1,141 @@ +import type { Editor } from "../texto/core"; +import type { + ImageElementPublicProps, + ImageOptionsAttrs, + UpdateFn, +} from "../texto/extensions/image"; +import type { ImageToolContext } from "../tools/image"; +import { findPosByKey } from "../texto/extensions/state"; +import { openImageLightboxFromUrl } from "../ui/imageLightbox"; +import { imageOnSetViewProps } from "../tools/image"; +import type { UmNotepad } from "../storage/um/umNotepad"; + +/** + * Container variants of the image tools (src/tools/image.ts): image bytes + * live inside the `.um` archive and image nodes reference them by asset id + * (spec section 17). Notes can still carry external vault images — any + * `data.id` that is not a container asset id falls back to the standard + * vault resolution (the notepad file itself anchors external attachments, + * same as a plain note). + */ + +async function packImage( + file: File, + notepad: UmNotepad, + apply: (attrs: Partial) => void, +): Promise { + try { + const bytes = new Uint8Array(await file.arrayBuffer()); + const asset = notepad.addAsset(bytes, file.name, file.type); + const url = notepad.assetUrl(asset.id); + apply({ + state: { + src: url ?? undefined, + text: "", + subtext: "", + preparedData: undefined, + }, + data: { + id: asset.id, + size: String(asset.size ?? bytes.byteLength), + filename: file.name, + }, + }); + } catch (err) { + apply({ state: { error: String(err), preparedData: undefined } }); + } +} + +/** Handles a file inserted via paste/drop for a new image node: packs the + * bytes into the notepad and rewrites the node with the asset reference. */ +export function handleAddImgContainer( + attrs: ImageOptionsAttrs, + editorRef: { current: Editor | null }, + notepad: UmNotepad, +): void { + window.requestAnimationFrame(() => { + const editor = editorRef.current; + if (editor == null) return; + // Already saved or no file to process + if (attrs.data?.id != null) return; + const file = attrs.state?.preparedData?.file; + if (file == null) return; + + void packImage(file, notepad, (updatedAttrs) => { + const pos = findPosByKey(editor.state, attrs.key); + if (pos != null) { + editor.view.dispatch( + editor.state.tr + .setNodeMarkup(pos, editor.schema.nodes.image, { + ...updatedAttrs, + key: attrs.key, + }) + .setMeta("addToHistory", false), + ); + } + }); + }); +} + +/** onSetViewProps hook for Image inside a notepad: + * - asset ids resolve to cached object URLs from the container; + * - missing assets show an error (broken image, never a broken notepad); + * - everything else is an external vault image and uses the standard + * vault resolution (src/tools/image.ts); + * - zoom opens the lightbox from the resolved URL. */ +export function imageOnSetViewPropsContainer( + props: ImageElementPublicProps, + update: UpdateFn, + ctx: ImageToolContext, + notepad: UmNotepad, +): ImageElementPublicProps | undefined { + const data = props.data as { id?: string; filename?: string | null } | null; + const id = data?.id; + + if (id != null && notepad.isAssetId(id)) { + let state = props.state; + const url = notepad.assetUrl(id); + if (url == null) { + const error = `Asset not found in notepad: ${data?.filename || id}`; + if (state?.error !== error || state?.src != null) { + state = { ...state, src: undefined, error }; + update({ data: props.data, state }, true); + } + } else if (state?.src !== url || state?.error != null) { + state = { ...state, src: url, error: undefined }; + update({ data: props.data, state }, true); + } + + return { + ...props, + state, + onClick: (event: MouseEvent) => { + event.stopPropagation(); + if (url != null) openImageLightboxFromUrl(url, data?.filename); + }, + onFileSelected: (file: File | null) => { + if (file) + void packImage(file, notepad, (updatedAttrs) => + update(updatedAttrs), + ); + }, + }; + } + + if (id == null) { + // Brand-new image (toolbar insert / paste / drop): the picked file must + // be packed into the container — never saved next to the document. + return { + ...props, + onFileSelected: (file: File | null) => { + if (file) + void packImage(file, notepad, (updatedAttrs) => + update(updatedAttrs), + ); + }, + }; + } + + // External vault image — standard resolution anchored at the notepad file. + return imageOnSetViewProps(props, update, ctx); +} diff --git a/src/notepad/notepadSearch.test.ts b/src/notepad/notepadSearch.test.ts new file mode 100644 index 0000000..be94480 --- /dev/null +++ b/src/notepad/notepadSearch.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { Editor } from "../texto/core"; +import { getExtensions } from "../texto/getExtensions"; +import { buildNoteTextIndex, findMatchesInIndex } from "./notepadSearch"; +import { findDocumentMatches } from "../search/documentSearch"; +import type { JSONContent } from "../texto/core/@types"; + +function editorFor(content: JSONContent): Editor { + const el = document.createElement("div"); + document.body.appendChild(el); + const editor = new Editor({ + element: el, + content, + extensions: getExtensions({}, { isMobileView: false }), + autofocus: false, + }); + el.remove(); + return editor; +} + +const docWithMarks: JSONContent = { + type: "noteDoc", + content: [ + { type: "noteTitle", content: [{ type: "text", text: "Note Title" }] }, + { + type: "paragraph", + content: [ + { type: "text", text: "alpha " }, + { type: "text", marks: [{ type: "bold" }], text: "beta " }, + { type: "text", text: "gamma delta" }, + ], + }, + { + type: "paragraph", + content: [{ type: "text", text: "second alpha line" }], + }, + { type: "image", attrs: { data: { id: "01IMG" } } }, + { type: "paragraph" }, + ], +}; + +describe("notepadSearch index", () => { + it("matches the PM document's own search ranges", () => { + const editor = editorFor(docWithMarks); + const index = buildNoteTextIndex(editor.getJSON()); + + for (const query of ["alpha", "beta gamma", "Title", "delta", "e"]) { + const pmMatches = findDocumentMatches(editor.state.doc, query); + const idxMatches = findMatchesInIndex(index, query); + expect(idxMatches.map((m) => ({ from: m.from, to: m.to }))).toEqual( + pmMatches.map((m) => ({ from: m.from, to: m.to })), + ); + } + editor.destroy(); + }); + + it("maps positions that resolve to the query text in the live doc", () => { + const editor = editorFor(docWithMarks); + const index = buildNoteTextIndex(editor.getJSON()); + const [match] = findMatchesInIndex(index, "beta gamma"); + expect(editor.state.doc.textBetween(match.from, match.to, "\n", "")).toBe( + "beta gamma", + ); + editor.destroy(); + }); + + it("does not produce matches crossing a non-text node", () => { + const index = buildNoteTextIndex(docWithMarks); + // "line" ends right before the image leaf; nothing may match across it + const matches = findMatchesInIndex(index, "second alpha line"); + expect(matches.length).toBe(1); + // no match can span from "Note Title" into "alpha" through the title node + const across = findMatchesInIndex(index, "Titlealpha"); + expect(across).toEqual([]); + }); + + it("snippets carry surrounding context", () => { + const index = buildNoteTextIndex(docWithMarks); + const [match] = findMatchesInIndex(index, "second"); + expect(match.snippet).toContain("second alpha line"); + }); +}); diff --git a/src/notepad/notepadSearch.ts b/src/notepad/notepadSearch.ts new file mode 100644 index 0000000..cc2a88a --- /dev/null +++ b/src/notepad/notepadSearch.ts @@ -0,0 +1,176 @@ +import type { JSONContent } from "../texto/core/@types"; + +/** + * Notepad-wide search plumbing: build a plain-text index of a note document + * (JSON shape) where every searchable segment keeps its ProseMirror position, + * and find matches whose from/to are valid PM positions in the mounted editor. + * Works on the JSON representation so collapsed (unmounted) notes are + * searchable too. + */ + +export interface NoteTextSegment { + /** PM position of the first character of the segment. */ + from: number; + /** PM position right after the last character of the segment. */ + to: number; + /** Offset of the segment inside the index text. */ + start: number; +} + +export interface NoteTextIndex { + text: string; + segments: NoteTextSegment[]; +} + +interface TextRun { + from: number; + to: number; + start: number; + end: number; +} + +function isTextNode(node: JSONContent): boolean { + return node.type === "text"; +} + +/** ProseMirror node size of a JSON node (open + children + close). */ +function jsonNodeSize(node: JSONContent): number { + if (isTextNode(node)) return (node.text ?? "").length; + const content = node.content; + if (!Array.isArray(content) || content.length === 0) return 1; + let size = 2; + for (const child of content) size += jsonNodeSize(child); + return size; +} + +/** Build the searchable text of a note document. Segments map text runs to + * PM positions; contiguous text nodes (split by marks) form one run. */ +export function buildNoteTextIndex(doc: JSONContent): NoteTextIndex { + let text = ""; + const segments: NoteTextSegment[] = []; + + const walk = (node: JSONContent, pos: number): number => { + if (isTextNode(node)) { + const value = node.text ?? ""; + if (value.length > 0) { + segments.push({ + from: pos, + to: pos + value.length, + start: text.length, + }); + text += value; + } + return value.length; + } + const content = Array.isArray(node.content) ? node.content : []; + if (content.length === 0) return 1; + let size = 2; + let childPos = pos + 1; + for (const child of content) { + size += walk(child, childPos); + childPos += jsonNodeSize(child); + } + return size; + }; + + // The root document node occupies no position of its own: its children + // start at 0, so the walk begins at -1 (children start at pos + 1). + walk(doc, -1); + return { text, segments }; +} + +/** Merge segments into contiguous runs (a match may cross formatting + * boundaries but never a non-text node). */ +function buildRuns(index: NoteTextIndex): TextRun[] { + const runs: TextRun[] = []; + for (const segment of index.segments) { + const last = runs[runs.length - 1]; + if (last != null && segment.from === last.to) { + last.to = segment.to; + last.end = segment.start + (segment.to - segment.from); + } else { + runs.push({ + from: segment.from, + to: segment.to, + start: segment.start, + end: segment.start + (segment.to - segment.from), + }); + } + } + return runs; +} + +export interface IndexMatch { + from: number; + to: number; + snippet: string; +} + +const SNIPPET_CONTEXT = 32; + +/** Case-insensitive search over the index. Matches never cross non-text + * nodes. */ +export function findMatchesInIndex( + index: NoteTextIndex, + query: string, +): IndexMatch[] { + const normalizedQuery = query.toLocaleLowerCase(); + if (!normalizedQuery) return []; + + const matches: IndexMatch[] = []; + for (const run of buildRuns(index)) { + const runText = index.text.slice(run.start, run.end); + const normalized = runText.toLocaleLowerCase(); + let offset = 0; + while (offset <= normalized.length - normalizedQuery.length) { + const at = normalized.indexOf(normalizedQuery, offset); + if (at === -1) break; + // `at` is relative to the run's text; the run's first character sits + // at PM position run.from. + const from = run.from + at; + const to = from + query.length; + const snippetStart = Math.max(0, at - SNIPPET_CONTEXT); + const snippetEnd = Math.min( + runText.length, + at + query.length + SNIPPET_CONTEXT, + ); + matches.push({ + from, + to, + snippet: + (snippetStart > 0 ? "…" : "") + + runText.slice(snippetStart, snippetEnd) + + (snippetEnd < runText.length ? "…" : ""), + }); + offset = at + Math.max(normalizedQuery.length, 1); + } + } + return matches; +} + +export interface NotepadSearchEntry { + id: string; + title: string; + doc: JSONContent; +} + +export interface NotepadSearchMatch extends IndexMatch { + noteId: string; + noteTitle: string; +} + +/** Search every note of the notepad (document order). */ +export function searchNotepad( + entries: NotepadSearchEntry[], + query: string, +): NotepadSearchMatch[] { + const matches: NotepadSearchMatch[] = []; + if (!query.trim()) return matches; + for (const entry of entries) { + const index = buildNoteTextIndex(entry.doc); + for (const match of findMatchesInIndex(index, query)) { + matches.push({ ...match, noteId: entry.id, noteTitle: entry.title }); + } + } + return matches; +} diff --git a/src/storage/foldPersistence.test.ts b/src/storage/foldPersistence.test.ts new file mode 100644 index 0000000..05e093e --- /dev/null +++ b/src/storage/foldPersistence.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { Editor } from "../texto/core/Editor"; +import type { JSONContent } from "../texto/core/@types"; +import { getExtensions } from "../texto/getExtensions"; +import { getFoldedHeadingPositions } from "../texto/extensions/heading/folding"; +import { + createFoldPersistence, + foldStorageKey, +} from "./foldPersistence"; + +function testContent(): JSONContent { + return { + type: "noteDoc", + content: [ + { type: "noteTitle", content: [{ type: "text", text: "Title" }] }, + { type: "paragraph", content: [{ type: "text", text: "Intro" }] }, + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Section A" }], + }, + { type: "paragraph", content: [{ type: "text", text: "A one" }] }, + { type: "paragraph", content: [{ type: "text", text: "A two" }] }, + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Section B" }], + }, + { type: "paragraph", content: [{ type: "text", text: "B body" }] }, + ], + }; +} + +interface Fixture { + editor: Editor; + /** Position of the "Section A" heading. */ + headingA: number; + dispose: () => void; +} + +const fixtures: Fixture[] = []; + +function buildEditor(content: JSONContent = testContent()): Fixture { + const el = document.createElement("div"); + document.body.appendChild(el); + const editor = new Editor({ + element: el, + content, + extensions: getExtensions({}, { isMobileView: false }), + autofocus: "start", + }); + let headingA = -1; + editor.state.doc.forEach((node, pos) => { + if (node.type.name === "heading" && headingA === -1) headingA = pos; + }); + const fixture = { + editor, + headingA, + dispose: () => { + editor.destroy(); + el.remove(); + }, + }; + fixtures.push(fixture); + return fixture; +} + +afterEach(() => { + for (const fixture of fixtures.splice(0)) fixture.dispose(); +}); + +describe("foldStorageKey", () => { + it("scopes by kind prefix and scope string", () => { + expect(foldStorageKey("heading", "docs/book.note")).toBe( + "inscriptum-note-fold-docs/book.note", + ); + expect(foldStorageKey("task", "docs/book.um/page-1")).toBe( + "inscriptum-task-fold-docs/book.um/page-1", + ); + }); +}); + +describe("createFoldPersistence", () => { + function mockApp() { + const saved = new Map(); + return { + saved, + loadLocalStorage: vi.fn((key: string) => saved.get(key) ?? null), + saveLocalStorage: vi.fn((key: string, value: unknown) => { + if (value == null) saved.delete(key); + else saved.set(key, value); + }), + }; + } + + it("syncs a page fold to the scoped key and restores it into a fresh editor", () => { + const app = mockApp(); + const scope = "docs/book.um/01HZPAGE"; + let currentScope: string | null = scope; + const persistence = createFoldPersistence(app, () => currentScope); + + const first = buildEditor(); + expect(first.editor.commands.foldHeading(first.headingA)).toBe(true); + persistence.sync(first.editor); + + const key = foldStorageKey("heading", scope); + expect(app.saveLocalStorage).toHaveBeenCalledWith(key, { + folds: [first.headingA], + }); + expect(app.saved.get(key)).toMatchObject({ folds: [first.headingA] }); + + // A fresh editor (page re-expanded / file reopened) restores the fold + // from storage: same position folded again, document untouched. + const second = buildEditor(); + persistence.restore(second.editor); + expect(getFoldedHeadingPositions(second.editor.state)).toEqual([ + first.headingA, + ]); + expect(second.editor.getJSON()).toEqual(first.editor.getJSON()); + + // Unfolding persists the cleared state (the stored value is removed, + // like Obsidian's foldManager — no empty [] is kept). + expect(second.editor.commands.unfoldHeading(first.headingA)).toBe(true); + persistence.sync(second.editor); + expect(app.saved.has(key)).toBe(false); + }); + + it("skips sync when nothing changed since the last write", () => { + const app = mockApp(); + const persistence = createFoldPersistence(app, () => "scope"); + const fixture = buildEditor(); + + // First sync writes the empty state for both kinds (a clear, like + // Obsidian's foldManager — no empty [] is kept). + persistence.sync(fixture.editor); + expect(app.saveLocalStorage).toHaveBeenCalledTimes(2); + + fixture.editor.commands.foldHeading(fixture.headingA); + persistence.sync(fixture.editor); + expect(app.saveLocalStorage).toHaveBeenCalledTimes(3); + persistence.sync(fixture.editor); // same positions — no redundant write + expect(app.saveLocalStorage).toHaveBeenCalledTimes(3); + }); + + it("re-resolves the scope live, so a renamed container lands on the new key", () => { + const app = mockApp(); + let scope: string | null = "old/book.um/page"; + const persistence = createFoldPersistence(app, () => scope); + const fixture = buildEditor(); + fixture.editor.commands.foldHeading(fixture.headingA); + + persistence.sync(fixture.editor); + expect( + app.saved.has(foldStorageKey("heading", "old/book.um/page")), + ).toBe(true); + + scope = "renamed/book.um/page"; + fixture.editor.commands.unfoldHeading(fixture.headingA); + persistence.sync(fixture.editor); + // The clear went to the current scope, the old key is left untouched. + expect( + app.saved.has(foldStorageKey("heading", "renamed/book.um/page")), + ).toBe(false); + expect( + app.saved.has(foldStorageKey("heading", "old/book.um/page")), + ).toBe(true); + }); + + it("restore filters stale positions that no longer point at the node kind", () => { + const app = mockApp(); + const scope = "docs/book.um/01HZPAGE"; + app.saved.set(foldStorageKey("heading", scope), { + folds: [999_999, 12.5, Number.NaN, "x"], + }); + const persistence = createFoldPersistence(app, () => scope); + const fixture = buildEditor(); + + persistence.restore(fixture.editor); + expect(getFoldedHeadingPositions(fixture.editor.state)).toEqual([]); + }); + + it("no-ops when the scope is unavailable, and does not consume the change", () => { + const app = mockApp(); + let scope: string | null = null; + const persistence = createFoldPersistence(app, () => scope); + const fixture = buildEditor(); + fixture.editor.commands.foldHeading(fixture.headingA); + + persistence.sync(fixture.editor); + expect(app.saveLocalStorage).not.toHaveBeenCalled(); + + // The scope check must not mark the change as saved: once the scope is + // available (e.g. the file was just assigned to the view), the pending + // positions reach storage. + scope = "docs/book.um/01HZPAGE"; + persistence.sync(fixture.editor); + expect(app.saveLocalStorage).toHaveBeenCalledWith( + foldStorageKey("heading", scope), + { folds: [fixture.headingA] }, + ); + + persistence.restore(fixture.editor); + expect(app.loadLocalStorage).toHaveBeenCalled(); + }); +}); diff --git a/src/storage/foldPersistence.ts b/src/storage/foldPersistence.ts new file mode 100644 index 0000000..e6a3539 --- /dev/null +++ b/src/storage/foldPersistence.ts @@ -0,0 +1,183 @@ +import type { Editor } from "../texto/core"; +import { + getFoldedHeadingPositions, + restoreFoldedHeadings, +} from "../texto/extensions/heading/folding"; +import { + getFoldedTaskPositions, + restoreFoldedTasks, +} from "../texto/extensions/task-item-folding"; + +/** Device-local persistence for in-document fold state (heading + task + * sections), shared by .note (NoteView) and .um pages (NotepadView). + * Mirrors the storage approach of Obsidian's own foldManager + * (`note-fold-`): the fold state is vault-local metadata, never part + * of the note document. Kind prefixes: `inscriptum-note-fold-` (headings, + * existing key), `inscriptum-task-fold-` (task subtasks). */ + +export type FoldKind = "heading" | "task"; + +/** What the fold state belongs to: the note file path for .note, or + * `/` for a .um page — a page has no file of its + * own, and a file path can never have a segment below it, so the join is + * unambiguous. */ +export type FoldScope = string; + +export function foldStorageKey(kind: FoldKind, scope: string): string { + const prefix = + kind === "heading" ? "inscriptum-note-fold-" : "inscriptum-task-fold-"; + return `${prefix}${scope}`; +} + +/** Structural subset of Obsidian's App used here. loadLocalStorage/ + * saveLocalStorage exist since v1.8.7 (typed API), but minAppVersion is + * older — access it structurally so both paths are used and the version + * rule stays satisfied. loadLocalStorage namespaces the value per vault + * like Obsidian's own foldManager does. */ +export interface FoldStorageApp { + loadLocalStorage?: (key: string) => unknown; + saveLocalStorage?: (key: string, value: unknown) => void; +} + +export interface FoldPersistence { + /** Persist the editor's current fold positions for both kinds (no-op + * when nothing changed since the last write). Call from the editor's + * onTransaction; fold toggles carry no doc change, so this never marks + * the document dirty. */ + sync(editor: Editor): void; + /** Restore folds saved for the scope into a freshly created editor. + * Desktop only — the caller gates that (folding is disabled on mobile). */ + restore(editor: Editor): void; +} + +export function createFoldPersistence( + app: FoldStorageApp, + /** Live scope resolver, re-read on every access so a renamed note + * (NoteView) or renamed container (NotepadView) keeps landing on the + * current key without rebuilding the persistence. */ + getScope: () => string | null, +): FoldPersistence { + /** Last fold positions saved to localStorage, per fold kind; guards + * redundant writes. */ + const lastSaved: Record = { + heading: null, + task: null, + }; + + function load(key: string): unknown { + if (typeof app.loadLocalStorage === "function") { + return app.loadLocalStorage(key); + } + try { + const raw = window.localStorage.getItem(key); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } + } + + function save(key: string, data: unknown): void { + if (typeof app.saveLocalStorage === "function") { + app.saveLocalStorage(key, data); + return; + } + try { + if (data == null) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, JSON.stringify(data)); + } + } catch { + // localStorage unavailable (private mode etc.) — folds just won't + // persist, the editor itself is unaffected. + } + } + + function sync(editor: Editor): void { + syncTarget(editor, "heading", "headingFolding"); + syncTarget(editor, "task", "taskItemFolding"); + } + + function syncTarget( + editor: Editor, + kind: FoldKind, + storageName: string, + ): void { + const positions = ( + editor.storage[storageName] as { positions?: number[] } | undefined + )?.positions; + if (positions == null || positions === lastSaved[kind]) return; + + const scope = getScope(); + if (!scope) return; + + // Same guard as Obsidian's foldManager: an empty fold list clears the + // stored value instead of persisting `[]`. + lastSaved[kind] = positions; + save( + foldStorageKey(kind, scope), + positions.length > 0 ? { folds: positions } : null, + ); + } + + function restore(editor: Editor): void { + restoreTarget( + editor, + "heading", + "headingFolding", + "heading", + (view, positions) => restoreFoldedHeadings(view, positions), + ); + restoreTarget( + editor, + "task", + "taskItemFolding", + "taskItem", + (view, positions) => restoreFoldedTasks(view, positions), + ); + } + + function restoreTarget( + editor: Editor, + kind: FoldKind, + storageName: string, + nodeTypeName: string, + restore: (view: Editor["view"], positions: number[]) => void, + ): void { + const scope = getScope(); + if (!scope) return; + + const saved = load(foldStorageKey(kind, scope)) as + | { folds?: number[] } + | null + | undefined; + const folds = saved?.folds; + if (!Array.isArray(folds) || folds.length === 0) return; + + // Positions saved from a previous session may not match this doc if the + // note was edited elsewhere; keep only positions that still point at the + // expected node kind. (The plugin also drops them on later edits via + // mapping.) The bounds check must come before nodeAt — an out-of-range + // position throws there, and stale storage must never break the editor. + const valid = folds.filter((pos) => { + if (typeof pos !== "number" || !Number.isFinite(pos)) return false; + if (pos < 0 || pos >= editor.state.doc.content.size) return false; + const node = editor.state.doc.nodeAt(pos); + return node?.type.name === nodeTypeName; + }); + if (valid.length === 0) return; + + restore(editor.view, valid); + const positions = + kind === "heading" + ? getFoldedHeadingPositions(editor.state) + : getFoldedTaskPositions(editor.state); + const storage = editor.storage[storageName] as + | { positions?: number[] } + | undefined; + if (storage) storage.positions = positions; + lastSaved[kind] = positions; + } + + return { sync, restore }; +} diff --git a/src/storage/noteStorage.test.ts b/src/storage/noteStorage.test.ts index 243b239..0abf590 100644 --- a/src/storage/noteStorage.test.ts +++ b/src/storage/noteStorage.test.ts @@ -181,7 +181,7 @@ describe("noteStorage", () => { const file = new TFile("test.note"); const vault = new Vault(); vault.adapter.stat.mockResolvedValue({ size: 123 }); - vault.adapter.getFullPath = (p: string) => `/vault/${p}`; + vault.adapter.getFullPath = vi.fn((p: string) => `/vault/${p}`); const renameSync = vi.fn(); (window as { require?: unknown }).require = (id: string) => id === "fs" ? { renameSync } : undefined; @@ -375,7 +375,10 @@ describe("sanitizeNoteDoc / parseNoteDoc", () => { type: "hljsCodeBlock", content: [ { type: "hljsCodeBlockRow", content: [{ type: "text", text: "" }] }, - { type: "hljsCodeBlockRow", content: [{ type: "text", text: "code" }] }, + { + type: "hljsCodeBlockRow", + content: [{ type: "text", text: "code" }], + }, ], }, ], @@ -411,9 +414,7 @@ describe("sanitizeNoteDoc / parseNoteDoc", () => { it("parseNoteDoc sanitizes on read", () => { const raw = JSON.stringify({ type: "noteDoc", - content: [ - { type: "paragraph", content: [{ type: "text", text: "" }] }, - ], + content: [{ type: "paragraph", content: [{ type: "text", text: "" }] }], }); const doc = parseNoteDoc(raw); expect(doc.content![0].content).toEqual([]); diff --git a/src/storage/noteStorage.ts b/src/storage/noteStorage.ts index e9c5df0..0698a75 100644 --- a/src/storage/noteStorage.ts +++ b/src/storage/noteStorage.ts @@ -1,4 +1,4 @@ -import type { TFile, Vault } from "obsidian"; +import type { DataAdapter, TFile, Vault } from "obsidian"; import type { JSONContent } from "../texto/core/@types"; import { ensureTrailingParagraphJSON } from "../texto/extensions/note-doc/trailingParagraph"; @@ -138,6 +138,13 @@ export async function writeNote( * the note view (mobile regression). The temp file with the full new * content is cleaned up after a successful write. */ +/** getFullPath is declared in the typings only on the concrete adapter + * classes, but both desktop and mobile adapters expose it — callers + * feature-detect it before use. */ +export type AdapterWithFullPath = DataAdapter & { + getFullPath?: (normalizedPath: string) => string; +}; + async function replaceFile( vault: Vault, tmpPath: string, @@ -145,7 +152,7 @@ async function replaceFile( data: string, ): Promise { const nodeFs = getNodeFs(); - const adapter = vault.adapter; + const adapter: AdapterWithFullPath = vault.adapter; if (nodeFs != null && typeof adapter.getFullPath === "function") { nodeFs.renameSync( adapter.getFullPath(tmpPath), @@ -198,7 +205,7 @@ export async function writeNoteRaw( const started = Date.now(); let priorBytes: number | null = null; try { - priorBytes = (await adapter.stat(file.path)).size; + priorBytes = (await adapter.stat(file.path))?.size ?? null; } catch { // no prior file (or stat unavailable) — logged as null } @@ -212,7 +219,7 @@ export async function writeNoteRaw( // means the storage layer lied about the write succeeding. try { const after = await adapter.stat(file.path); - if (data.length > 0 && after.size === 0) { + if (after != null && data.length > 0 && after.size === 0) { result = "verify-failed"; console.error( `[inscriptum] Write verification failed for "${file.path}": file is 0 bytes after a ${data.length}-byte write.`, diff --git a/src/storage/um/umContainer.test.ts b/src/storage/um/umContainer.test.ts new file mode 100644 index 0000000..c500eae --- /dev/null +++ b/src/storage/um/umContainer.test.ts @@ -0,0 +1,621 @@ +import { describe, expect, it } from "vitest"; +import { unzipSync, zipSync } from "fflate"; +import type { JSONContent } from "../../texto/core/@types"; +import { createEmptyNote } from "../noteStorage"; +import { + assetExtension, + assetPathForId, + collectReferencedAssetIds, + notePathForId, + parseUmContainer, + serializeUmContainer, + sortNoteDescriptors, +} from "./umContainer"; +import { generateUmId, isUmId } from "./umIds"; +import { noteDocTitle, createTitleNoteDoc, UmNotepad } from "./umNotepad"; +import { + FROZEN_MTIME, + MANIFEST_PATH, + UM_SCHEMA_VERSION, + UmError, +} from "./umTypes"; + +const ENC = new TextEncoder(); +const DEC = new TextDecoder(); + +function helloDoc(text: string): JSONContent { + return { + type: "noteDoc", + content: [ + { type: "noteTitle", content: [{ type: "text", text }] }, + { type: "paragraph" }, + ], + }; +} + +function notepadWithTwoNotes(): UmNotepad { + const nb = UmNotepad.empty(); + const a = nb.addNote(undefined, "First"); + nb.setNoteContent(a.id, helloDoc("First")); + const b = nb.addNote(a.id, "Second"); + nb.setNoteContent(b.id, helloDoc("Second")); + nb.dirtyNotes.clear(); + nb.structureChanged = false; + return nb; +} + +describe("umIds", () => { + it("generates unique 26-char base32 ids", () => { + const seen = new Set(); + for (let i = 0; i < 1000; i++) { + const id = generateUmId(); + expect(id).toHaveLength(26); + expect(isUmId(id)).toBe(true); + seen.add(id); + } + expect(seen.size).toBe(1000); + }); + + it("is monotonic within the same millisecond", () => { + const now = 1700000000000; + const a = generateUmId(now); + const b = generateUmId(now); + expect(a).not.toBe(b); + expect(a < b).toBe(true); + }); + + it("rejects non-ULID shapes", () => { + expect(isUmId("notes/photo.png")).toBe(false); + expect(isUmId("short")).toBe(false); + expect(isUmId(null)).toBe(false); + }); +}); + +describe("parseUmContainer / serializeUmContainer", () => { + it("round-trips notes, titles and order", () => { + const nb = notepadWithTwoNotes(); + const parsed = parseUmContainer(nb.serialize()); + + expect(parsed.manifest.format).toBe("um"); + expect(parsed.manifest.version).toBe(1); + expect(parsed.manifest.type).toBe("notepad"); + const notes = sortNoteDescriptors(parsed.manifest.notes); + expect(notes.map((n) => n.title)).toEqual(["First", "Second"]); + expect(notes.map((n) => n.order)).toEqual([0, 1]); + const firstDoc = parsed.notes.get(notes[0].id); + expect(DEC.decode(ENC.encode("")).length).toBe(0); + expect(firstDoc?.type).toBe("noteDoc"); + expect(JSON.stringify(firstDoc)).toContain("First"); + }); + + it("round-trips assets byte-for-byte", () => { + const nb = notepadWithTwoNotes(); + const png = new Uint8Array([137, 80, 78, 71, 1, 2, 3]); + const asset = nb.addAsset(png, "shot.png", "image/png"); + const doc = helloDoc("with image"); + doc.content?.push({ + type: "image", + attrs: { data: { id: asset.id, size: "7", filename: "shot.png" } }, + }); + const note = nb.notes()[0]; + nb.setNoteContent(note.id, doc); + + const parsed = parseUmContainer(nb.serialize()); + expect(parsed.assets.get(asset.id)).toEqual(png); + expect(parsed.manifest.assets?.[0].path).toBe( + assetPathForId(asset.id, "png"), + ); + expect(parsed.manifest.assets?.[0].type).toBe("image/png"); + expect(parsed.manifest.assets?.[0].size).toBe(7); + }); + + it("preserves unknown manifest fields (spec 12.1)", () => { + const nb = notepadWithTwoNotes(); + const parsedOnce = parseUmContainer(nb.serialize()); + (parsedOnce.manifest as Record)["customFeature"] = { + whatever: [1, 2, 3], + }; + (parsedOnce.manifest as Record)["metadata"] = { a: "b" }; + + const reparsed = parseUmContainer(serializeUmContainer(parsedOnce)); + expect(reparsed.manifest["customFeature"]).toEqual({ whatever: [1, 2, 3] }); + expect(reparsed.manifest["metadata"]).toEqual({ a: "b" }); + }); + + it("preserves unknown archive entries (spec 3, 12)", () => { + const nb = notepadWithTwoNotes(); + const bytes = nb.serialize(); + // Hand-craft a container with a foreign directory by rebuilding the zip + // via the parsed data: inject into unknownEntries. + const parsed = parseUmContainer(bytes); + parsed.unknownEntries.set("history/01JABC/steps.json", ENC.encode("[]")); + parsed.unknownEntries.set("editors/review", ENC.encode("{}")); + + const reparsed = parseUmContainer(serializeUmContainer(parsed)); + expect(reparsed.unknownEntries.has("history/01JABC/steps.json")).toBe(true); + expect(DEC.decode(reparsed.unknownEntries.get("editors/review"))).toBe( + "{}", + ); + }); + + it("treats unlisted files under notes/ and assets/ as unknown, not fatal", () => { + const nb = notepadWithTwoNotes(); + const parsed = parseUmContainer(nb.serialize()); + parsed.unknownEntries.set("notes/orphan.json", ENC.encode("{}")); + parsed.unknownEntries.set("assets/orphan.png", new Uint8Array([1])); + + expect(() => parseUmContainer(serializeUmContainer(parsed))).not.toThrow(); + }); + + it("serializes deterministically", () => { + const nb = notepadWithTwoNotes(); + const png = new Uint8Array([9, 8, 7, 6]); + const asset = nb.addAsset(png, "a.jpg", "image/jpeg"); + const doc = helloDoc("x"); + doc.content?.push({ type: "image", attrs: { data: { id: asset.id } } }); + nb.setNoteContent(nb.notes()[0].id, doc); + nb.dirtyNotes.clear(); + + const a = nb.serialize(); + const b = nb.serialize(); + expect(Buffer.from(a).equals(Buffer.from(b))).toBe(true); + // A parse→serialize round trip is stable too. + const c = serializeUmContainer(parseUmContainer(a)); + expect(Buffer.from(c).equals(Buffer.from(a))).toBe(true); + }); + + it("reindexes non-contiguous orders while preserving relative order (spec 8.3)", () => { + const nb = notepadWithTwoNotes(); + const parsed = parseUmContainer(nb.serialize()); + const notes = parsed.manifest.notes; + notes[0].order = 5; + notes[1].order = 42; + + const reserialized = parseUmContainer(serializeUmContainer(parsed)); + const sorted = sortNoteDescriptors(reserialized.manifest.notes); + expect(sorted.map((n) => n.title)).toEqual(["First", "Second"]); + expect(sorted.map((n) => n.order)).toEqual([0, 1]); + }); + + it("writes note documents at the recommended path", () => { + const nb = notepadWithTwoNotes(); + const files = unzipSync(nb.serialize()); + for (const descriptor of parseUmContainer(nb.serialize()).manifest.notes) { + expect(descriptor.path).toBe(notePathForId(descriptor.id)); + expect(files[descriptor.path]).toBeDefined(); + } + }); + + it.each([ + [ + "no-manifest", + new Uint8Array([ + 80, 75, 5, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ]), + ], + ])("rejects an archive without a manifest (%s)", (code, bytes) => { + try { + parseUmContainer(bytes); + throw new Error("expected UmError"); + } catch (err) { + expect(err).toBeInstanceOf(UmError); + expect((err as UmError).code).toBe(code); + } + }); + + it("reports precise error codes for broken manifests", () => { + // Zip a broken manifest directly: serializeUmContainer must stay a + // total function over parsed data, validation lives in parse. + const makeBytes = (override: Record): Uint8Array => { + const base = UmNotepad.empty(); + const manifest = { + ...parseUmContainer(base.serialize()).manifest, + ...override, + }; + return zipSync( + { "manifest.json": ENC.encode(JSON.stringify(manifest)) }, + { mtime: FROZEN_MTIME }, + ); + }; + + const expectCode = (bytes: Uint8Array, code: string) => { + try { + parseUmContainer(bytes); + throw new Error(`expected UmError ${code}`); + } catch (err) { + expect(err).toBeInstanceOf(UmError); + expect((err as UmError).code).toBe(code); + } + }; + + expectCode(makeBytes({ format: "no" }), "bad-format"); + expectCode(makeBytes({ version: 2 }), "bad-version"); + expectCode(makeBytes({ type: "binder" }), "bad-type"); + expectCode(makeBytes({ notes: "nope" }), "bad-notes"); + expectCode(makeBytes({ notes: [{ id: "x" }] }), "bad-descriptor"); + }); + + it("rejects duplicate note ids and missing note documents", () => { + const nb = notepadWithTwoNotes(); + const data = parseUmContainer(nb.serialize()); + const [a, b] = data.manifest.notes; + b.id = a.id; + try { + parseUmContainer(serializeUmContainer(data)); + throw new Error("expected UmError"); + } catch (err) { + expect((err as UmError).code).toBe("duplicate-id"); + } + + const data2 = parseUmContainer(nb.serialize()); + const descriptor = data2.manifest.notes[1]; + data2.notes.delete(descriptor.id); + try { + parseUmContainer(serializeUmContainer(data2)); + throw new Error("expected UmError"); + } catch (err) { + expect((err as UmError).code).toBe("missing-note"); + } + }); + + it("accepts an empty notepad (spec 21)", () => { + const nb = UmNotepad.empty(); + const parsed = parseUmContainer(nb.serialize()); + expect(parsed.manifest.notes).toEqual([]); + expect(sortNoteDescriptors(parsed.manifest.notes)).toEqual([]); + }); +}); + +describe("UmNotepad", () => { + it("adds a note after a given note with contiguous orders", () => { + const nb = notepadWithTwoNotes(); + const first = nb.notes()[0]; + const added = nb.addNote(first.id, "Inserted"); + expect(nb.notes().map((n) => n.title)).toEqual([ + "First", + "Inserted", + "Second", + ]); + expect(nb.notes().map((n) => n.order)).toEqual([0, 1, 2]); + expect(nb.noteContent(added.id)?.type).toBe("noteDoc"); + }); + + it("gc keeps referenced assets across notes and drops the rest", () => { + const nb = notepadWithTwoNotes(); + const kept = nb.addAsset(new Uint8Array([1]), "kept.png", "image/png"); + const dropped = nb.addAsset( + new Uint8Array([2]), + "dropped.png", + "image/png", + ); + + const doc = helloDoc("ref"); + doc.content?.push({ type: "image", attrs: { data: { id: kept.id } } }); + nb.setNoteContent(nb.notes()[0].id, doc); + + expect(nb.gcAssets()).toBe(1); + expect(nb.isAssetId(kept.id)).toBe(true); + expect(nb.isAssetId(dropped.id)).toBe(false); + expect(nb.data.assets.has(dropped.id)).toBe(false); + }); + + it("collects only asset references, not external vault links", () => { + const nb = notepadWithTwoNotes(); + const asset = nb.addAsset(new Uint8Array([1]), "in.png", "image/png"); + + const doc = helloDoc("mixed"); + doc.content?.push({ + type: "image", + attrs: { data: { id: asset.id } }, + }); + doc.content?.push({ + type: "image", + attrs: { data: { id: "attachments/external.png" } }, + }); + nb.setNoteContent(nb.notes()[0].id, doc); + + const refs = nb.referencedAssetIds(); + expect(refs.has(asset.id)).toBe(true); + expect(refs.size).toBe(1); + expect(collectReferencedAssetIds(doc, new Set([asset.id])).size).toBe(1); + }); + + it("tracks dirty notes", () => { + const nb = notepadWithTwoNotes(); + expect(nb.dirtyNotes.size).toBe(0); + const id = nb.notes()[0].id; + nb.setNoteContent(id, helloDoc("changed")); + expect(nb.dirtyNotes.has(id)).toBe(true); + }); + + it("adopts the document's first line as the title on load (spec 8.4)", () => { + const nb = notepadWithTwoNotes(); + const data = parseUmContainer(nb.serialize()); + const first = data.manifest.notes[0]; + first.title = "stale manifest title"; + const doc = data.notes.get(first.id)!; + // Document's first line says "hhhhh" — it must win over the manifest. + doc.content![0] = { + type: "noteTitle", + content: [{ type: "text", text: "hhhhh" }], + }; + + const reopened = UmNotepad.fromBytes(serializeUmContainer(data)); + expect(reopened.note(first.id)?.title).toBe("hhhhh"); + }); + + it("mirrors title changes from setNoteContent", () => { + const nb = notepadWithTwoNotes(); + const id = nb.notes()[0].id; + nb.setNoteContent(id, helloDoc("New first line")); + expect(nb.note(id)?.title).toBe("New first line"); + }); + + it("addNote with a title writes it into the document's first line", () => { + const nb = UmNotepad.empty(); + const added = nb.addNote(undefined, "Starter"); + const titleNode = nb + .noteContent(added.id) + ?.content?.find((n) => n.type === "noteTitle"); + expect(titleNode?.content).toEqual([{ type: "text", text: "Starter" }]); + expect(nb.note(added.id)?.title).toBe("Starter"); + }); + + it("derives an empty title from a note without title text", () => { + const nb = notepadWithTwoNotes(); + const id = nb.notes()[0].id; + const doc = helloDoc(""); + doc.content = doc.content?.map((n) => + n.type === "noteTitle" ? { type: "noteTitle" } : n, + ); + nb.setNoteContent(id, doc); + expect(nb.note(id)?.title).toBe(""); + expect(noteDocTitle(doc)).toBe(""); + }); + + it("removeNote drops the note and reindexes; restoreNote puts it back", () => { + const nb = notepadWithTwoNotes(); + const second = nb.notes()[1]; + const removed = nb.removeNote(second.id); + expect(removed).not.toBeNull(); + expect(nb.notes().map((n) => n.id)).toEqual([nb.notes()[0].id]); + expect(nb.notes()[0].order).toBe(0); + expect(nb.noteContent(second.id)).toBeUndefined(); + + nb.restoreNote(removed!); + expect(nb.notes().map((n) => n.title)).toEqual(["First", "Second"]); + expect(nb.noteContent(second.id)).toEqual(helloDoc("Second")); + expect(nb.notes()[1].order).toBe(1); + }); + + it("removeNote of the first note restores at the original index", () => { + const nb = notepadWithTwoNotes(); + const first = nb.notes()[0]; + const removed = nb.removeNote(first.id); + nb.restoreNote(removed!); + expect(nb.notes().map((n) => n.title)).toEqual(["First", "Second"]); + }); + + it("removeNote returns null for an unknown id", () => { + const nb = notepadWithTwoNotes(); + expect(nb.removeNote("missing")).toBeNull(); + }); + + it("duplicateNote copies the document after the original with a fresh id", () => { + const nb = notepadWithTwoNotes(); + const first = nb.notes()[0]; + const copy = nb.duplicateNote(first.id); + + expect(copy).not.toBeNull(); + expect(copy?.id).not.toBe(first.id); + expect(copy?.title).toBe("First (copy)"); + expect(nb.notes().map((n) => n.title)).toEqual([ + "First", + "First (copy)", + "Second", + ]); + expect(nb.notes().map((n) => n.order)).toEqual([0, 1, 2]); + // The copy's first line carries the "(copy)" title so the mirror sync + // does not wipe the suffix on reload. + const copyTitleNode = nb + .noteContent(copy!.id) + ?.content?.find((n) => n.type === "noteTitle"); + expect(copyTitleNode?.content).toEqual([ + { type: "text", text: "First (copy)" }, + ]); + + const parsed = parseUmContainer(nb.serialize()); + expect(parsed.notes.get(copy!.id)).toEqual(nb.noteContent(copy!.id)); + }); + + it("duplicateNote of an untitled note stays untitled", () => { + const nb = notepadWithTwoNotes(); + const copy = nb.duplicateNote(nb.notes()[1].id); + expect(copy?.title).toBe("Second (copy)"); + const empty = UmNotepad.empty(); + const added = empty.addNote(); + const copy2 = empty.duplicateNote(added.id); + expect(copy2?.title ?? "").toBe(""); + }); + + it("moveNote reorders without touching ids or content", () => { + const nb = notepadWithTwoNotes(); + const first = nb.notes()[0]; + expect(nb.moveNote(first.id, 1)).toBe(true); + expect(nb.notes().map((n) => n.title)).toEqual(["Second", "First"]); + expect(nb.notes().map((n) => n.order)).toEqual([0, 1]); + expect(nb.noteContent(first.id)).toEqual(helloDoc("First")); + + // round-trip keeps the new order + const parsed = parseUmContainer(nb.serialize()); + expect( + sortNoteDescriptors(parsed.manifest.notes).map((n) => n.title), + ).toEqual(["Second", "First"]); + }); + + it("moveNote clamps out-of-range indexes", () => { + const nb = notepadWithTwoNotes(); + const first = nb.notes()[0]; + nb.moveNote(first.id, 99); + expect(nb.notes().map((n) => n.title)).toEqual(["Second", "First"]); + }); + + it("persists the expanded flag and defaults to collapsed", () => { + const nb = notepadWithTwoNotes(); + expect(nb.isExpanded(nb.notes()[0].id)).toBe(false); // absent = collapsed + nb.setExpanded(nb.notes()[0].id, true); + + const reopened = UmNotepad.fromBytes(nb.serialize()); + expect(reopened.isExpanded(nb.notes()[0].id)).toBe(true); + expect(reopened.isExpanded(nb.notes()[1].id)).toBe(false); + + // Collapsing removes the flag from the manifest. + reopened.setExpanded(reopened.notes()[0].id, false); + const final = UmNotepad.fromBytes(reopened.serialize()); + expect(final.notes()[0].expanded).toBeUndefined(); + expect(final.notes()[0].expanded === undefined).toBe(true); + }); + + it("restores the expanded flag together with a removed note", () => { + const nb = notepadWithTwoNotes(); + const first = nb.notes()[0]; + nb.setExpanded(first.id, true); + const removed = nb.removeNote(first.id); + expect(nb.isExpanded(first.id)).toBe(false); + nb.restoreNote(removed!); + expect(nb.isExpanded(first.id)).toBe(true); + }); +}); + +describe("path helpers", () => { + it("derives asset extensions from filename or mime", () => { + expect(assetExtension("photo.PNG")).toBe("png"); + expect(assetExtension("noext", "image/webp")).toBe("webp"); + expect(assetExtension("noext", "image/svg+xml")).toBe("svg"); + expect(assetExtension("noext")).toBe("bin"); + expect(assetPathForId("01JABC", "png")).toBe("assets/01JABC.png"); + }); +}); + +describe("createEmptyNote compatibility", () => { + it("a plain .note document is a valid note payload (spec 9)", () => { + const nb = UmNotepad.empty(); + // A page added into an empty notepad becomes the title page (order 0): + // its starter document carries the title/summary header (spec 9.1). + const added = nb.addNote(); + expect(nb.noteContent(added.id)).toEqual(createTitleNoteDoc()); + const parsed = parseUmContainer(nb.serialize()); + expect(parsed.notes.get(added.id)).toEqual(createTitleNoteDoc()); + + // The plain .note starter (noteTitle + paragraph) stays a valid payload + // for any page: setNoteContent accepts it, the plain profile parses it. + nb.setNoteContent(added.id, createEmptyNote()); + expect(nb.noteContent(added.id)).toEqual(createEmptyNote()); + }); +}); + +describe("size limits (zip-bomb guard)", () => { + it("rejects an archive that inflates past the limit", () => { + // ~1MB of zeros deflates to ~1KB — a real bomb shape at test scale. + const bomb = zipSync( + { "notes/x.json": new Uint8Array(1024 * 1024) }, + { mtime: FROZEN_MTIME }, + ); + try { + parseUmContainer(bomb, { maxInflatedBytes: 1024 }); + throw new Error("expected UmError"); + } catch (err) { + expect(err).toBeInstanceOf(UmError); + expect((err as UmError).code).toBe("too-large"); + } + }); + + it("rejects an archive over the compressed input limit", () => { + const bytes = serializeUmContainer(notepadWithTwoNotes().data); + expect(bytes.byteLength).toBeGreaterThan(10); + try { + parseUmContainer(bytes, { maxFileBytes: 10 }); + throw new Error("expected UmError"); + } catch (err) { + expect(err).toBeInstanceOf(UmError); + expect((err as UmError).code).toBe("too-large"); + } + }); + + it("skips past-limit entries instead of decompressing them", () => { + // One honest entry plus one oversized one: the oversized entry must not + // be inflated at all (the cap is enforced from declared sizes), and the + // whole parse fails — never a partially loaded container. + const bomb = zipSync( + { + "manifest.json": ENC.encode( + JSON.stringify({ format: "um", version: 1, type: "notepad" }), + ), + "notes/big.json": new Uint8Array(2 * 1024 * 1024), + }, + { mtime: FROZEN_MTIME }, + ); + expect(() => + parseUmContainer(bomb, { maxInflatedBytes: 1024 * 1024 }), + ).toThrowError(UmError); + }); + + it("parses a normal container under the default limits", () => { + const bytes = serializeUmContainer(notepadWithTwoNotes().data); + const parsed = parseUmContainer(bytes); + expect(parsed.notes.size).toBe(2); + }); +}); + +describe("hostile asset descriptors (write guard)", () => { + it("asset paths cannot overwrite the manifest or a note document", () => { + const parsed = parseUmContainer(notepadWithTwoNotes().serialize()); + const notePath = parsed.manifest.notes[0].path; + parsed.manifest.assets = [ + { id: "01JHACK1", path: MANIFEST_PATH, type: "image/png" }, + { id: "01JHACK2", path: notePath, type: "image/png" }, + ]; + parsed.assets.set("01JHACK1", new Uint8Array([1, 2, 3])); + parsed.assets.set("01JHACK2", new Uint8Array([4, 5, 6])); + + const bytes = serializeUmContainer(parsed); + + // The required entries survive intact and the container still parses. + const reparsed = parseUmContainer(bytes); + expect(reparsed.manifest.format).toBe("um"); + expect(reparsed.manifest.notes).toHaveLength(2); + expect(reparsed.notes.size).toBe(2); + expect(DEC.decode(unzipSync(bytes)[MANIFEST_PATH])).toContain('"um"'); + }); +}); + +describe("asset GC vs uninterpretable pages", () => { + const withPackedAsset = (bytes: Uint8Array): UmNotepad => { + const parsed = parseUmContainer(bytes); + parsed.manifest.assets = [ + { id: "01JASSET", path: "assets/01JASSET.png", type: "image/png" }, + ]; + parsed.assets.set("01JASSET", new Uint8Array([1, 2, 3])); + return new UmNotepad(parsed); + }; + + it("collects an unreferenced asset when every page is openable", () => { + const nb = withPackedAsset(notepadWithTwoNotes().serialize()); + expect(nb.gcAssets()).toBe(1); + }); + + it("never collects while an uninterpretable page exists (spec 8.6.2)", () => { + const parsed = parseUmContainer(notepadWithTwoNotes().serialize()); + // A page from a newer editor version: its asset references are shapes + // this editor cannot read, so it must pin the registry. + parsed.manifest.notes[1].schemaVersion = UM_SCHEMA_VERSION + 1; + const nb = withPackedAsset(serializeUmContainer(parsed)); + + expect(nb.gcAssets()).toBe(0); + expect(nb.data.assets.has("01JASSET")).toBe(true); + + // A save keeps the asset bytes in the archive. + const reparsed = parseUmContainer(nb.serialize()); + expect(reparsed.assets.has("01JASSET")).toBe(true); + }); +}); diff --git a/src/storage/um/umContainer.ts b/src/storage/um/umContainer.ts new file mode 100644 index 0000000..54d430d --- /dev/null +++ b/src/storage/um/umContainer.ts @@ -0,0 +1,380 @@ +import { unzipSync, zipSync, type Zippable } from "fflate"; +import type { JSONContent } from "../../texto/core/@types"; +import { + ASSETS_DIR, + FROZEN_MTIME, + MANIFEST_PATH, + UM_FORMAT, + UM_MAX_FILE_BYTES, + UM_MAX_INFLATED_BYTES, + UM_TYPE, + UM_VERSION, + UmError, + type UmAssetDescriptor, + type UmContainerData, + type UmManifest, + type UmNoteDescriptor, +} from "./umTypes"; + +/** + * Pure core of the `.um` container: parse an archive into an in-memory + * representation and serialize it back. ZIP is an implementation detail + * (spec section 4) — all semantics come from the manifest. + * + * Compatibility rules implemented here (spec section 12): unknown manifest + * fields and unknown archive entries survive a round trip untouched; only + * the five required manifest checks (section 22) can reject a container. + */ + +const TEXT_DECODER = new TextDecoder(); +const TEXT_ENCODER = new TextEncoder(); + +function decodeJson(bytes: Uint8Array): unknown { + return JSON.parse(TEXT_DECODER.decode(bytes)); +} + +function encodeJson(value: unknown): Uint8Array { + return TEXT_ENCODER.encode(JSON.stringify(value, null, 2)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Overridable size caps (see UM_MAX_* in umTypes) — tests pass tiny + * values; production callers use the defaults. */ +export interface UmParseLimits { + maxFileBytes?: number; + maxInflatedBytes?: number; +} + +/** Parse and validate a `.um` archive (spec section 22). Throws UmError. + * Size-guarded: a hostile archive (zip bomb) fails with "too-large" + * instead of exhausting memory. */ +export function parseUmContainer( + bytes: Uint8Array, + limits?: UmParseLimits, +): UmContainerData { + const maxFileBytes = limits?.maxFileBytes ?? UM_MAX_FILE_BYTES; + const maxInflatedBytes = limits?.maxInflatedBytes ?? UM_MAX_INFLATED_BYTES; + if (bytes.byteLength > maxFileBytes) { + throw new UmError( + "too-large", + `Archive exceeds the size limit: ${bytes.byteLength} bytes (max ${maxFileBytes})`, + ); + } + + // unzipSync preallocates each entry's output buffer at its declared + // central-directory size and never grows it, so summing the declared + // sizes bounds the actual allocation. Entries are skipped (not + // decompressed) once the running total crosses the cap. + let inflated = 0; + let overLimit = false; + let files: Record; + try { + files = unzipSync(bytes, { + filter: ({ originalSize }) => { + inflated += originalSize; + if (inflated > maxInflatedBytes) { + overLimit = true; + return false; + } + return true; + }, + }); + } catch (err) { + throw new UmError( + "no-manifest", + `Not a readable UM archive: ${err instanceof Error ? err.message : String(err)}`, + ); + } + if (overLimit) { + throw new UmError( + "too-large", + `Archive inflates past the size limit (max ${maxInflatedBytes} bytes)`, + ); + } + + const manifestBytes = files[MANIFEST_PATH]; + if (manifestBytes == null) { + throw new UmError("no-manifest", `Missing ${MANIFEST_PATH}`); + } + let manifest: UmManifest; + try { + manifest = decodeJson(manifestBytes) as UmManifest; + } catch (err) { + throw new UmError( + "bad-manifest-json", + `${MANIFEST_PATH} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, + ); + } + if (!isRecord(manifest)) { + throw new UmError("bad-manifest-json", `${MANIFEST_PATH} is not an object`); + } + if (manifest.format !== UM_FORMAT) { + throw new UmError("bad-format", `format must be "${UM_FORMAT}"`); + } + if (manifest.version !== UM_VERSION) { + throw new UmError( + "bad-version", + `Unsupported UM version: ${String(manifest.version)}`, + ); + } + if (manifest.type !== UM_TYPE) { + throw new UmError( + "bad-type", + `Unsupported container type: ${String(manifest.type)}`, + ); + } + if (!Array.isArray(manifest.notes)) { + throw new UmError("bad-notes", "manifest.notes must be an array"); + } + + const notes = new Map(); + const seenIds = new Set(); + const seenPaths = new Set(); + const claimedPaths = new Set([MANIFEST_PATH]); + + for (const raw of manifest.notes) { + if (!isRecord(raw)) { + throw new UmError( + "bad-descriptor", + "Every note descriptor must be an object", + ); + } + const { id, path, order } = raw as Partial; + if (typeof id !== "string" || id.length === 0) { + throw new UmError("bad-descriptor", "Note descriptor is missing an id"); + } + if (typeof path !== "string" || path.length === 0) { + throw new UmError("bad-descriptor", `Note "${id}" is missing a path`); + } + if (typeof order !== "number" || !Number.isFinite(order)) { + throw new UmError("bad-descriptor", `Note "${id}" has an invalid order`); + } + if (raw.title !== undefined && typeof raw.title !== "string") { + throw new UmError("bad-descriptor", `Note "${id}" has an invalid title`); + } + if (raw.expanded !== undefined && typeof raw.expanded !== "boolean") { + throw new UmError( + "bad-descriptor", + `Note "${id}" has an invalid expanded flag`, + ); + } + if (raw.schema !== undefined && typeof raw.schema !== "string") { + throw new UmError("bad-descriptor", `Note "${id}" has an invalid schema`); + } + if ( + raw.schemaVersion !== undefined && + (!Number.isInteger(raw.schemaVersion) || raw.schemaVersion < 1) + ) { + throw new UmError( + "bad-descriptor", + `Note "${id}" has an invalid schemaVersion`, + ); + } + if (seenIds.has(id)) { + throw new UmError("duplicate-id", `Duplicate note id: ${id}`); + } + if (seenPaths.has(path)) { + throw new UmError("bad-descriptor", `Duplicate note path: ${path}`); + } + seenIds.add(id); + seenPaths.add(path); + claimedPaths.add(path); + } + + const loadedPaths = new Map(); + for (const descriptor of manifest.notes as UmNoteDescriptor[]) { + let doc = loadedPaths.get(descriptor.path); + if (doc === undefined) { + const noteBytes = files[descriptor.path]; + if (noteBytes == null) { + throw new UmError( + "missing-note", + `Note document not found in archive: ${descriptor.path}`, + ); + } + try { + doc = decodeJson(noteBytes) as JSONContent; + } catch (err) { + throw new UmError( + "bad-note-json", + `${descriptor.path} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, + ); + } + if (!isRecord(doc)) { + throw new UmError( + "bad-note-json", + `${descriptor.path} is not an object`, + ); + } + loadedPaths.set(descriptor.path, doc); + } + notes.set(descriptor.id, doc); + } + + const assets = new Map(); + const assetDescriptors = readAssetDescriptors(manifest); + for (const asset of assetDescriptors) { + claimedPaths.add(asset.path); + const bytes = files[asset.path]; + // A missing asset is a broken image, not a broken notepad (spec 22: + // invalid optional extensions must not make the container unreadable). + if (bytes != null) assets.set(asset.id, bytes); + } + + // Everything we don't understand is carried over verbatim (spec 3, 12). + const unknownEntries = new Map(); + for (const [path, content] of Object.entries(files)) { + if (!claimedPaths.has(path)) { + unknownEntries.set(path, content); + } + } + + return { manifest, notes, assets, unknownEntries }; +} + +/** Read the optional asset registry from a manifest, tolerating junk: an + * invalid `assets` value degrades to "no assets" instead of rejecting the + * container (spec 12.2). Duplicate entries keep their first occurrence. */ +export function readAssetDescriptors( + manifest: UmManifest, +): UmAssetDescriptor[] { + const raw = manifest.assets; + if (!Array.isArray(raw)) return []; + const out: UmAssetDescriptor[] = []; + const seen = new Set(); + for (const entry of raw) { + if (!isRecord(entry)) continue; + const { id, path } = entry as Partial; + if (typeof id !== "string" || typeof path !== "string") continue; + if (id.length === 0 || path.length === 0 || seen.has(id)) continue; + seen.add(id); + out.push({ + id, + path, + type: typeof entry.type === "string" ? entry.type : undefined, + size: typeof entry.size === "number" ? entry.size : undefined, + }); + } + return out; +} + +function writeAssetDescriptors( + manifest: UmManifest, + assets: UmAssetDescriptor[], +): void { + if (assets.length === 0) { + delete manifest.assets; + } else { + manifest.assets = assets; + } +} + +/** Serialize the container deterministically: fixed timestamps, fixed entry + * order, pretty-printed JSON. Assets and unknown entries are stored + * uncompressed (level 0) — images are already compressed and unknown + * entries must not be mangled. */ +export function serializeUmContainer(data: UmContainerData): Uint8Array { + const sorted = sortNoteDescriptors(data.manifest.notes); + reindexOrders(sorted); + + const entries: Zippable = {}; + entries[MANIFEST_PATH] = encodeJson(data.manifest); + + // The manifest and note documents are required entries (spec 22): no + // asset or unknown entry — e.g. a hostile descriptor naming a protected + // path — may overwrite one. + const writtenPaths = new Set([MANIFEST_PATH]); + for (const descriptor of sorted) { + if (writtenPaths.has(descriptor.path)) continue; + writtenPaths.add(descriptor.path); + const doc = data.notes.get(descriptor.id); + if (doc == null) continue; + entries[descriptor.path] = encodeJson(doc); + } + + for (const asset of readAssetDescriptors(data.manifest)) { + const bytes = data.assets.get(asset.id); + if (bytes == null || writtenPaths.has(asset.path)) continue; + writtenPaths.add(asset.path); + entries[asset.path] = [bytes, { level: 0, mtime: FROZEN_MTIME }]; + } + + for (const [path, bytes] of data.unknownEntries) { + if (writtenPaths.has(path)) continue; + writtenPaths.add(path); + entries[path] = [bytes, { level: 0, mtime: FROZEN_MTIME }]; + } + + return zipSync(entries, { mtime: FROZEN_MTIME, level: 6 }); +} + +/** Notes ordered by explicit `order`, ties broken by manifest order (spec 7: + * ordering must never come from the archive or the filesystem). */ +export function sortNoteDescriptors( + notes: UmNoteDescriptor[], +): UmNoteDescriptor[] { + return notes + .map((descriptor, index) => ({ descriptor, index })) + .sort( + (a, b) => a.descriptor.order - b.descriptor.order || a.index - b.index, + ) + .map(({ descriptor }) => descriptor); +} + +/** Make `order` values contiguous and zero-based after every mutation (spec + * 8.3: "SHOULD be unique and contiguous"). */ +export function reindexOrders(notes: UmNoteDescriptor[]): void { + notes.forEach((descriptor, index) => { + descriptor.order = index; + }); +} + +/** Build the recommended archive path for a new note document (spec 8.2). */ +export function notePathForId(id: string): string { + return `notes/${id}.json`; +} + +function sanitizeExtension(ext: string): string { + const clean = ext.toLowerCase().replace(/[^a-z0-9]/g, ""); + return clean.length > 0 && clean.length <= 8 ? clean : "bin"; +} + +/** Archive extension for a new asset, derived from the file name (preferred) + * or the MIME type. */ +export function assetExtension(filename: string, mime?: string): string { + const dot = filename.lastIndexOf("."); + if (dot >= 0 && dot < filename.length - 1) { + return sanitizeExtension(filename.slice(dot + 1)); + } + const fromMime = mime?.split("/")[1]; + if (fromMime) return sanitizeExtension(fromMime.split("+")[0]); + return "bin"; +} + +/** Build the recommended archive path for an asset (spec section 17). */ +export function assetPathForId(id: string, ext: string): string { + return `${ASSETS_DIR}/${id}.${ext}`; +} + +/** Walk a note document and collect the asset ids its image nodes reference. + * A reference is an image node whose `data.id` names an entry of the given + * asset id set — anything else is an external vault link and is ignored. */ +export function collectReferencedAssetIds( + doc: JSONContent, + assetIds: Set, + into: Set = new Set(), +): Set { + const data = doc.attrs?.data as { id?: unknown } | undefined; + if (doc.type === "image" && typeof data?.id === "string") { + if (assetIds.has(data.id)) into.add(data.id); + } + if (Array.isArray(doc.content)) { + for (const child of doc.content) { + collectReferencedAssetIds(child, assetIds, into); + } + } + return into; +} diff --git a/src/storage/um/umIds.ts b/src/storage/um/umIds.ts new file mode 100644 index 0000000..e2841e1 --- /dev/null +++ b/src/storage/um/umIds.ts @@ -0,0 +1,77 @@ +/** + * Opaque, sortable unique identifiers for notes and assets (spec 8.1: + * "implementations SHOULD use opaque identifiers"). ULID shape: 48-bit + * millisecond timestamp + 80 bits of randomness, Crockford base32, 26 chars. + * Lexicographic order equals creation order, which keeps manifests stable + * and human-scannable. + */ + +const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; +const TIME_LEN = 10; +const RANDOM_LEN = 16; + +let lastTime = 0; +/** Monotonicity within the same millisecond: increment the random part so + * two IDs generated in one tick never collide and keep their order. */ +let lastRandom: number[] = []; + +function randomBits(length: number): number[] { + const out: number[] = new Array(length); + const bytes = new Uint8Array(length); + if (typeof globalThis.crypto?.getRandomValues === "function") { + globalThis.crypto.getRandomValues(bytes); + } else { + for (let i = 0; i < length; i++) { + bytes[i] = Math.floor(Math.random() * 256); + } + } + for (let i = 0; i < length; i++) { + out[i] = bytes[i] % 32; + } + return out; +} + +function increment(values: number[]): number[] { + const out = values.slice(); + for (let i = out.length - 1; i >= 0; i--) { + out[i] += 1; + if (out[i] < 32) return out; + out[i] = 0; + } + // 80 bits overflowed — astronomically unlikely; start fresh. + return randomBits(out.length); +} + +export function generateUmId(now = Date.now()): string { + let time = now; + let random: number[]; + if (time === lastTime) { + random = increment(lastRandom); + } else { + random = randomBits(RANDOM_LEN); + } + lastTime = time; + lastRandom = random; + + let id = ""; + for (let i = TIME_LEN - 1; i >= 0; i--) { + id = ENCODING[time % 32] + id; + time = Math.floor(time / 32); + } + for (const bits of random) { + id += ENCODING[bits]; + } + return id; +} + +/** Strict ULID-shape check used to tell packed asset ids apart from vault + * paths in image node `data.id` (a vault path always contains "/" or "."). */ +export function isUmId(value: unknown): value is string { + if (typeof value !== "string" || value.length !== TIME_LEN + RANDOM_LEN) { + return false; + } + for (const ch of value) { + if (!ENCODING.includes(ch)) return false; + } + return true; +} diff --git a/src/storage/um/umNotepad.ts b/src/storage/um/umNotepad.ts new file mode 100644 index 0000000..369330f --- /dev/null +++ b/src/storage/um/umNotepad.ts @@ -0,0 +1,577 @@ +import type { JSONContent } from "../../texto/core/@types"; +import { + assetExtension, + assetPathForId, + collectReferencedAssetIds, + notePathForId, + parseUmContainer, + reindexOrders, + serializeUmContainer, + sortNoteDescriptors, +} from "./umContainer"; +import { generateUmId } from "./umIds"; +import { + inferSchemaFields, + interpretSchema, + migrateNoteDoc, + type UmSchemaState, +} from "./umSchemas"; +import { + UM_SCHEMA_PLAIN, + UM_SCHEMA_TITLE, + UM_SCHEMA_VERSION, + UmError, + type UmAssetDescriptor, + type UmContainerData, + type UmManifest, + type UmNoteDescriptor, +} from "./umTypes"; + +/** + * In-memory notepad: the parsed container plus the mutations the editor + * UI performs on it (note content, titles, assets). Persistence lives in + * umVault; this class is Obsidian-free and directly testable. + * + * Asset references: an image node inside a notepad note stores the asset id + * in `data.id` (spec section 17). Membership in the asset registry decides + * whether an id is a packed asset or an external vault link, so the note + * document itself needs no new attribute shapes. + */ +export class UmNotepad { + readonly data: UmContainerData; + + /** Notes whose editor content changed since the last save. */ + readonly dirtyNotes = new Set(); + /** True when notes or assets were added/removed/renamed (manifest changed). */ + structureChanged = false; + + /** Fingerprint of the on-disk state this notepad last reflected (its own + * write or the last external load). Set by umVault; used to tell our own + * saves apart from foreign vault "modify" events. */ + savedFingerprint: string | null = null; + + private objectUrls = new Map(); + /** Notes whose migration chain failed at load (8.6.3): shown as a + * notice, never edited, preserved verbatim on rewrite. */ + private migrationFailed = new Set(); + + constructor(data: UmContainerData) { + this.data = data; + // Legacy containers (8.6.2): stamp the inferred family/version so the + // next save writes them back. Quiet — opening never writes by itself. + for (const descriptor of this.data.manifest.notes) { + if (descriptor.schema == null) { + descriptor.schema = inferSchemaFields(descriptor.order).schema; + } + if (descriptor.schemaVersion == null) { + descriptor.schemaVersion = UM_SCHEMA_VERSION; + } + } + for (const [id, doc] of this.data.notes) { + this.prepareNote(id, doc); + } + } + + /** Load-time preparation of one note: run pending migrations (8.6.3), + * normalize the title-page header, mirror the display title. Notes this + * editor cannot interpret are left completely untouched (8.6.2). */ + private prepareNote(id: string, doc: JSONContent): void { + const descriptor = this.note(id); + if (!descriptor) return; + const state = interpretSchema(descriptor); + if (state.kind === "unsupported") return; + + let prepared = doc; + if (state.kind === "openable" && state.version < UM_SCHEMA_VERSION) { + const migrated = migrateNoteDoc(state.family, state.version, doc); + if (!migrated.ok) { + this.migrationFailed.add(id); + return; + } + prepared = migrated.doc; + this.data.notes.set(id, prepared); + descriptor.schemaVersion = migrated.toVersion; + this.dirtyNotes.add(id); + } + + if (state.family === UM_SCHEMA_TITLE) { + const normalized = normalizeTitleDoc(prepared); + if (normalized.changed) { + prepared = normalized.doc; + this.data.notes.set(id, prepared); + this.dirtyNotes.add(id); + } + } + + this.syncTitleFromDoc(id, prepared); + } + + /** How this editor interprets the note (8.6.2) — the view uses it to pick + * the editor profile or show a newer-version notice. */ + noteSchemaState(id: string): UmSchemaState { + const descriptor = this.note(id); + if (!descriptor) { + return { kind: "unsupported", family: null, version: null }; + } + if (this.migrationFailed.has(id)) { + return { kind: "invalid", family: descriptor.schema ?? "" }; + } + return interpretSchema(descriptor); + } + + isNoteOpenable(id: string): boolean { + return this.noteSchemaState(id).kind === "openable"; + } + + static fromBytes(bytes: Uint8Array): UmNotepad { + return new UmNotepad(parseUmContainer(bytes)); + } + + /** A brand-new notepad with no notes (spec section 21). */ + static empty(): UmNotepad { + return new UmNotepad({ + manifest: { + format: "um", + version: 1, + type: "notepad", + notes: [], + }, + notes: new Map(), + assets: new Map(), + unknownEntries: new Map(), + }); + } + + /** Ordered note descriptors. */ + notes(): UmNoteDescriptor[] { + return sortNoteDescriptors(this.data.manifest.notes); + } + + note(id: string): UmNoteDescriptor | undefined { + return this.data.manifest.notes.find((n) => n.id === id); + } + + noteContent(id: string): JSONContent | undefined { + return this.data.notes.get(id); + } + + /** Notepad UI expanded state — persisted on the descriptor (absent = + * collapsed, the spec's initial default). */ + isExpanded(id: string): boolean { + return this.note(id)?.expanded === true; + } + + setExpanded(id: string, expanded: boolean): void { + const descriptor = this.note(id); + if (!descriptor || descriptor.expanded === expanded) return; + if (expanded) { + descriptor.expanded = true; + } else { + delete descriptor.expanded; + } + this.structureChanged = true; + } + + /** The display title of a note, mirrored from its document: the header + * title node for title pages, the first line for regular notes (8.4). */ + private syncTitleFromDoc(id: string, doc: JSONContent): void { + const descriptor = this.note(id); + if (!descriptor) return; + descriptor.title = noteDisplayTitle(doc, descriptor.schema); + } + + /** Insert a new empty note after `afterId` (or at the end) and return its + * descriptor. The first page of an empty notepad is the title page and + * starts with the title/summary header (spec 9.1); every other page is + * plain content without a mandatory title (9.2). */ + addNote(afterId?: string, title = ""): UmNoteDescriptor { + const id = generateUmId(); + const descriptor: UmNoteDescriptor = { + id, + path: notePathForId(id), + order: 0, + title, + }; + + const sorted = sortNoteDescriptors(this.data.manifest.notes); + const at = + afterId != null + ? sorted.findIndex((n) => n.id === afterId) + 1 + : sorted.length; + sorted.splice(at, 0, descriptor); + reindexOrders(sorted); + this.data.manifest.notes = sorted; + + const isTitlePage = at === 0; + descriptor.schema = isTitlePage ? UM_SCHEMA_TITLE : UM_SCHEMA_PLAIN; + descriptor.schemaVersion = UM_SCHEMA_VERSION; + this.data.notes.set( + id, + isTitlePage ? createTitleNoteDoc(title) : createPlainNoteDoc(), + ); + this.structureChanged = true; + return descriptor; + } + + /** Everything needed to undo a removal. */ + removeNote(id: string): { + descriptor: UmNoteDescriptor; + doc: JSONContent; + index: number; + } | null { + const sorted = sortNoteDescriptors(this.data.manifest.notes); + const index = sorted.findIndex((n) => n.id === id); + if (index === -1) return null; + const [descriptor] = sorted.splice(index, 1); + reindexOrders(sorted); + this.data.manifest.notes = sorted; + const doc = this.data.notes.get(id); + if (doc == null) return null; + this.data.notes.delete(id); + this.dirtyNotes.delete(id); + this.structureChanged = true; + return { descriptor, doc, index }; + } + + /** Put a removed note back at its former position (delete undo). */ + restoreNote(removed: { + descriptor: UmNoteDescriptor; + doc: JSONContent; + index: number; + }): void { + const sorted = sortNoteDescriptors(this.data.manifest.notes); + const at = Math.min(Math.max(removed.index, 0), sorted.length); + sorted.splice(at, 0, removed.descriptor); + reindexOrders(sorted); + this.data.manifest.notes = sorted; + this.data.notes.set(removed.descriptor.id, removed.doc); + this.structureChanged = true; + } + + /** Duplicate a note right after the original: deep copy of the document + * (image nodes keep referencing the same asset ids — assets are shared, + * not copied) and a fresh stable id/path. */ + duplicateNote(id: string): UmNoteDescriptor | null { + const sourceDoc = this.data.notes.get(id); + const sourceDescriptor = this.note(id); + if (!sourceDoc || !sourceDescriptor) return null; + + const newId = generateUmId(); + const baseTitle = sourceDescriptor.title ?? ""; + const descriptor: UmNoteDescriptor = { + id: newId, + path: notePathForId(newId), + order: 0, + title: baseTitle ? `${baseTitle} (copy)` : "", + // A duplicate is never the title page: duplicating order 0 is + // refused by the view, and the copy lands at order ≥ 1. + schema: sourceDescriptor.schema ?? UM_SCHEMA_PLAIN, + schemaVersion: sourceDescriptor.schemaVersion ?? UM_SCHEMA_VERSION, + }; + + const sorted = sortNoteDescriptors(this.data.manifest.notes); + const at = sorted.findIndex((n) => n.id === id) + 1; + sorted.splice(at, 0, descriptor); + reindexOrders(sorted); + this.data.manifest.notes = sorted; + + // The "(copy)" suffix must also land in the copy document's title + // source (header node or first line), or the title mirror wipes it on + // the next load. A non-empty base title guarantees a writable line. + const copyDoc: JSONContent = JSON.parse(JSON.stringify(sourceDoc)); + if (descriptor.title) { + const titled = + descriptor.schema === UM_SCHEMA_TITLE + ? withNoteTitleText(copyDoc, descriptor.title) + : withFirstLineTitle(copyDoc, descriptor.title); + this.data.notes.set(newId, titled); + } else { + this.data.notes.set(newId, copyDoc); + } + this.structureChanged = true; + return descriptor; + } + + /** Move a note to the given index of the resulting order (d&d reorder). */ + moveNote(id: string, toIndex: number): boolean { + const sorted = sortNoteDescriptors(this.data.manifest.notes); + const from = sorted.findIndex((n) => n.id === id); + if (from === -1) return false; + const [descriptor] = sorted.splice(from, 1); + const at = Math.min(Math.max(toIndex, 0), sorted.length); + sorted.splice(at, 0, descriptor); + reindexOrders(sorted); + this.data.manifest.notes = sorted; + this.structureChanged = true; + return true; + } + + // ── Assets ── + + assetIds(): Set { + return new Set(assetDescriptors(this.data.manifest).map((a) => a.id)); + } + + asset(id: string): UmAssetDescriptor | undefined { + return assetDescriptors(this.data.manifest).find((a) => a.id === id); + } + + isAssetId(id: string): boolean { + return assetDescriptors(this.data.manifest).some((a) => a.id === id); + } + + /** Store image bytes as a container asset and return its descriptor. */ + addAsset( + bytes: Uint8Array, + filename: string, + mime?: string, + ): UmAssetDescriptor { + const id = generateUmId(); + const ext = assetExtension(filename, mime); + const descriptor: UmAssetDescriptor = { + id, + path: assetPathForId(id, ext), + type: mime, + size: bytes.byteLength, + }; + if (!Array.isArray(this.data.manifest.assets)) { + this.data.manifest.assets = []; + } + this.data.manifest.assets.push(descriptor); + this.data.assets.set(id, bytes); + this.structureChanged = true; + return descriptor; + } + + /** Object URL for the asset's bytes, cached for the notepad's lifetime + * (revoked in destroy). Null when the asset is missing from the archive. */ + assetUrl(id: string): string | null { + if (this.objectUrls.has(id)) { + return this.objectUrls.get(id) ?? null; + } + const bytes = this.data.assets.get(id); + const asset = this.data.manifest.assets?.find((a) => a.id === id); + if (bytes == null) return null; + const blob = new Blob([bytes.slice()], { + type: asset?.type || "application/octet-stream", + }); + const url = URL.createObjectURL(blob); + this.objectUrls.set(id, url); + return url; + } + + /** Asset ids referenced by at least one note document. */ + referencedAssetIds(): Set { + const ids = this.assetIds(); + const referenced = new Set(); + for (const doc of this.data.notes.values()) { + collectReferencedAssetIds(doc, ids, referenced); + } + return referenced; + } + + /** Drop assets no note references anymore. Returns the removed count. + * Called before every save, so deletion of the last image node cleans + * the archive without a separate deletion protocol. */ + gcAssets(): number { + // Pages this editor cannot interpret (newer version, unknown family, + // failed migration) are preserved verbatim — including media whose + // references we cannot read. While any such page exists, it pins the + // whole asset registry: collecting by the reference shapes we know + // could delete an asset a newer page still uses (spec 8.6.2). + for (const descriptor of this.data.manifest.notes) { + if (this.noteSchemaState(descriptor.id).kind !== "openable") return 0; + } + const referenced = this.referencedAssetIds(); + const all = this.data.manifest.assets; + if (!Array.isArray(all) || all.length === 0) return 0; + + const kept = all.filter((asset) => referenced.has(asset.id)); + const removed = all.length - kept.length; + if (removed === 0) return 0; + + for (const asset of all) { + if (!referenced.has(asset.id)) { + this.data.assets.delete(asset.id); + this.revokeUrl(asset.id); + } + } + this.data.manifest.assets = kept; + this.structureChanged = true; + return removed; + } + + // ── Persistence support ── + + setNoteContent(id: string, content: JSONContent): void { + this.data.notes.set(id, content); + // Manifest `title` mirrors the document's first line (spec 8.4). + this.syncTitleFromDoc(id, content); + this.dirtyNotes.add(id); + } + + serialize(): Uint8Array { + this.gcAssets(); + return serializeUmContainer(this.data); + } + + private revokeUrl(id: string): void { + const url = this.objectUrls.get(id); + if (url != null) { + URL.revokeObjectURL(url); + this.objectUrls.delete(id); + } + } + + destroy(): void { + for (const id of [...this.objectUrls.keys()]) { + this.revokeUrl(id); + } + } +} + +function assetDescriptors(manifest: UmManifest): UmAssetDescriptor[] { + const raw = manifest.assets; + if (!Array.isArray(raw)) return []; + return raw.filter( + (entry): entry is UmAssetDescriptor => + typeof entry?.id === "string" && typeof entry?.path === "string", + ); +} + +/** Text of a note document's first-line title node (the noteTitle node). + * This is the source of truth for the title page's display title (8.4); + * the manifest `title` mirrors it. Empty string when the title node is + * absent or empty. */ +export function noteDocTitle(doc: JSONContent): string { + const titleNode = doc.content?.find((n) => n.type === "noteTitle"); + return titleNode ? inlineText(titleNode) : ""; +} + +/** Display title derived from a regular note's first line (8.4): the text + * of a leading heading of any level, a text block, or a legacy title node. + * Anything else carries no title. */ +export function noteDocFirstLineTitle(doc: JSONContent): string { + const first = doc.content?.[0]; + if ( + first == null || + (first.type !== "noteTitle" && + first.type !== "heading" && + first.type !== "paragraph") + ) { + return ""; + } + return inlineText(first); +} + +/** Family-aware display title of a note document. */ +export function noteDisplayTitle( + doc: JSONContent, + family: string | undefined, +): string { + return family === UM_SCHEMA_TITLE + ? noteDocTitle(doc) + : noteDocFirstLineTitle(doc); +} + +/** Starter document for a title page: the title/summary header plus an + * empty content block (spec 9.1). */ +export function createTitleNoteDoc(title = ""): JSONContent { + return { + type: "noteDoc", + content: [ + { + type: "noteTitle", + content: title.length > 0 ? [{ type: "text", text: title }] : [], + }, + { type: "noteSummary" }, + { type: "paragraph" }, + ], + }; +} + +/** Starter document for a regular note: content only, no title node + * (spec 9.2). */ +export function createPlainNoteDoc(): JSONContent { + return { type: "noteDoc", content: [{ type: "paragraph" }] }; +} + +/** Ensure a title-page document parses under the title profile: the header + * (title node + summary) exists and at least one content block follows. + * Pure — returns the same doc when nothing is missing. */ +function normalizeTitleDoc(doc: JSONContent): { + doc: JSONContent; + changed: boolean; +} { + const content = Array.isArray(doc.content) ? [...doc.content] : []; + let changed = false; + if (content[0]?.type !== "noteTitle") { + content.unshift({ type: "noteTitle" }); + changed = true; + } + if (content[1]?.type !== "noteSummary") { + content.splice(1, 0, { type: "noteSummary" }); + changed = true; + } + if (content.length === 2) { + content.push({ type: "paragraph" }); + changed = true; + } + if (!changed) return { doc, changed: false }; + return { doc: { ...doc, content }, changed: true }; +} + +/** Write `title` into the document's title node (title pages). */ +function withNoteTitleText(doc: JSONContent, title: string): JSONContent { + const content = Array.isArray(doc.content) ? [...doc.content] : []; + const index = content.findIndex((n) => n.type === "noteTitle"); + if (index === -1) { + content.unshift({ type: "noteTitle" }); + } + const target = index === -1 ? content[0] : content[index]; + content[index === -1 ? 0 : index] = { + ...target, + content: title.length > 0 ? [{ type: "text", text: title }] : [], + }; + return { ...doc, content }; +} + +/** Write `title` into a regular note's first line (8.4): a leading title + * node, heading or text block is rewritten in place; when the first line + * carries no text a title node is prepended (the plain profile's optional + * title slot). */ +function withFirstLineTitle(doc: JSONContent, title: string): JSONContent { + const content = Array.isArray(doc.content) ? [...doc.content] : []; + const first = content[0]; + if ( + first != null && + (first.type === "noteTitle" || + first.type === "heading" || + first.type === "paragraph") + ) { + content[0] = { + ...first, + content: title.length > 0 ? [{ type: "text", text: title }] : [], + }; + return { ...doc, content }; + } + return { + ...doc, + content: [ + { + type: "noteTitle", + content: title.length > 0 ? [{ type: "text", text: title }] : [], + }, + ...content, + ], + }; +} + +function inlineText(node: JSONContent): string { + return (node.content ?? []) + .map((n) => (typeof n.text === "string" ? n.text : "")) + .join(""); +} + +// Re-export for callers that only import this module. +export { UmError }; diff --git a/src/storage/um/umSchemas.test.ts b/src/storage/um/umSchemas.test.ts new file mode 100644 index 0000000..4994cd7 --- /dev/null +++ b/src/storage/um/umSchemas.test.ts @@ -0,0 +1,326 @@ +import { describe, expect, it } from "vitest"; +import { unzipSync, zipSync } from "fflate"; +import type { JSONContent } from "../../texto/core/@types"; +import { UmNotepad, noteDisplayTitle } from "./umNotepad"; +import { + inferSchemaFields, + interpretSchema, + migrateNoteDoc, + runMigrationChain, +} from "./umSchemas"; +import { + UM_SCHEMA_PLAIN, + UM_SCHEMA_TITLE, + UM_SCHEMA_VERSION, +} from "./umTypes"; + +function titleDoc(title = ""): JSONContent { + return { + type: "noteDoc", + content: [ + { type: "noteTitle", content: title ? [{ type: "text", text: title }] : [] }, + { type: "noteSummary" }, + { type: "paragraph" }, + ], + }; +} + +function paragraphDoc(text?: string): JSONContent { + return { + type: "noteDoc", + content: [ + text + ? { type: "paragraph", content: [{ type: "text", text }] } + : { type: "paragraph" }, + ], + }; +} + +function headingDoc(text: string): JSONContent { + return { + type: "noteDoc", + content: [ + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text }], + }, + { type: "paragraph" }, + ], + }; +} + +describe("interpretSchema (8.6.2)", () => { + it("infers the family when the fields are absent (legacy)", () => { + expect(interpretSchema({ order: 0 })).toEqual({ + kind: "openable", + family: UM_SCHEMA_TITLE, + version: UM_SCHEMA_VERSION, + }); + expect(interpretSchema({ order: 3 })).toEqual({ + kind: "openable", + family: UM_SCHEMA_PLAIN, + version: UM_SCHEMA_VERSION, + }); + }); + + it("treats a newer version of a known family as unsupported", () => { + expect(interpretSchema({ order: 0, schema: "title", schemaVersion: 99 })).toEqual({ + kind: "unsupported", + family: "title", + version: 99, + }); + }); + + it("treats an unknown family exactly like a newer version", () => { + expect(interpretSchema({ order: 1, schema: "mindmap", schemaVersion: 1 })).toEqual({ + kind: "unsupported", + family: "mindmap", + version: 1, + }); + expect(interpretSchema({ order: 1, schema: "mindmap", schemaVersion: 42 })).toEqual({ + kind: "unsupported", + family: "mindmap", + version: 42, + }); + }); + + it("opens known families at the current version", () => { + expect(interpretSchema({ order: 0, schema: "title", schemaVersion: 1 })).toEqual({ + kind: "openable", + family: "title", + version: 1, + }); + }); +}); + +describe("migrations (8.6.3)", () => { + it("runs a chain in order", () => { + const result = runMigrationChain( + [ + (doc) => ({ ...doc, tag: 1 }), + (doc) => ({ ...doc, tag: (doc.tag as number) + 1 }), + ], + { type: "doc" }, + 2, + ); + expect(result.ok).toBe(true); + expect(result.doc).toEqual({ type: "doc", tag: 2 }); + expect(result.toVersion).toBe(2); + }); + + it("fails with the ORIGINAL doc when a step throws", () => { + const original: JSONContent = { type: "doc" }; + const result = runMigrationChain( + [ + (doc) => ({ ...doc, step: 1 }), + () => { + throw new Error("boom"); + }, + ], + original, + 2, + ); + expect(result.ok).toBe(false); + expect(result.doc).toBe(original); + }); + + it("reports missing chain entries as failure, current-version docs as ok", () => { + // v1 is current: nothing to migrate, and fromVersion >= current is + // rejected as a misuse. + const doc = titleDoc(); + expect(migrateNoteDoc("title", 1, doc)).toEqual({ + ok: false, + doc, + toVersion: 1, + }); + }); +}); + +describe("UmNotepad schema handling", () => { + const ENC = new TextEncoder(); + const DEC = new TextDecoder(); + + function zipContainer( + manifest: unknown, + notes: Record, + ): Uint8Array { + const entries: Record = { + "manifest.json": ENC.encode(JSON.stringify(manifest)), + }; + for (const [path, doc] of Object.entries(notes)) { + entries[path] = ENC.encode(JSON.stringify(doc)); + } + return zipSync(entries); + } + + function readManifest(bytes: Uint8Array): Record { + return JSON.parse(DEC.decode(unzipSync(bytes)["manifest.json"])); + } + + it("stamps inferred schema fields on legacy containers and writes them back", () => { + const bytes = zipContainer( + { + format: "um", + version: 1, + type: "notepad", + notes: [ + { id: "a", path: "notes/a.json", order: 0 }, + { id: "b", path: "notes/b.json", order: 1 }, + ], + }, + { + "notes/a.json": titleDoc("Cover"), + "notes/b.json": paragraphDoc("hello"), + }, + ); + + const nb = UmNotepad.fromBytes(bytes); + expect(nb.note("a")?.schema).toBe(UM_SCHEMA_TITLE); + expect(nb.note("a")?.schemaVersion).toBe(UM_SCHEMA_VERSION); + expect(nb.note("b")?.schema).toBe(UM_SCHEMA_PLAIN); + expect(nb.note("b")?.schemaVersion).toBe(UM_SCHEMA_VERSION); + // Title mirrors adopted per family (header node vs first line). + expect(nb.note("a")?.title).toBe("Cover"); + expect(nb.note("b")?.title).toBe("hello"); + + const manifest = readManifest(nb.serialize()); + expect(manifest.notes).toEqual([ + expect.objectContaining({ + id: "a", + schema: UM_SCHEMA_TITLE, + schemaVersion: UM_SCHEMA_VERSION, + }), + expect.objectContaining({ + id: "b", + schema: UM_SCHEMA_PLAIN, + schemaVersion: UM_SCHEMA_VERSION, + }), + ]); + }); + + it("normalizes a legacy title page missing its summary (in memory, persists on save)", () => { + const bytes = zipContainer( + { + format: "um", + version: 1, + type: "notepad", + notes: [{ id: "a", path: "notes/a.json", order: 0, title: "Old" }], + }, + { + // Pre-summary era: noteTitle + paragraph, no noteSummary. + "notes/a.json": { + type: "noteDoc", + content: [ + { type: "noteTitle", content: [{ type: "text", text: "Old" }] }, + { type: "paragraph" }, + ], + }, + }, + ); + + const nb = UmNotepad.fromBytes(bytes); + expect(nb.isNoteOpenable("a")).toBe(true); + expect(nb.noteContent("a")?.content?.map((n) => n.type)).toEqual([ + "noteTitle", + "noteSummary", + "paragraph", + ]); + expect(nb.dirtyNotes.has("a")).toBe(true); + + const manifest = readManifest(nb.serialize()); + expect(manifest.notes).toEqual([ + expect.objectContaining({ id: "a", schema: UM_SCHEMA_TITLE }), + ]); + }); + + it("openable title page gets a normalized header; plain pages stay content-only", () => { + const nb = UmNotepad.empty(); + nb.addNote(undefined, "Cover"); + const first = nb.notes()[0]; + expect(first.schema).toBe(UM_SCHEMA_TITLE); + const doc = nb.noteContent(first.id); + expect(doc?.content?.map((n) => n.type)).toEqual([ + "noteTitle", + "noteSummary", + "paragraph", + ]); + + nb.addNote(first.id); + const second = nb.notes()[1]; + expect(second.schema).toBe(UM_SCHEMA_PLAIN); + const doc2 = nb.noteContent(second.id); + expect(doc2?.content?.map((n) => n.type)).toEqual(["paragraph"]); + }); + + it("preserves uninterpretable pages verbatim through a round trip", () => { + const nb = UmNotepad.empty(); + const first = nb.addNote(undefined, "Cover"); + nb.setNoteContent(first.id, titleDoc("Cover")); + const second = nb.addNote(first.id); + nb.setNoteContent(second.id, paragraphDoc("hello")); + + // Simulate a page from a future editor: unknown family, newer version. + const descriptor = nb.note(second.id)!; + descriptor.schema = "mindmap"; + descriptor.schemaVersion = 7; + descriptor.title = "Future page"; + + expect(nb.noteSchemaState(second.id).kind).toBe("unsupported"); + expect(nb.isNoteOpenable(second.id)).toBe(false); + + // The rewrite keeps the descriptor untouched and the document on disk. + const manifest = readManifest(nb.serialize()); + expect(manifest.notes).toEqual([ + expect.objectContaining({ id: first.id, schema: UM_SCHEMA_TITLE }), + expect.objectContaining({ + id: second.id, + schema: "mindmap", + schemaVersion: 7, + title: "Future page", + }), + ]); + }); + + it("derives plain titles from the first line and mirrors title-page headers", () => { + expect(noteDisplayTitle(paragraphDoc("From text"), "plain")).toBe("From text"); + expect(noteDisplayTitle(headingDoc("From heading"), "plain")).toBe( + "From heading", + ); + expect(noteDisplayTitle(paragraphDoc(), "plain")).toBe(""); + expect(noteDisplayTitle(titleDoc("Cover title"), "title")).toBe( + "Cover title", + ); + expect(noteDisplayTitle(titleDoc(), "title")).toBe(""); + }); + + it("duplicate carries the schema and the (copy) suffix in the first line", () => { + const nb = UmNotepad.empty(); + const cover = nb.addNote(undefined, "Cover"); + nb.setNoteContent(cover.id, titleDoc("Cover")); + const page = nb.addNote(cover.id); + nb.setNoteContent(page.id, paragraphDoc("Original line")); + + const copy = nb.duplicateNote(page.id); + expect(copy?.schema).toBe(UM_SCHEMA_PLAIN); + expect(copy?.title).toBe("Original line (copy)"); + const copyDoc = nb.noteContent(copy!.id)!; + expect(copyDoc.content?.[0]).toEqual({ + type: "paragraph", + content: [{ type: "text", text: "Original line (copy)" }], + }); + }); +}); + +describe("inferSchemaFields", () => { + it("maps order 0 to title and everything else to plain, at the current version", () => { + expect(inferSchemaFields(0)).toEqual({ + schema: "title", + schemaVersion: UM_SCHEMA_VERSION, + }); + expect(inferSchemaFields(5)).toEqual({ + schema: "plain", + schemaVersion: UM_SCHEMA_VERSION, + }); + }); +}); diff --git a/src/storage/um/umSchemas.ts b/src/storage/um/umSchemas.ts new file mode 100644 index 0000000..69d9751 --- /dev/null +++ b/src/storage/um/umSchemas.ts @@ -0,0 +1,149 @@ +import type { JSONContent } from "../../texto/core/@types"; +import { + UM_SCHEMA_PLAIN, + UM_SCHEMA_TITLE, + UM_SCHEMA_VERSION, + type UmNoteDescriptor, +} from "./umTypes"; + +/** + * Format-level registry of note schema families (spec 8.6): which families + * this editor knows, their current versions, and the migration chains from + * older versions. Editor-facing composition (extensions per family) lives + * in src/texto/editorSchemas.ts — this module stays JSON-only so the + * storage layer never depends on the editor. + * + * Versioning policy (8.6.1): a version is raised only for breaking + * changes; additive changes keep the value. Older documents are handled by + * migrations, never by retained editor variants. + */ + +/** Interpretation of a note descriptor for this editor (spec 8.6.2). */ +export type UmSchemaState = + /** Known family at a version this editor opens. `family` selects the + * editor profile; a `version` older than current needs a migration run + * first (migrateNoteDoc). */ + | { kind: "openable"; family: string; version: number } + /** Newer version of a known family, or a family this editor does not + * know — such a document can only come from a newer editor. The page is + * shown as a notice, never edited, and preserved verbatim on rewrite. */ + | { kind: "unsupported"; family: string | null; version: number | null } + /** Known family, older version, and the migration chain failed. Same + * UI treatment as "unsupported"; preserved verbatim on rewrite. */ + | { kind: "invalid"; family: string }; + +/** Current version of a schema family; null for unknown families. */ +export function currentSchemaVersion(family: string): number | null { + return family === UM_SCHEMA_TITLE || family === UM_SCHEMA_PLAIN + ? UM_SCHEMA_VERSION + : null; +} + +/** Legacy inference (8.6.2): fields absent → order 0 is the title page, + * every other note is plain; the version is the current one. */ +export function inferSchemaFields(order: number): { + schema: string; + schemaVersion: number; +} { + return { + schema: order === 0 ? UM_SCHEMA_TITLE : UM_SCHEMA_PLAIN, + schemaVersion: UM_SCHEMA_VERSION, + }; +} + +/** + * Resolve a descriptor against this editor's knowledge. Pure — no I/O, no + * migration runs here; the caller migrates "openable at an older version" + * before treating the note as openable (see migrateNoteDoc). + */ +export function interpretSchema( + descriptor: Pick, +): UmSchemaState { + // Absent fields are legacy (written before schemas existed): infer. + const family = descriptor.schema ?? inferSchemaFields(descriptor.order).schema; + const version = descriptor.schemaVersion ?? UM_SCHEMA_VERSION; + + const current = currentSchemaVersion(family); + if (current == null) { + return { kind: "unsupported", family, version }; + } + if (version > current) { + return { kind: "unsupported", family, version }; + } + return { kind: "openable", family, version }; +} + +/** + * Migration chains: family → fromVersion → transform to fromVersion + 1. + * v1 is the first version of both families, so every chain starts empty; + * a breaking change to a family registers its transform (see + * registerMigration) and bumps UM_SCHEMA_VERSION. + */ +const MIGRATIONS: Record< + string, + Record JSONContent) | undefined> +> = { + [UM_SCHEMA_TITLE]: {}, + [UM_SCHEMA_PLAIN]: {}, +}; + +/** Register the v(n) → v(n+1) transform of a family (8.6.3): a + * deterministic JSON→JSON migration. Called from version-bump commits. */ +export function registerMigration( + family: string, + fromVersion: number, + transform: (doc: JSONContent) => JSONContent, +): void { + const chain = (MIGRATIONS[family] ??= {}); + chain[fromVersion] = transform; +} + +export interface UmMigrationResult { + ok: boolean; + doc: JSONContent; + /** Version the returned doc is at when ok. */ + toVersion: number; +} + +/** Apply a chain of steps in order; any throwing step fails the whole run + * with the original doc untouched. Extracted pure so the chain mechanics + * are testable before the first real version bump exists. */ +export function runMigrationChain( + steps: Array<(doc: JSONContent) => JSONContent>, + doc: JSONContent, + toVersion: number, +): UmMigrationResult { + let result = doc; + try { + for (const step of steps) { + result = step(result); + } + } catch { + return { ok: false, doc, toVersion }; + } + return { ok: true, doc: result, toVersion }; +} + +/** + * Run the migration chain from `fromVersion` to the current version of the + * family. Deterministic JSON→JSON transforms (8.6.3). A throwing transform + * is reported as { ok: false } — the caller degrades that page to + * "unsupported" and preserves the original doc and descriptor untouched. + */ +export function migrateNoteDoc( + family: string, + fromVersion: number, + doc: JSONContent, +): UmMigrationResult { + const current = currentSchemaVersion(family); + if (current == null || fromVersion >= current) { + return { ok: false, doc, toVersion: fromVersion }; + } + const steps: Array<(doc: JSONContent) => JSONContent> = []; + for (let v = fromVersion; v < current; v++) { + const step = MIGRATIONS[family]?.[v]; + if (step == null) return { ok: false, doc, toVersion: fromVersion }; + steps.push(step); + } + return runMigrationChain(steps, doc, current); +} diff --git a/src/storage/um/umTypes.ts b/src/storage/um/umTypes.ts new file mode 100644 index 0000000..5fa889e --- /dev/null +++ b/src/storage/um/umTypes.ts @@ -0,0 +1,117 @@ +import type { JSONContent } from "../../texto/core/@types"; + +/** UM container format identifier (spec section 6.1). */ +export const UM_FORMAT = "um"; +/** The only UM container version this implementation reads/writes (6.2). */ +export const UM_VERSION = 1; +/** The only container type defined by UM 1.0 (6.3). */ +export const UM_TYPE = "notepad"; + +/** Note schema family of the title page (spec 8.6): mandatory title and + * summary header, followed by content blocks (9.1). */ +export const UM_SCHEMA_TITLE = "title"; +/** Note schema family of a regular note (spec 8.6): content blocks only, + * a title node is not required (9.2). */ +export const UM_SCHEMA_PLAIN = "plain"; +/** Current version of every schema family this editor reads/writes (8.6.1). + * Raised only for breaking changes; additive changes keep the value. */ +export const UM_SCHEMA_VERSION = 1; +/** Manifest entry point inside the archive (section 5). */ +export const MANIFEST_PATH = "manifest.json"; +/** Recommended location of note documents (section 8.2). */ +export const NOTES_DIR = "notes"; +/** Location of packed images (section 17). */ +export const ASSETS_DIR = "assets"; + +/** Frozen timestamp written into every ZIP entry: deterministic output, so + * two saves of identical content produce identical bytes. */ +export const FROZEN_MTIME = new Date(Date.UTC(1980, 0, 1)); + +/** Size guards against hostile `.um` files (zip bombs). A vault-synced + * archive must never be read or decompressed past these bounds. The + * compressed cap bounds what `readBinary` loads; the inflated cap bounds + * what `unzipSync` allocates — enforced per entry from the ZIP central + * directory's declared sizes, before that entry is decompressed. Both are + * far above any container this plugin can produce (images are stored + * uncompressed, so file size ≈ media size; JSON notes deflate by factors, + * not orders of magnitude). */ +export const UM_MAX_FILE_BYTES = 256 * 1024 * 1024; +export const UM_MAX_INFLATED_BYTES = 512 * 1024 * 1024; + +export interface UmNoteDescriptor { + /** Stable unique identifier (spec 8.1) — never derived from position. */ + id: string; + /** Archive path of the serialized note document (8.2). */ + path: string; + /** Position within the notepad (8.3). */ + order: number; + /** Display title. Optional per spec; our implementation always writes it. */ + title?: string; + /** Notepad UI expanded state. Absent or false = collapsed (the spec's + * initial default); we write it only for expanded notes. */ + expanded?: boolean; + /** Document schema family (8.6). Absent = legacy container: inferred on + * load (order 0 → title, otherwise plain) and written back on save. */ + schema?: string; + /** Version of the schema family (8.6). Absent = legacy: inferred on load + * as the current version. Never lowered on rewrite (8.6.2). */ + schemaVersion?: number; +} + +export interface UmAssetDescriptor { + id: string; + path: string; + /** MIME type, when known. */ + type?: string; + /** Byte size, when known. */ + size?: number; +} + +/** The manifest is kept as a plain record so unknown fields (spec 12.1) and + * unknown features (12.2) survive a load→save round trip untouched: we only + * mutate the fields we know. */ +export interface UmManifest { + format: string; + version: number; + type: string; + notes: UmNoteDescriptor[]; + assets?: UmAssetDescriptor[]; + [key: string]: unknown; +} + +/** Parsed in-memory representation of a `.um` archive. */ +export interface UmContainerData { + manifest: UmManifest; + /** Note documents by note id. */ + notes: Map; + /** Asset bytes by asset id. */ + assets: Map; + /** Archive entries we don't understand (spec 3, 12) — preserved verbatim + * on save. Includes notes/assets files not listed in the manifest. */ + unknownEntries: Map; +} + +export type UmErrorCode = + | "no-manifest" + | "bad-manifest-json" + | "bad-format" + | "bad-version" + | "bad-type" + | "bad-notes" + | "bad-descriptor" + | "duplicate-id" + | "missing-note" + | "bad-note-json" + | "too-large"; + +/** Parse/serialize failure with a stable machine-readable code, so callers + * can show a precise message without string matching. */ +export class UmError extends Error { + code: UmErrorCode; + + constructor(code: UmErrorCode, message: string) { + super(message); + this.name = "UmError"; + this.code = code; + } +} diff --git a/src/storage/um/umVault.ts b/src/storage/um/umVault.ts new file mode 100644 index 0000000..77fc55c --- /dev/null +++ b/src/storage/um/umVault.ts @@ -0,0 +1,161 @@ +import type { TFile, Vault } from "obsidian"; +import { logDiagEvent, logNoteWrite } from "../noteStorage"; +import type { AdapterWithFullPath } from "../noteStorage"; +import { UmNotepad } from "./umNotepad"; +import { UM_MAX_FILE_BYTES, UmError } from "./umTypes"; + +/** + * Obsidian-facing IO for `.um` containers. The write path mirrors + * noteStorage's atomic strategy (issues/empty-note-wipe-guard): a hidden + * temp file first, then a single replace — node fs.rename on desktop, + * adapter.rename into a fresh path, in-place adapter.writeBinary as the + * last resort. Never adapter.remove the open file (rule-open-file-safety). + */ + +export async function readUmFile( + file: TFile, + vault: Vault, +): Promise { + // Size guard before readBinary: a hostile oversized file is rejected + // without loading it into memory at all (parseUmContainer re-checks). + if (file.stat.size > UM_MAX_FILE_BYTES) { + throw new UmError( + "too-large", + `Notepad file exceeds the size limit: ${file.stat.size} bytes (max ${UM_MAX_FILE_BYTES})`, + ); + } + const buffer = await vault.readBinary(file); + return UmNotepad.fromBytes(new Uint8Array(buffer)); +} + +/** Create a new `.um` file from an in-memory notepad. */ +export async function createUmFile( + vault: Vault, + path: string, + notepad: UmNotepad, +): Promise { + const bytes = notepad.serialize(); + const tfile = await vault.createBinary(path, bufferFrom(bytes)); + return tfile as TFile; +} + +/** Atomically persist the notepad and update its saved-state fingerprint. */ +export async function writeUmFile( + file: TFile, + vault: Vault, + notepad: UmNotepad, + trigger = "unknown", +): Promise { + const started = Date.now(); + const bytes = notepad.serialize(); + const data = bufferFrom(bytes); + const slash = file.path.lastIndexOf("/"); + const dir = slash === -1 ? "" : file.path.slice(0, slash); + const unique = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + // Dot-prefixed → invisible to the file explorer, no vault events on cleanup. + const tmpPath = `${dir ? `${dir}/` : ""}.${file.name}.${unique}.tmp`; + + let priorBytes: number | null = null; + try { + priorBytes = (await vault.adapter.stat(file.path))?.size ?? null; + } catch { + priorBytes = null; + } + + let result: "ok" | "error" = "ok"; + let error: string | undefined; + try { + await vault.adapter.writeBinary(tmpPath, data); + await replaceFile(vault, tmpPath, file.path); + notepad.savedFingerprint = await fingerprintStat(vault, file.path); + } catch (err) { + result = "error"; + error = err instanceof Error ? err.message : String(err); + // The temp copy holds the complete new content — keep it and report it + // (same policy as noteStorage: never delete the only good copy). + await logDiagEvent( + vault, + `um-write error path=${file.path} tmp=${tmpPath} error=${error}`, + ); + throw err instanceof Error ? err : new Error(String(err)); + } finally { + await logNoteWrite(vault, { + kind: "note-write", + ts: new Date().toISOString(), + trigger: `um:${trigger}`, + path: file.path, + bytes: bytes.byteLength, + priorBytes, + result, + error, + durationMs: Date.now() - started, + }); + } +} + +/** Fingerprint of the file's last known on-disk state: the notepad's own + * writes update it, so vault "modify" events for foreign changes are the + * only ones that observe a different value. Null while unknown. */ +export async function umFingerprint( + vault: Vault, + file: TFile, +): Promise { + return fingerprintStat(vault, file.path); +} + +async function fingerprintStat( + vault: Vault, + path: string, +): Promise { + try { + const stat = await vault.adapter.stat(path); + if (stat == null) return null; + return `${stat.mtime}:${stat.size}`; + } catch { + return null; + } +} + +async function replaceFile( + vault: Vault, + tmpPath: string, + targetPath: string, +): Promise { + const nodeFs = getNodeFs(); + const adapter: AdapterWithFullPath = vault.adapter; + if (nodeFs != null && typeof adapter.getFullPath === "function") { + nodeFs.renameSync( + adapter.getFullPath(tmpPath), + adapter.getFullPath(targetPath), + ); + return; + } + try { + await adapter.rename(tmpPath, targetPath); + return; + } catch { + // adapter.rename refuses to overwrite an existing destination — + // fall through to the in-place write. + } + // In-place replace of the OPEN notepad file is intentional here: + // adapter.remove would fire a vault "delete" and close the view + // (issues/mobile-edit-exits-note, rule-open-file-safety). + const data = await vault.adapter.readBinary(tmpPath); + await adapter.writeBinary(targetPath, data); +} + +/** The node fs module on desktop Obsidian; null on mobile / when blocked. */ +function getNodeFs(): typeof import("node:fs") | null { + try { + const req = (window as { require?: (id: string) => unknown }).require; + if (typeof req !== "function") return null; + return req("fs") as typeof import("node:fs"); + } catch { + return null; + } +} + +function bufferFrom(bytes: Uint8Array): ArrayBuffer { + // Uint8Array.prototype.buffer may be larger than the view — copy exactly. + return bytes.slice().buffer as ArrayBuffer; +} diff --git a/src/styles/drag-handle.css b/src/styles/drag-handle.css index 8ee1407..8225762 100644 --- a/src/styles/drag-handle.css +++ b/src/styles/drag-handle.css @@ -99,8 +99,11 @@ specificity, in later stylesheets — task-item/style.css is imported after this file) would otherwise keep the chevron visible under the dots overlay. */ -.note .texto-heading-fold-chevron-host.is-hover:not(.is-grabbed):not(.is-dragging), -.note .texto-heading-fold-chevron-host .texto-heading-fold-chevron.is-hover:not(.is-grabbed):not(.is-dragging), +.note +.texto-heading-fold-chevron-host.is-hover:not(.is-grabbed):not(.is-dragging), +.note + .texto-heading-fold-chevron-host + .texto-heading-fold-chevron.is-hover:not(.is-grabbed):not(.is-dragging), .note .texto-task-fold-chevron.is-hover:not(.is-grabbed):not(.is-dragging) { opacity: 1; } diff --git a/src/styles/editor.css b/src/styles/editor.css index 283e3c7..b2de836 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -22,8 +22,11 @@ @import "./mobile.css"; @import "./search.css"; -/* Hidden container for SVG checkbox icons (see note.element.tsx). */ -.texto-editor-host .visually-hidden { +/* Visually-hidden utility: hide elements for sighted users while keeping + them available to the DOM (SVG sprite containers, native checkboxes). + Global on purpose — any host that renders a sprite container (note + editor, notepad sections, …) must not re-declare this. */ +.visually-hidden { position: absolute; width: 1px; height: 1px; diff --git a/src/styles/heading-folding.css b/src/styles/heading-folding.css index 8212120..0cc02fc 100644 --- a/src/styles/heading-folding.css +++ b/src/styles/heading-folding.css @@ -81,7 +81,8 @@ grab point) via this class. The hold-grab/drag states hide the chevron (dots handle takes its place) — they must win over is-hover regardless of stylesheet order. */ -.note .texto-heading-fold-chevron-host.is-hover:not(.is-grabbed):not(.is-dragging) { +.note +.texto-heading-fold-chevron-host.is-hover:not(.is-grabbed):not(.is-dragging) { opacity: 1; } diff --git a/src/styles/notepad.css b/src/styles/notepad.css new file mode 100644 index 0000000..00096b8 --- /dev/null +++ b/src/styles/notepad.css @@ -0,0 +1,760 @@ +/* NotepadView (.um) — flat document of collapsible note sections. + Layout follows the blog draft view: page order + fold chevron in the left + margin, content column with a thin rule, light separators between pages, + ghost add/delete controls under each section. */ + +/* The selector must outrank Obsidian's `.workspace-leaf-content .view-content` + (0,2,0) — its 12px side padding would steal width from the content column + and skew the nav drawer's push/overlay fit (see navFits in NotepadView). */ +.view-content.notepad-view-container { + position: relative; + display: flex; + flex-direction: column; + padding: 0; +} + +/* ── Pages navigation drawer (blog-style, hidden by default) ── */ + +.notepad-nav { + position: absolute; + top: 40px; + bottom: 0; + left: 0; + width: 300px; + z-index: 15; + display: flex; + flex-direction: column; + background: var(--background-primary); + border-right: 1px solid var(--background-modifier-border); + box-shadow: var(--shadow-s); + transform: translateX(-100%); + visibility: hidden; + transition: + transform 0.35s, + visibility 0.35s; +} + +.notepad-nav.is-open { + transform: translateX(0); + visibility: visible; +} + +/* ── Push vs overlay ── + When the whole column fits right of the drawer (.notepad-nav-push on the + container, kept live by NotepadView), an open drawer takes the left strip + and the column re-anchors right after it: a fixed padding gap, the column + at its FULL width — the scroller's right padding is dropped while pushed + (an empty padding must not shrink the column; the fit check in navFits() + does not reserve it either). When the column does not fit even pushed, + the drawer overlays the centered content instead — that overlay is the + only case carrying the shadow — and the column anchors to the RIGHT edge + (rule below) so the drawer covers as little of it as the width allows. + The column never shrinks for the drawer's sake. The closed state centers + the column in both modes, so the re-anchor cannot be interpolated in CSS + (auto margin → 0) — NotepadView flips it with a transform (FLIP) while + the scroller's margin snaps underneath. */ +.notepad-view-container.notepad-nav-push.notepad-nav-open .notepad-scroller { + margin-left: 300px; /* NAV_WIDTH in NotepadView.ts */ + padding-right: 0; +} + +/* Overlay on desktop (the column does not fit beside the drawer): anchor the + column to the RIGHT edge of the content area instead of centering it — the + open drawer then covers only the sliver it must, while centering would + hide the column's whole left strip behind the drawer (the width the + centering margins free up is exactly the overlap saved). Mobile columns + are full-width, where this is a no-op. */ +.notepad-view-container.notepad-nav-open:not(.notepad-nav-push) +.notepad-sections { + margin-left: auto; + margin-right: 0; +} + +.notepad-view-container.notepad-nav-push.notepad-nav-open .notepad-sections { + margin-left: 25px; /* hug the drawer instead of centering */ +} + +/* Overlay only: the drawer floats above the content, so it needs the + shadow; in push mode it sits beside the content like a pane. */ +.notepad-view-container.notepad-nav-push .notepad-nav { + box-shadow: none; +} + +/* Blog look: bare ☰ button (menu-2 glyph, 38px, hairline stroke) hanging + outside the closed drawer and docking inside when it opens. The button.* + prefix out-ranks Obsidian's button:not(.clickable-icon) chrome. + When the drawer opens, ☰ morphs into ✕ (crossfade + rotation) — the + button then closes the drawer. */ +button.notepad-nav-toggle { + position: absolute; + top: 8px; + /* Closed drawer: the toggle hangs OUTSIDE it (the drawer is translated + -100%, so -48px past its right edge lands at the view's left edge). + Open drawer: the toggle docks inside, flush with the right edge. */ + right: -48px; + width: 38px; + height: 38px; + padding: 0; + border: none; + border-radius: 0; + box-shadow: none; + background: transparent; + appearance: none; + -webkit-appearance: none; + color: var(--text-faint); + cursor: pointer; + visibility: visible; + transition: + color 0.2s, + right 0.35s; +} + +button.notepad-nav-toggle svg { + position: absolute; + top: 0; + left: 0; + width: 38px; + height: 38px; + stroke-width: 0.5px; + transition: + stroke 0.2s, + opacity 0.35s, + transform 0.35s; +} + +button.notepad-nav-toggle .icon-close { + opacity: 0; + transform: rotate(-90deg) scale(0.6); +} + +.notepad-nav.is-open button.notepad-nav-toggle .icon-menu { + opacity: 0; + transform: rotate(90deg) scale(0.6); +} + +.notepad-nav.is-open button.notepad-nav-toggle .icon-close { + opacity: 1; + transform: rotate(0deg); +} + +button.notepad-nav-toggle:hover, +button.notepad-nav-toggle:focus { + color: var(--text-normal); + background: transparent; + box-shadow: none; +} + +button.notepad-nav-toggle:hover svg { + stroke: var(--text-normal); +} + +/* Blog TOC look: inset line, page rows with the number right before the + header — one aligned line, compact left indent. */ +.notepad-nav-list { + flex: 1 1 auto; + overflow-y: auto; + margin-top: var(--size-4-2); +} + +.notepad-nav-item { + display: flex; + align-items: baseline; + cursor: pointer; + color: var(--text-muted); + + &:first-child { + text-transform: uppercase; + } +} + +.notepad-nav-item:hover { + color: var(--text-normal); +} + +.notepad-nav-numb { + width: 2.4rem; + padding-right: 0.8rem; + text-align: right; + color: var(--text-faint); + font-size: var(--font-ui-small); + flex-shrink: 0; +} + +.notepad-nav-header { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 0.4rem 0; + font-size: 15px; + line-height: 2.2rem; +} + +.notepad-nav-header.is-empty { + color: var(--text-faint); + font-style: italic; +} + +.notepad-nav-item.is-active .notepad-nav-header { + color: var(--interactive-accent); +} + +/* ── Notepad-wide search panel ── */ + +.notepad-search { + position: absolute; + top: var(--size-2-2); + right: var(--size-4-4); + z-index: 10; + display: flex; + align-items: center; + gap: var(--size-2-1); + padding: var(--size-2-1) var(--size-2-2); + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-m); + background: var(--background-primary); + box-shadow: var(--shadow-s); +} + +.notepad-search-input { + width: 200px; + padding: 2px 6px; + border: 1px solid var(--background-modifier-border); + border-radius: var(--radius-s); + background: var(--background-primary); + color: var(--text-normal); + font-size: var(--font-ui-small); +} + +.notepad-search-input:focus { + outline: none; + border-color: var(--interactive-accent); +} + +.notepad-search-count { + min-width: 44px; + text-align: center; + color: var(--text-muted); + font-size: var(--font-ui-small); + white-space: nowrap; +} + +.notepad-search-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: var(--radius-s); + background: transparent; + color: var(--text-muted); + cursor: pointer; +} + +.notepad-search-btn:hover { + color: var(--text-normal); + background: var(--background-modifier-hover); +} + +.notepad-toolbar-host { + flex: 0 0 auto; +} + +.notepad-scroller { + flex: 1 1 auto; + overflow-y: auto; + /* The page column is fixed-width on desktop (see .notepad-sections + min-width), so a narrow window overflows: scroll to it instead of + squeezing the editor. While the nav flip animates the column + transiently pokes past the scroller edge — that must not flash a + horizontal scrollbar, so the flip guard pins it back to hidden + (see .notepad-scroller.is-flipping below). */ + overflow-x: auto; + /* padding: var(--size-4-4) var(--size-4-5) var(--size-4-10); */ +} + +/* Flip guard: present for as long as the FLIP transform animates the + column (NotepadView.flipContentLayout adds/removes it around the + transition) — the translated column pokes past the scroller edge + mid-animation and must not scroll horizontally while doing so. */ +.notepad-scroller.is-flipping { + overflow-x: hidden; +} + +/* Hidden measuring probe (NotepadView.scrollbarWidth): sized content with + forced scrollbar, mounted briefly inside the live scroller so any scoped + scrollbar styling is inherited by the measurement. */ +.notepad-scrollbar-probe { + position: absolute; + top: 0; + left: 0; + height: 100px; + width: 100px; + overflow-y: scroll; + visibility: hidden; + pointer-events: none; +} + +.notepad-sections { + /* 74px section left margin + 96px inner .texto-editor side padding + (48×2) + the 748px ProseMirror text column — the same column width a + .note gets, so a page's editor area is not narrower than a note's. */ + max-width: 918px; + margin: 0 auto; + /* Matches the drawer's slide so the shift and the drawer move together. */ + transition: transform 0.35s; + padding: 28px 0; +} + +/* Fixed page column (desktop, the owner's call 2026-09-27): the page keeps + its designed width and a narrow window scrolls horizontally instead of + squeezing the editor area (.note compresses; a .um page must not). + Mobile keeps the compressing fit — a fixed column there would need + horizontal scrolling on every phone. */ +.view-content.notepad-view-container:not(.is-mobile) .notepad-sections { + min-width: 918px; +} + +/* FLIP helper (NotepadView.flipContentLayout): present only for one forced + reflow, painting the column at its pre-layout spot with the transition + suspended; removing the class releases it into the transition above. */ +.notepad-sections.nav-flip { + transition: none; + transform: translateX(var(--nav-flip-x, 0px)); +} + +.notepad-section { + position: relative; + padding-left: 74px; +} + +/* ── Left margin: page order + chevron ── + Copied from the blog draft view: bare glyphs (no button chrome — + background/border/box-shadow all off), hairline strokes, muted color + darkening on hover with a 0.2s transition. */ + +.notepad-section-margin { + position: absolute; + left: 0; + top: -5px; + display: flex; + align-items: center; + gap: 0; + width: 70px; +} + +.notepad-section-order { + min-width: 24px; + text-align: right; + color: var(--text-faint); + font-size: 15px; + line-height: 1; + user-select: none; + transition: color 0.2s; +} + +button.notepad-section-gutter { + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + padding: 0; + border: none; + border-radius: 0; + box-shadow: none; + background: transparent; + appearance: none; + -webkit-appearance: none; + color: var(--text-faint); + cursor: grab; + transition: color 0.2s; +} + +button.notepad-section-gutter:active { + cursor: grabbing; +} + +button.notepad-section-gutter svg { + transition: + stroke 0.2s, + transform 0.2s; +} + +button.notepad-section-gutter:hover, +.notepad-section-margin:hover .notepad-section-order { + color: var(--text-normal); +} + +button.notepad-section-gutter:hover svg { + stroke: var(--text-normal); +} + +/* Folded pages point right; expanded rotate the chevron down. The gutter + stays in ONE fixed spot for both states (aligned with the first line). */ +.notepad-section.is-expanded button.notepad-section-gutter svg { + transform: rotate(90deg); +} + +/* ── Content column ── + The body is the page frame: a left rule for expanded pages and a bottom + separator on every page — the controls row lives INSIDE the frame, so + the line closes each page including the last one. */ + +.notepad-section-body { + min-width: 0; + /* padding: 2px 0 0 16px; */ +} + +/* The title page is the cover: no left frame rule, ever (spec 9.1). + Restated after the rules above so it wins the equal-specificity race. */ +.notepad-section.is-title.is-expanded .notepad-section-body { + border: none; + box-shadow: none; +} + +.notepad-section .notepad-section-body { + /* border-bottom: 1px solid var(--background-modifier-border); */ + box-shadow: + rgb(0 0 0 / 34%) 1px 1px 3px 0px, + rgba(0, 0, 0, 0.06) 1px 1px 2px 0px; + border-radius: 4px; +} + +.theme-dark { + .notepad-section .notepad-section-body { + border-bottom: 1px solid var(--background-modifier-border); + border-right: 1px solid var(--background-modifier-border); + } +} + +/* Visual gap between the page frames themselves (frames must not touch). */ +.notepad-section + .notepad-section { + margin-top: 32px; +} + +.notepad-section-content { + min-height: 20px; + padding-bottom: var(--size-2-1); +} + +/* ── Collapsed row ── */ + +.notepad-section-collapsed { + display: flex; + align-items: center; + gap: var(--size-2-2); + min-height: 30px; + margin-left: var(--size-2-3); + padding: var(--size-2-2) 0; + cursor: pointer; + font-size: 15px; + line-height: 18px; + color: var(--text-muted); +} + +.notepad-section-collapsed:hover { + color: var(--text-normal); +} + +.notepad-section-title { + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.notepad-section-title.is-empty { + color: var(--text-faint); + font-style: italic; +} + +/* ── Expanded section ── */ + +.notepad-note-host { + min-height: 120px; +} + +/* ── Title page header (spec 9.1) ── + The blog draft's cover: a large title with a summary line under it. + While a field is empty it shows its placeholder (the `empty` decoration + class + data-placeholder); while the caret is inside the header and the + field has text, the `focused` class shows the field label with the + vertical rule to the left of the field (inside the 74px margin, where + the title page has no gutter). Real focus is CSS-live via :focus-within, + so the labels disappear with the caret without decoration churn. */ + +/* The cover title is larger than an in-content h1 (the blog draft look). */ +.notepad-section.is-title .ProseMirror > h1:first-child { + font-size: 40px; + line-height: 1.15; +} + +/* The header nodes come from the editor's own rendering (h1/p); the label + pseudo-element hangs into the left margin. */ +.notepad-view-container [data-label] { + position: relative; +} + +.notepad-view-container [data-label]:after { + position: absolute; + content: attr(data-label); + color: var(--text-faint); + border-right: 1px solid var(--background-modifier-border); + right: 100%; + top: -6px; + bottom: -6px; + padding: 6px 12px; + font-weight: normal; + margin: 0 21px; + opacity: 0; + visibility: hidden; + transition: opacity 0.15s ease; +} + +/* Label shows only while the page holds real focus (the caret is inside + the header via the decoration class, the focus gate is CSS-live). */ +.notepad-section-body:focus-within [data-label].focused:after { + opacity: 1; + visibility: visible; +} + +.notepad-view-container [data-placeholder] { + position: relative; +} + +.notepad-view-container [data-placeholder].empty:before { + position: absolute; + left: 0; + right: 0; + content: attr(data-placeholder); + color: var(--text-faint); + font-weight: normal; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + pointer-events: none; +} + +/* The summary line under the title — the blog draft's role=definition: + 15px/18px regular, muted gray, 12px vertical rhythm (theme variables + keep it correct in both light and dark schemes; the blog's hard-coded + #79828b is the light-scheme face of --text-muted). */ +.notepad-view-container p[role="definition"] { + margin: 12px 0; + color: var(--text-muted); + font-size: 15px; + font-weight: 400; + line-height: 18px; + font-style: normal; +} + +.notepad-view-container p[role="definition"] a[href] { + color: var(--text-muted); + border-bottom: none; + border-bottom: 0.1em solid rgba(127, 127, 127, 0.4); +} + +/* Static title row: the title page fallback when its editor failed. */ +.notepad-section-collapsed.is-static { + cursor: default; +} + +/* ── Unopenable page notice (spec 8.6.2) ── */ + +.notepad-section-unsupported { + min-height: 20px; + padding: var(--size-2-2) 0 var(--size-2-1); +} + +.notepad-section-unsupported-title { + color: var(--text-muted); + font-size: 15px; + font-weight: 600; +} + +.notepad-section-unsupported-text { + margin-top: 2px; + color: var(--text-faint); + font-size: var(--font-ui-small); +} + +/* Notepad pages frame themselves, so the editor's own top/bottom padding + (32px/120px for plain .note files) is dropped — only for the um view. + The inner editor never scrolls on its own: the notepad scroller scrolls + the whole document, and a nested auto-scroller produces phantom + scrollbars on sub-pixel rounding. */ +.notepad-view-container .texto-editor { + padding-top: 0; + padding-bottom: 0; + overflow-y: visible; +} + +/* ── Page-scoped image layouts ── + The image extension expresses "full width" in cqw units and floats + wrap-layout images (see texto/extensions/image/style.css). Both must be + scoped to the PAGE they are on: without a size container anywhere in the + notepad, cqw falls back to the viewport (a full-width image spanned the + whole window), and with the editor's overflow visible a float reached + into the following pages' text. Making each page's .note a size container + mirrors the note view (styles/editor.css .note-view-container .note) and + fixes both: cqw resolves against the page width, and the containment's + layout part establishes the formatting context that traps floats inside + their page (the page then grows to contain the float). */ +.notepad-view-container .note { + container-type: inline-size; +} + +/* ── Drag & drop reorder ── */ + +.notepad-section.is-dragging { + opacity: 0.35; +} + +.notepad-drop-line { + position: absolute; + left: 0; + right: 0; + height: 2px; + border-radius: 1px; + background: var(--interactive-accent); + pointer-events: none; + z-index: 5; +} + +/* ── Controls: add-after / delete ── + Copied from the blog: bare buttons (transparent, no border/box-shadow), + group always visible but very muted (0.3), the group's hover brings it + to full strength, a hovered icon darkens. Small icons pinned to the + right edge of the section column. */ + +.notepad-section-controls { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0; + padding: 0 var(--size-2-1) var(--size-2-1) 0; + opacity: 0.3; + transition: opacity 0.2s; +} + +.notepad-section-controls:hover, +.notepad-section-controls:focus-within { + opacity: 1; +} + +button.notepad-section-control { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 1px 4px; + border: none; + border-radius: var(--radius-s); + box-shadow: none; + background: transparent; + appearance: none; + -webkit-appearance: none; + color: var(--text-faint); + cursor: pointer; +} + +button.notepad-section-control svg { + width: 20px; + height: 20px; + stroke-width: 1.25; + transition: + stroke 0.2s, + opacity 0.2s; +} + +button.notepad-section-control:hover svg { + stroke: var(--text-normal); +} + +/* Delete icon keeps the plus's three-level emphasis, in red — with a soft + red tint on its own hover so the highlight is unmistakable. */ +button.notepad-section-control.is-danger svg { + stroke: var(--text-error); + opacity: 0.35; +} + +.notepad-section-controls:hover button.notepad-section-control.is-danger svg, +.notepad-section-controls:focus-within + button.notepad-section-control.is-danger + svg { + opacity: 0.75; +} + +/* Own hover outranks the group-hover rule above (same properties): the + icon-only emphasis — full-strength red, slightly thicker stroke, no + button chrome of any kind. */ +.notepad-section-controls button.notepad-section-control.is-danger:hover svg { + opacity: 1; + stroke-width: 1.6; +} + +/* ── Undo action inside a delete notice ── + Compact text action that fits the notice in both themes (the raw button + otherwise inherits wide modal styling). */ + +.notepad-notice-action { + flex: 0 0 auto; + align-self: center; + width: auto; + margin-left: var(--size-4-2); + padding: 0; + border: none; + background: transparent; + color: var(--text-on-accent); + font-size: var(--font-ui-small); + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; + opacity: 0.85; +} + +.notepad-notice-action:hover { + opacity: 1; +} + +/* ── Add-note row (always the last element of the page list) ── */ + +/* The element+class selector must outrank Obsidian's + `button:not(.clickable-icon)` base — a bare class loses that race, and + button chrome (background, shadow) would bleed into the normal state. + Background is hover-only (the owner's call, 2026-09-27). */ +button.notepad-add-note { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + /* One inter-section gap above (mirrors .notepad-section + .notepad-section) + so the row reads as the list terminator, not part of the last page. */ + margin-top: 32px; + padding: var(--size-2-2) 0; + border: 1px dashed var(--background-modifier-border); + border-radius: var(--radius-s); + background-color: transparent; + box-shadow: none; + color: var(--text-muted); + font-size: var(--font-ui-small); + cursor: pointer; +} + +.notepad-add-note:hover { + color: var(--text-normal); + background: var(--background-modifier-hover); +} + +/* Obsidian's `button:not(.clickable-icon)` base outranks a bare class — + restate the bare-button reset for every state. */ +button.notepad-section-gutter:hover, +button.notepad-section-control:hover, +button.notepad-section-gutter:focus, +button.notepad-section-control:focus { + background-color: transparent; + box-shadow: none; +} diff --git a/src/texto/core/Extension.ts b/src/texto/core/Extension.ts index 1a366e8..d3ee790 100644 --- a/src/texto/core/Extension.ts +++ b/src/texto/core/Extension.ts @@ -23,10 +23,13 @@ export class Extension { }; constructor(config: Partial> = {}) { + // The user config's `this`-typed handlers narrow Options/Storage beyond + // the base ExtensionConfig; the dynamic dispatch via getExtensionField + // erases that at runtime, so the merged config is intentionally widened. this.config = { ...this.config, ...config, - }; + } as ExtensionConfig; this.name = this.config.name; diff --git a/src/texto/core/Mark.ts b/src/texto/core/Mark.ts index 442db9d..10153bc 100644 --- a/src/texto/core/Mark.ts +++ b/src/texto/core/Mark.ts @@ -28,10 +28,13 @@ export class Mark< }; constructor(config: Partial> = {}) { + // The user config's `this`-typed handlers narrow Options/Storage beyond + // the base MarkConfig; the dynamic dispatch via getExtensionField erases + // that at runtime, so the merged config is intentionally widened. this.config = { ...this.config, ...config, - }; + } as MarkConfig; this.name = this.config.name; @@ -106,7 +109,7 @@ export class Mark< name: extension.name, options: extension.options, }), - ); + ) as ExtendedStorage; return extension; } diff --git a/src/texto/editorSchemas.test.ts b/src/texto/editorSchemas.test.ts new file mode 100644 index 0000000..9a00fe9 --- /dev/null +++ b/src/texto/editorSchemas.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { + getProfileExtensions, + getProfileSchema, + EDITOR_PROFILES, + type EditorProfileId, +} from "./editorSchemas"; + +/** + * Schema-shape guard (spec 8.6.1): the canonical shape of every profile is + * pinned here. A change to node/mark composition or content expressions + * without raising UM_SCHEMA_VERSION is a breaking change shipped silently — + * this test fails first. To change a profile's schema legitimately: bump + * the version in umSchemas/umTypes, add the migration chain entry, then + * update the snapshot strings below. + */ + +const NOTE_NODES = [ + "attachment:null", + "blockquote:block+", + "bulletList:listItem+", + "hardBreak:null", + "heading:inline*", + "hljsCodeBlock:(hljsCodeBlockRow | paragraph?)+", + "hljsCodeBlockRow:inline*", + "horizontalRule:null", + "image:null", + "listItem:paragraph block*", + "noteDoc:noteTitle (block | attachment | image)+", + "noteTitle:inline*", + "orderedList:listItem+", + "paragraph:inline*", + "table:tableRow+", + "tableCell:block+", + "tableHeader:block+", + "tableRow:(tableCell | tableHeader)*", + "taskItem:paragraph block*", + "taskList:taskItem+", + "text:null", +]; + +const MARKS = "bold,code,highlight,hljsMark,italic,link,strike,textStyle,underline"; + +function canonicalShape(profile: EditorProfileId): string { + const schema = getProfileSchema(profile); + const nodes = Object.entries(schema.nodes) + .map(([name, type]) => `${name}:${type.spec.content ?? "null"}`) + .sort(); + const marks = Object.keys(schema.marks).sort().join(","); + const topName = schema.spec.topNode; + if (topName !== EDITOR_PROFILES[profile].topNode) { + return `top-node mismatch: profile says ${EDITOR_PROFILES[profile].topNode}, schema built ${topName}`; + } + return `top=${topName}\nnodes=${nodes.join(",")}\nmarks=${marks}`; +} + +describe("editor profiles", () => { + it("every profile builds a schema whose top node matches the spec", () => { + for (const profile of Object.keys(EDITOR_PROFILES) as EditorProfileId[]) { + const extensions = getProfileExtensions(profile); + expect(extensions.length).toBeGreaterThan(0); + const schema = getProfileSchema(profile); + expect(schema.spec.topNode).toBe(EDITOR_PROFILES[profile].topNode); + } + }); + + it("note profile (plain .note, v1) — title mandatory", () => { + expect(canonicalShape("note")).toBe( + `top=noteDoc\nnodes=${NOTE_NODES.join(",")}\nmarks=${MARKS}`, + ); + }); + + it("title profile (title-v1) — header nodes, summary mandatory", () => { + expect(canonicalShape("title")).toBe( + `top=noteDoc\nnodes=${[ + ...NOTE_NODES.filter((n) => !n.startsWith("noteDoc:")), + "noteDoc:noteTitle noteSummary (block | attachment | image)+", + "noteSummary:inline*", + ] + .sort() + .join(",")}\nmarks=${MARKS}`, + ); + }); + + it("plain profile (plain-v1) — title optional (legacy pages parse)", () => { + expect(canonicalShape("plain")).toBe( + `top=noteDoc\nnodes=${[ + ...NOTE_NODES.filter((n) => !n.startsWith("noteDoc:")), + "noteDoc:noteTitle? (block | attachment | image)+", + ] + .sort() + .join(",")}\nmarks=${MARKS}`, + ); + }); + + it("title and plain profiles differ only in the top-node expression", () => { + const title = getProfileSchema("title"); + const plain = getProfileSchema("plain"); + const titleNodes = Object.keys(title.nodes).sort(); + const plainNodes = Object.keys(plain.nodes).sort(); + expect(plainNodes).toEqual(titleNodes.filter((n) => n !== "noteSummary")); + expect(title.nodes.noteDoc.spec.content).not.toBe( + plain.nodes.noteDoc.spec.content, + ); + }); +}); diff --git a/src/texto/editorSchemas.ts b/src/texto/editorSchemas.ts new file mode 100644 index 0000000..5698f6d --- /dev/null +++ b/src/texto/editorSchemas.ts @@ -0,0 +1,58 @@ +import type { Extensions } from "./core/@types"; +import type { ExtensionHooks } from "./getExtensions"; +import { getExtensions, type GetExtensionsOptions } from "./getExtensions"; +import { ExtensionManager } from "./core/ExtensionManager"; +import { getSchemaByResolvedExtensions } from "./core/helpers/getSchemaByResolvedExtensions"; +import type { Schema } from "prosemirror-model"; + +/** + * Named editor profiles (spec 8.6): one composition of extensions per + * schema family. All profiles share the same plugin set — only the top + * node and the header nodes differ, so "one set of plugins, different + * schemas" holds without hand-written ProseMirror specs. + * + * Versioning (8.6.1): the manifest stores `schema` + `schemaVersion`; the + * current version of every family lives in the storage layer + * (src/storage/um/umSchemas.ts). A schema-shape change here without a + * version bump is caught by the snapshot test in editorSchemas.test.ts. + */ + +export type EditorProfileId = "note" | "title" | "plain"; + +export interface EditorProfile { + id: EditorProfileId; + /** Manifest schema family this profile edits ("note" is the plain .note + * view's profile and never appears in a `.um` manifest). */ + family: string | null; + /** Name of the profile's top node in the built schema. */ + topNode: string; +} + +export const EDITOR_PROFILES: Record = { + note: { id: "note", family: null, topNode: "noteDoc" }, + title: { id: "title", family: "title", topNode: "noteDoc" }, + plain: { id: "plain", family: "plain", topNode: "noteDoc" }, +}; + +/** Build the extension set of a profile. The "note" profile is the + * historical composition (used by the plain .note view). */ +export function getProfileExtensions( + profile: EditorProfileId, + hooks: ExtensionHooks = {}, + options: GetExtensionsOptions = {}, +): Extensions { + return getExtensions(hooks, { ...options, profile }); +} + +/** Resolve a profile's extensions into the ProseMirror schema (same path + * the editor uses: resolve addExtensions first, then build). */ +export function getProfileSchema( + profile: EditorProfileId, + hooks: ExtensionHooks = {}, + options: GetExtensionsOptions = {}, +): Schema { + const resolved = ExtensionManager.resolve( + getProfileExtensions(profile, hooks, options), + ); + return getSchemaByResolvedExtensions(resolved); +} diff --git a/src/texto/extensions/attachment/attachment.icon.test.ts b/src/texto/extensions/attachment/attachment.icon.test.ts index d759fea..9b05bae 100644 --- a/src/texto/extensions/attachment/attachment.icon.test.ts +++ b/src/texto/extensions/attachment/attachment.icon.test.ts @@ -1,21 +1,28 @@ import { describe, it, expect, afterEach, beforeAll } from "vitest"; import { Editor } from "../../core/Editor"; import { getExtensions } from "../../getExtensions"; -import { performBlockMove, findDraggableBlock } from "../drag-handle/dragHandlePlugin"; +import { + performBlockMove, + findDraggableBlock, +} from "../drag-handle/dragHandlePlugin"; import { VIEW_TAG } from "./attachment"; import type { AttachmentElementType } from "./attachment"; /** Obsidian patches these helpers onto HTMLElement; jsdom has none. */ beforeAll(() => { const proto = HTMLElement.prototype as unknown as Record; - proto["addClass"] ??= function (cls: string) { - (this as HTMLElement).classList.add(cls); + proto["addClass"] ??= function (this: HTMLElement, cls: string) { + this.classList.add(cls); }; - proto["removeClass"] ??= function (cls: string) { - (this as HTMLElement).classList.remove(cls); + proto["removeClass"] ??= function (this: HTMLElement, cls: string) { + this.classList.remove(cls); }; - proto["toggleClass"] ??= function (cls: string, value?: boolean) { - (this as HTMLElement).classList.toggle(cls, value); + proto["toggleClass"] ??= function ( + this: HTMLElement, + cls: string, + value?: boolean, + ) { + this.classList.toggle(cls, value); }; }); diff --git a/src/texto/extensions/code-block-hljs/hljsCodeBlock.ts b/src/texto/extensions/code-block-hljs/hljsCodeBlock.ts index 53da8f0..0c64568 100644 --- a/src/texto/extensions/code-block-hljs/hljsCodeBlock.ts +++ b/src/texto/extensions/code-block-hljs/hljsCodeBlock.ts @@ -496,10 +496,13 @@ export const HljsCodeBlock = Node.create({ container.props.onToggleWrap = () => { const currentPos = isFunction(getPos) ? getPos() : null; const current = - currentPos == null - ? null - : editor.state.doc.nodeAt(currentPos); - if (current == null || current.type !== this.type) return; + currentPos == null ? null : editor.state.doc.nodeAt(currentPos); + if ( + currentPos == null || + current == null || + current.type !== this.type + ) + return; editor.view.dispatch( editor.view.state.tr.setNodeMarkup(currentPos, undefined, { ...(current.attrs as HljsCodeBlockAttrs), @@ -511,9 +514,7 @@ export const HljsCodeBlock = Node.create({ container.props.onCopy = () => { const currentPos = isFunction(getPos) ? getPos() : null; const current = - currentPos == null - ? null - : editor.state.doc.nodeAt(currentPos); + currentPos == null ? null : editor.state.doc.nodeAt(currentPos); if (current == null || current.type !== this.type) return; const text = getHljsBlockContentAsText(current); void navigator.clipboard.writeText(text).catch((err: unknown) => { @@ -583,7 +584,8 @@ export const HljsCodeBlock = Node.create({ // does not re-render the generator (external props changes don't // trigger it), so the button class is set directly; a later // generator re-render recomputes the same class from props. - const wrapped = (updatedNode.attrs as HljsCodeBlockAttrs).wrap === true; + const wrapped = + (updatedNode.attrs as HljsCodeBlockAttrs).wrap === true; container.classList.toggle("is-wrapped", wrapped); container.props.wrapped = wrapped; container diff --git a/src/texto/extensions/code-block-hljs/style.css b/src/texto/extensions/code-block-hljs/style.css index 1094706..d4daacf 100644 --- a/src/texto/extensions/code-block-hljs/style.css +++ b/src/texto/extensions/code-block-hljs/style.css @@ -261,7 +261,6 @@ cursor: default; } - & pre { display: block; } diff --git a/src/texto/extensions/code-block-hljs/views/codeBlockIcons.svgnode.tsx b/src/texto/extensions/code-block-hljs/views/codeBlockIcons.svgnode.tsx index 35b3d66..a8f6ceb 100644 --- a/src/texto/extensions/code-block-hljs/views/codeBlockIcons.svgnode.tsx +++ b/src/texto/extensions/code-block-hljs/views/codeBlockIcons.svgnode.tsx @@ -13,7 +13,10 @@ function makeCodeBlockIconNode(name: "copy" | "wrap" | "check") { while (true) { yield ( - + ); } diff --git a/src/texto/extensions/drag-handle/dragHandle.test.ts b/src/texto/extensions/drag-handle/dragHandle.test.ts index 10ed0d0..0654a1d 100644 --- a/src/texto/extensions/drag-handle/dragHandle.test.ts +++ b/src/texto/extensions/drag-handle/dragHandle.test.ts @@ -92,7 +92,7 @@ interface Fixture { function createFixture( content: JSONContent = testContent(), - options: { isMobileView?: boolean } = {}, + options: { isMobileView?: boolean; profile?: "note" | "title" | "plain" } = {}, ): Fixture { const el = createDiv(); document.body.appendChild(el); @@ -116,7 +116,7 @@ const fixtures: Fixture[] = []; function useFixture( content?: JSONContent, - options?: { isMobileView?: boolean }, + options?: { isMobileView?: boolean; profile?: "note" | "title" | "plain" }, ): Fixture { const fixture = createFixture(content, options); fixtures.push(fixture); @@ -400,3 +400,32 @@ describe("DragHandle extension", () => { ).toBe(true); }); }); + +describe("cover header is not draggable (spec 9.1)", () => { + function titleDocContent(): JSONContent { + return { + type: "noteDoc", + content: [ + { type: "noteTitle", content: [{ type: "text", text: "Cover" }] }, + { + type: "noteSummary", + content: [{ type: "text", text: "Summary text" }], + }, + { type: "paragraph", content: [{ type: "text", text: "Body" }] }, + ], + }; + } + + it("resolves neither the title nor the summary, only the content blocks", () => { + const { editor, positions } = useFixture(titleDocContent(), { + profile: "title", + }); + const doc = editor.state.doc; + // positions[0]=title, [1]=summary, [2]=paragraph — inside each block. + expect(findDraggableBlock(doc, positions[0] + 1)).toBeNull(); + expect(findDraggableBlock(doc, positions[1] + 1)).toBeNull(); + const body = findDraggableBlock(doc, positions[2] + 1); + expect(body?.node.type.name).toBe("paragraph"); + expect(body?.node.textContent).toBe("Body"); + }); +}); diff --git a/src/texto/extensions/drag-handle/dragHandlePlugin.ts b/src/texto/extensions/drag-handle/dragHandlePlugin.ts index afccb74..b296f0f 100644 --- a/src/texto/extensions/drag-handle/dragHandlePlugin.ts +++ b/src/texto/extensions/drag-handle/dragHandlePlugin.ts @@ -86,8 +86,12 @@ export const dragHandleKey = new PluginKey( "inscriptumDragHandle", ); -/** Top-level node kinds that cannot be dragged (the document title). */ -export const DRAG_HANDLE_EXCLUDED_TYPES: readonly string[] = ["noteTitle"]; +/** Top-level node kinds that cannot be dragged (the document title and the + * title page's summary — the cover header is fixed, spec 9.1). */ +export const DRAG_HANDLE_EXCLUDED_TYPES: readonly string[] = [ + "noteTitle", + "noteSummary", +]; /** Node kinds that drag as individual items (a whole list drags item-wise). */ export const DRAG_HANDLE_ITEM_TYPES: readonly string[] = [ @@ -1026,10 +1030,7 @@ function createDragHandleView( // landed on the list node beside its items' lines (sub-pixel // boundary resolution): the candidates are the container's OWN // units, and the deepest one whose line owns X wins. - if ( - unit.block.from < than.from || - unit.block.to > than.to - ) { + if (unit.block.from < than.from || unit.block.to > than.to) { continue; } } @@ -1124,13 +1125,18 @@ function createDragHandleView( // The Y-strip model stays as the GUTTER fallback: posAtCoords is null // left of the content — exactly where the handles live — and the // strip-owns-the-line rule must keep working there. - const coords = view.posAtCoords({ left: event.clientX, top: event.clientY }); + const coords = view.posAtCoords({ + left: event.clientX, + top: event.clientY, + }); if (coords != null) { // inside = the inner position of the deepest node under the point // (the image itself, the paragraph…); -1 when the point is between // nodes — then pos (the nearest boundary) is the best guess. const pos = - coords.inside != null && coords.inside >= 0 ? coords.inside : coords.pos; + coords.inside != null && coords.inside >= 0 + ? coords.inside + : coords.pos; if (pos != null) { const block = findDraggableBlock( view.state.doc, diff --git a/src/texto/extensions/note-doc/NoteSummary.ts b/src/texto/extensions/note-doc/NoteSummary.ts new file mode 100644 index 0000000..700a7bd --- /dev/null +++ b/src/texto/extensions/note-doc/NoteSummary.ts @@ -0,0 +1,41 @@ +import { Node } from "../../core"; +import { mergeAttributes } from "../../core/utilities"; +import type { AnyRecord } from "../../core/@types"; + +/** + * The summary line of a title page header (spec 9.1) — the blog draft's + * topicSummary: a short line under the title with its own placeholder and + * a field label shown while the header is focused. + */ +export const NoteSummary = Node.create({ + name: "noteSummary", + + content: "inline*", + + defining: true, + selectable: false, + + addAttributes() { + return { + "data-placeholder": { + default: "Summary of the text", + }, + "data-label": { + default: "Summary", + }, + role: { + default: "definition", + }, + }; + }, + + parseHTML() { + return [{ tag: "p" }]; + }, + + renderHTML({ HTMLAttributes }) { + return ["p", mergeAttributes(HTMLAttributes), 0]; + }, +}); + +export type NoteSummaryOptions = AnyRecord; diff --git a/src/texto/extensions/note-doc/PlainDoc.ts b/src/texto/extensions/note-doc/PlainDoc.ts new file mode 100644 index 0000000..d37952c --- /dev/null +++ b/src/texto/extensions/note-doc/PlainDoc.ts @@ -0,0 +1,31 @@ +import { Node } from "../../core"; +import { NoteTitle } from "./NoteTitle"; +import { trailingParagraphPlugin } from "./trailingParagraph"; + +/** + * Top node of a regular notepad page (spec 9.2, schema family `plain`): + * content blocks only, no mandatory title. The title node stays in the + * schema as an OPTIONAL leading slot so pages of legacy containers (written + * when the title was required) parse unchanged and are preserved on + * rewrite — no migration needed. + * + * The node NAME stays "noteDoc" for the same JSON-compatibility reason as + * TitleDoc; the manifest fields select the profile. + * + * The notePlaceholders plugin is NOT included: its title decoration + * targets child(0) whatever node that is, which would mislabel the first + * content block of title-less pages (and it indexes child(1) unguarded). + */ +export const PlainDoc = Node.create({ + name: "noteDoc", + topNode: true, + content: "noteTitle? (block | attachment | image)+", + + addExtensions() { + return [NoteTitle]; + }, + + addProseMirrorPlugins() { + return [trailingParagraphPlugin()]; + }, +}); diff --git a/src/texto/extensions/note-doc/TitleDoc.ts b/src/texto/extensions/note-doc/TitleDoc.ts new file mode 100644 index 0000000..82833be --- /dev/null +++ b/src/texto/extensions/note-doc/TitleDoc.ts @@ -0,0 +1,28 @@ +import { Node } from "../../core"; +import { NoteTitle } from "./NoteTitle"; +import { NoteSummary } from "./NoteSummary"; +import { titleHeaderPlugin } from "./plugins/titleHeader.plugin"; +import { trailingParagraphPlugin } from "./trailingParagraph"; + +/** + * Top node of a title-page document (spec 9.1, schema family `title`): + * the mandatory title + summary header, then regular content blocks. + * + * The node NAME stays "noteDoc" — the serialized shape of existing + * containers keeps parsing without migrations; the manifest's + * `schema`/`schemaVersion` fields (8.6) carry the distinction between the + * profiles, and each profile builds its own ProseMirror Schema instance. + */ +export const TitleDoc = Node.create({ + name: "noteDoc", + topNode: true, + content: "noteTitle noteSummary (block | attachment | image)+", + + addExtensions() { + return [NoteTitle, NoteSummary]; + }, + + addProseMirrorPlugins() { + return [titleHeaderPlugin(), trailingParagraphPlugin()]; + }, +}); diff --git a/src/texto/extensions/note-doc/index.ts b/src/texto/extensions/note-doc/index.ts index 7dbaa5a..0a906a6 100644 --- a/src/texto/extensions/note-doc/index.ts +++ b/src/texto/extensions/note-doc/index.ts @@ -2,5 +2,8 @@ import { NoteDoc } from "./NoteDoc"; export * from "./NoteDoc"; export * from "./NoteTitle"; +export * from "./NoteSummary"; +export * from "./TitleDoc"; +export * from "./PlainDoc"; export default NoteDoc; diff --git a/src/texto/extensions/note-doc/plugins/titleHeader.plugin.ts b/src/texto/extensions/note-doc/plugins/titleHeader.plugin.ts new file mode 100644 index 0000000..f3c2c5d --- /dev/null +++ b/src/texto/extensions/note-doc/plugins/titleHeader.plugin.ts @@ -0,0 +1,79 @@ +import { Plugin, PluginKey } from "prosemirror-state"; +import { Decoration, DecorationSet } from "prosemirror-view"; +import type { EditorState } from "prosemirror-state"; + +/** + * Decorations for the title-page header (spec 9.1) — a port of the blog + * draft's TopicDoc plugin. The header is the doc's first two children + * (noteTitle, noteSummary). While a field is empty it shows its + * `data-placeholder`; while the caret is inside the header and the field + * has text it gets the `focused` class, which the stylesheet turns into + * the blog's field label with the vertical rule to the left of the field. + * + * The label's visibility also requires real editor focus; that part is + * CSS-live (`.notepad-section-body:focus-within`), so no decoration work + * is needed on focus changes. + */ +export function titleHeaderPlugin(): Plugin { + return new Plugin({ + key: new PluginKey("titleHeader"), + props: { + decorations: (state: EditorState) => { + const { doc, selection } = state; + if (doc.content.childCount < 2) return null; + + const title = doc.content.child(0); + const summary = doc.content.child(1); + if (title.type.name !== "noteTitle" || summary.type.name !== "noteSummary") { + return null; + } + + const headerEnd = title.nodeSize + summary.nodeSize; + const inHeader = selection.to <= headerEnd; + + const decorations: Decoration[] = []; + if (title.content.size === 0) { + decorations.push( + Decoration.node(0, title.nodeSize, { + class: "empty", + "data-placeholder": "Title", + }), + ); + } else if (inHeader) { + decorations.push( + Decoration.node(0, title.nodeSize, { + class: "focused", + "data-label": "Title", + }), + ); + } + + if (summary.content.size === 0) { + decorations.push( + Decoration.node( + title.nodeSize, + title.nodeSize + summary.nodeSize, + { + class: "empty", + "data-placeholder": "Summary of the text", + }, + ), + ); + } else if (inHeader) { + decorations.push( + Decoration.node( + title.nodeSize, + title.nodeSize + summary.nodeSize, + { + class: "focused", + "data-label": "Summary", + }, + ), + ); + } + + return DecorationSet.create(doc, decorations); + }, + }, + }); +} diff --git a/src/texto/extensions/note-doc/trailingParagraph.test.ts b/src/texto/extensions/note-doc/trailingParagraph.test.ts index 6cc07a5..4939331 100644 --- a/src/texto/extensions/note-doc/trailingParagraph.test.ts +++ b/src/texto/extensions/note-doc/trailingParagraph.test.ts @@ -45,7 +45,7 @@ function createEditor(content?: JSONContent) { * a real document change (a harmless character in the title) to trigger * appendTransaction — the same path a real user's edit takes. */ function pump(editor: Editor): void { - const pos = editor.state.doc.firstChild.nodeSize - 1; + const pos = editor.state.doc.firstChild!.nodeSize - 1; editor.view.dispatch(editor.state.tr.insertText("x", pos, pos)); } @@ -299,7 +299,7 @@ describe("trailingParagraphPlugin", () => { const editor = createEditor(); expect( editor.state.plugins.some( - (p) => p.key === trailingParagraphPluginKey.key, + (p) => p.spec.key === trailingParagraphPluginKey, ), ).toBe(true); editor.destroy(); diff --git a/src/texto/extensions/table/helpers/handleMouseDown.ts b/src/texto/extensions/table/helpers/handleMouseDown.ts index a82d3fc..28a6d7d 100644 --- a/src/texto/extensions/table/helpers/handleMouseDown.ts +++ b/src/texto/extensions/table/helpers/handleMouseDown.ts @@ -220,7 +220,7 @@ export function handleMouseDown( let topLeftAnchor = Number(target.dataset.cellPos) || null; let isFirstMove = true; - function setCellSelection($anchor: ResolvedPos, event: MouseEvent) { + function setCellSelection($anchor: ResolvedPos | null, event: MouseEvent) { if (!$anchor) return; const $head = @@ -230,7 +230,16 @@ export function handleMouseDown( event.clientY, isFirstMove ? CIRCLE_CLEARANCE : 0, ) ?? - cellInTableAtPoint(view, topLeftAnchor, event.clientX, event.clientY); + // Without a recorded anchor the fallback cannot resolve a table — + // doc.resolve(null) would throw, so skip it instead. + (topLeftAnchor == null + ? null + : cellInTableAtPoint( + view, + topLeftAnchor, + event.clientX, + event.clientY, + )); isFirstMove = false; if (!$head) return; diff --git a/src/texto/extensions/table/helpers/overlay.ts b/src/texto/extensions/table/helpers/overlay.ts index 209a78b..25435f5 100644 --- a/src/texto/extensions/table/helpers/overlay.ts +++ b/src/texto/extensions/table/helpers/overlay.ts @@ -133,7 +133,7 @@ function getOneCellSelectingDecoration(state: EditorState) { const cell = cellAround(state.selection.$anchor); if (!cell) { - return; + return null; } const start = findTableAnchor(state); diff --git a/src/texto/extensions/task-item/style.css b/src/texto/extensions/task-item/style.css index e6d59e6..9ab7e67 100644 --- a/src/texto/extensions/task-item/style.css +++ b/src/texto/extensions/task-item/style.css @@ -105,7 +105,8 @@ ul.chk, The hold-grab/drag states hide the chevron (dots handle takes its place; those rules live in styles/drag-handle.css, imported BEFORE this file — so the exclusion here is what lets them win). */ -.texto-extension-task-item-host .texto-task-fold-chevron.is-hover:not(.is-grabbed):not(.is-dragging) { +.texto-extension-task-item-host +.texto-task-fold-chevron.is-hover:not(.is-grabbed):not(.is-dragging) { opacity: 1; } diff --git a/src/texto/getExtensions.ts b/src/texto/getExtensions.ts index 11df936..435378e 100644 --- a/src/texto/getExtensions.ts +++ b/src/texto/getExtensions.ts @@ -2,7 +2,7 @@ import type { Extensions } from "./core/@types"; import type { ImageOptionsHooks } from "./extensions/image"; import type { AttachmentOptionsHooks } from "./extensions/attachment"; import type { StateOptionsHooks } from "./extensions/state"; -import { NoteDoc } from "./extensions/note-doc"; +import { NoteDoc, PlainDoc, TitleDoc } from "./extensions/note-doc"; import { Link } from "./extensions/link"; import { Cleanup } from "./extensions/cleanup"; import { ListKeymap } from "./extensions/list-keymap"; @@ -52,15 +52,28 @@ export interface ExtensionHooks { export interface GetExtensionsOptions { /** Mobile version of Obsidian (touch gestures, table resize handles). */ isMobileView?: boolean; + /** Editor profile (spec 8.6): which top node the schema gets. "note" is + * the historical composition — the plain .note document with a + * mandatory title; "title"/"plain" are the notepad page families. The + * profiles share everything except the top node. */ + profile?: "note" | "title" | "plain"; } export function getExtensions( hooks: ExtensionHooks = {}, options: GetExtensionsOptions = {}, ): Extensions { + const profile = options.profile ?? "note"; + // The profile's top node takes NoteDoc's position in the list; the + // plugin order therefore stays identical across profiles. + const topNode = + profile === "title" ? TitleDoc : profile === "plain" ? PlainDoc : NoteDoc; return [ - State.configure({ nodeTypes: ["image", "attachment"], hooks: hooks.state }), - NoteDoc, + State.configure({ + nodeTypes: ["image", "attachment"], + hooks: hooks.state, + }), + topNode, Link, Cleanup, ListKeymap, diff --git a/src/ui/ConfirmModal.ts b/src/ui/ConfirmModal.ts new file mode 100644 index 0000000..bea43c6 --- /dev/null +++ b/src/ui/ConfirmModal.ts @@ -0,0 +1,40 @@ +import { App, Modal, Setting } from "obsidian"; + +export interface ConfirmModalOptions { + title?: string; + message: string; + confirmText?: string; + cancelText?: string; + onConfirm: () => void; +} + +/** Yes/No confirmation dialog. Escape closes as cancel. */ +export class ConfirmModal extends Modal { + private options: ConfirmModalOptions; + + constructor(app: App, options: ConfirmModalOptions) { + super(app); + this.options = options; + } + + onOpen(): void { + this.titleEl.setText(this.options.title ?? "Are you sure?"); + this.contentEl.createEl("p", { text: this.options.message }); + + const buttons = new Setting(this.contentEl); + buttons.addButton((btn) => + btn + .setButtonText(this.options.cancelText ?? "Cancel") + .onClick(() => this.close()), + ); + buttons.addButton((btn) => + btn + .setButtonText(this.options.confirmText ?? "OK") + .setCta() + .onClick(() => { + this.close(); + this.options.onConfirm(); + }), + ); + } +} diff --git a/src/ui/NewNoteModal.ts b/src/ui/NewNoteModal.ts index 5ed9a9d..427b416 100644 --- a/src/ui/NewNoteModal.ts +++ b/src/ui/NewNoteModal.ts @@ -2,39 +2,75 @@ import { App, Modal, Setting, TextComponent, type TFolder } from "obsidian"; const INVALID_FILENAME_CHARS = /[\\/:*?"<>|]/g; +/** What the modal should create: a plain `.note` or a `.um` notepad. */ +export type NewNoteKind = "note" | "notepad"; + +export interface NewNoteResult { + name: string; + folderPath: string; + kind: NewNoteKind; +} + +export interface NewNoteModalOptions { + title?: string; + namePlaceholder?: string; + /** Preset the type selector. */ + kind?: NewNoteKind; + /** Show the Note/Notepad selector (default true). */ + chooseKind?: boolean; +} + export class NewNoteModal extends Modal { - private resolve: ( - result: { name: string; folderPath: string } | null, - ) => Promise; + private resolve: (result: NewNoteResult | null) => Promise; private resolved = false; private input: TextComponent | null = null; private folderPath: string; private folders: TFolder[]; + private modalTitle: string | undefined; + private namePlaceholder: string | undefined; + private kind: NewNoteKind; + private chooseKind: boolean; constructor( app: App, folders: TFolder[], defaultFolderPath: string, - resolve: ( - result: { name: string; folderPath: string } | null, - ) => Promise, + resolve: (result: NewNoteResult | null) => Promise, + options?: NewNoteModalOptions, ) { super(app); this.resolve = resolve; this.folders = folders; this.folderPath = defaultFolderPath === "/" ? "" : defaultFolderPath; + this.modalTitle = options?.title; + this.namePlaceholder = options?.namePlaceholder; + this.kind = options?.kind ?? "note"; + this.chooseKind = options?.chooseKind ?? true; } onOpen(): void { this.modalEl.addClass("inscriptum-new-note-modal"); - this.titleEl.setText("New inscriptum"); + this.titleEl.setText(this.modalTitle ?? "New inscriptum"); + if (this.chooseKind) { + const kindSetting = new Setting(this.contentEl) + .setName("Type") + .setClass("inscriptum-new-note-setting"); + kindSetting.addDropdown((dropdown) => { + dropdown.addOption("note", "Note (.note)"); + dropdown.addOption("notepad", "Notepad (.um)"); + dropdown.setValue(this.kind); + dropdown.onChange((value) => { + this.kind = value === "notepad" ? "notepad" : "note"; + }); + }); + } const nameSetting = new Setting(this.contentEl) .setName("Note name") .setClass("inscriptum-new-note-setting"); nameSetting.addText((text) => { this.input = text; - text.setPlaceholder("Untitled"); + text.setPlaceholder(this.namePlaceholder ?? "Untitled"); text.inputEl.addEventListener("keydown", (event: KeyboardEvent) => { if (event.key === "Enter") { event.preventDefault(); @@ -84,8 +120,8 @@ export class NewNoteModal extends Modal { const name = raw.replace(INVALID_FILENAME_CHARS, "-"); this.resolved = true; - this.resolve({ name, folderPath: this.folderPath }).catch((e) => - console.error(e), + this.resolve({ name, folderPath: this.folderPath, kind: this.kind }).catch( + (e) => console.error(e), ); this.close(); } diff --git a/src/ui/imageLightbox.ts b/src/ui/imageLightbox.ts index 6961afa..e9c5423 100644 --- a/src/ui/imageLightbox.ts +++ b/src/ui/imageLightbox.ts @@ -15,15 +15,26 @@ export function openImageLightbox( if (app.vault.getAbstractFileByPath(imageId) == null) { return; } + openImageLightboxFromUrl( + app.vault.adapter.getResourcePath(imageId), + filename ?? imageId, + ); +} +/** Lightbox for an image that already has a usable URL (e.g. a container + * asset's object URL — no vault file involved). */ +export function openImageLightboxFromUrl( + src: string, + filename?: string | null, +): void { closeImageLightbox(); const overlay = createDiv(); overlay.className = LIGHTBOX_CLASS; const img = createEl("img"); - img.src = app.vault.adapter.getResourcePath(imageId); - img.alt = filename || imageId; + img.src = src; + img.alt = filename || src; overlay.appendChild(img); if (filename) { diff --git a/tests/e2e/drag-handle.spec.ts b/tests/e2e/drag-handle.spec.ts new file mode 100644 index 0000000..47a06f3 --- /dev/null +++ b/tests/e2e/drag-handle.spec.ts @@ -0,0 +1,87 @@ +import { expect, test, type Locator } from "@playwright/test"; +import { noteDoc, p } from "./fixtures/docs"; +import { docJson, mount, openHarness, topLevelTexts } from "./helpers"; + +const EDITOR = ".texto-editor .ProseMirror"; + +async function boundingBox(locator: Locator): Promise<{ + x: number; + y: number; + width: number; + height: number; +}> { + const box = await locator.boundingBox(); + if (box == null) { + throw new Error("element has no bounding box"); + } + return box; +} + +test.describe("drag handle", () => { + test("dragging a paragraph by its handle reorders the blocks", async ({ + page, + }) => { + await openHarness(page); + await mount(page, noteDoc(p("first"), p("second"))); + + const first = page.locator(`${EDITOR} > p`, { hasText: "first" }); + await first.hover(); + + const handle = page.locator(".texto-editor .texto-drag-handle.is-visible"); + await expect(handle).toBeVisible(); + const handleBox = await boundingBox(handle); + + await page.mouse.move( + handleBox.x + handleBox.width / 2, + handleBox.y + handleBox.height / 2, + ); + await page.mouse.down(); + + const second = page.locator(`${EDITOR} > p`, { hasText: "second" }); + const secondBox = await boundingBox(second); + // Target the LOWER half of the "second" block: the drop boundary is + // then after it, so "first" must land last. (Below the last block + // posAtCoords gives no target and the drop cancels.) + await page.mouse.move( + secondBox.x + 30, + secondBox.y + secondBox.height - 3, + { steps: 20 }, + ); + + // The drop indicator must be on while dragging with a live target. + await expect(page.locator(".texto-drag-drop-line").first()).toBeVisible(); + + await page.mouse.up(); + + expect(topLevelTexts(await docJson(page))).toEqual(["second", "first"]); + }); + + test("dropping the block back onto itself cancels the move", async ({ + page, + }) => { + await openHarness(page); + await mount(page, noteDoc(p("first"), p("second"))); + + const first = page.locator(`${EDITOR} > p`, { hasText: "first" }); + await first.hover(); + + const handle = page.locator(".texto-editor .texto-drag-handle.is-visible"); + await expect(handle).toBeVisible(); + const handleBox = await boundingBox(handle); + + await page.mouse.move( + handleBox.x + handleBox.width / 2, + handleBox.y + handleBox.height / 2, + ); + await page.mouse.down(); + // Move well past the threshold to arm the drag, then release over the + // dragged block itself — a no-op drop. + const firstBox = await boundingBox(first); + await page.mouse.move(firstBox.x + 30, firstBox.y + firstBox.height / 2, { + steps: 10, + }); + await page.mouse.up(); + + expect(topLevelTexts(await docJson(page))).toEqual(["first", "second"]); + }); +}); diff --git a/tests/e2e/e2e-window.d.ts b/tests/e2e/e2e-window.d.ts new file mode 100644 index 0000000..a24980b --- /dev/null +++ b/tests/e2e/e2e-window.d.ts @@ -0,0 +1,20 @@ +// Types for the window.__e2e API installed by the browser harness +// (src/browser-harness/harness.ts). Values cross page.evaluate as plain +// JSON, so the doc shape is intentionally loose here. +import type { Editor } from "../../src/texto/core/Editor"; + +declare global { + interface Window { + __e2e: { + mount(doc: unknown): string; + readonly editor: Editor | null; + docJson(): unknown; + positions(): number[]; + headingFolds(): number[]; + taskFolds(): number[]; + lastError(): unknown; + }; + } +} + +export {}; diff --git a/tests/e2e/fixtures/docs.ts b/tests/e2e/fixtures/docs.ts new file mode 100644 index 0000000..c604390 --- /dev/null +++ b/tests/e2e/fixtures/docs.ts @@ -0,0 +1,55 @@ +import type { JsonNode } from "../helpers"; + +export function noteTitle(text = "Title"): JsonNode { + return { type: "noteTitle", content: [{ type: "text", text }] }; +} + +export function noteDoc(...children: JsonNode[]): JsonNode { + return { type: "noteDoc", content: [noteTitle(), ...children] }; +} + +export function p(text: string): JsonNode { + return { type: "paragraph", content: [{ type: "text", text }] }; +} + +export function heading(level: number, text: string): JsonNode { + return { + type: "heading", + attrs: { level }, + content: [{ type: "text", text }], + }; +} + +export interface TaskSpec { + text: string; + checked?: boolean; + sub?: TaskSpec[]; +} + +export function taskList(items: TaskSpec[]): JsonNode { + return { + type: "taskList", + content: items.map((item) => ({ + type: "taskItem", + attrs: { checked: item.checked ?? false }, + content: [ + p(item.text), + ...(item.sub != null && item.sub.length > 0 ? [taskList(item.sub)] : []), + ], + })), + }; +} + +/** rows[0] becomes the header row (tableHeader), the rest tableCell. */ +export function table(rows: string[][]): JsonNode { + return { + type: "table", + content: rows.map((row, i) => ({ + type: "tableRow", + content: row.map((text) => ({ + type: i === 0 ? "tableHeader" : "tableCell", + content: text.length > 0 ? [p(text)] : [], + })), + })), + }; +} diff --git a/tests/e2e/formatting.spec.ts b/tests/e2e/formatting.spec.ts new file mode 100644 index 0000000..394791a --- /dev/null +++ b/tests/e2e/formatting.spec.ts @@ -0,0 +1,101 @@ +import { expect, test } from "@playwright/test"; +import { noteDoc, p } from "./fixtures/docs"; +import { docJson, findText, mount, openHarness } from "./helpers"; + +const EDITOR = ".texto-editor .ProseMirror"; + +/** Screen coords of the given text-offset span inside the first paragraph. */ +async function spanBox( + page: import("@playwright/test").Page, + from: number, + to: number, +): Promise<{ x: number; y: number }> { + const box = await page.evaluate( + ({ f, t }) => { + const pEl = document.querySelector(".texto-editor .ProseMirror > p"); + const text = pEl?.firstChild; + if (text == null) { + return null; + } + const range = document.createRange(); + range.setStart(text, f); + range.setEnd(text, t); + const rect = range.getBoundingClientRect(); + return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + }, + { f: from, t: to }, + ); + if (box == null) { + throw new Error("paragraph text node not found"); + } + return box; +} + +test.describe("formatting", () => { + test("Mod-B toggles bold on the selected word", async ({ page }) => { + await openHarness(page); + await mount(page, noteDoc(p("hello"))); + + // Dblclick the word's own glyphs — a dblclick at the paragraph center + // maps to a caret past the text and the word selection flakes. + const word = await spanBox(page, 0, 5); + await page.mouse.dblclick(word.x, word.y); + await page.keyboard.press("ControlOrMeta+b"); + + const bolded = findText(await docJson(page), "hello"); + expect(bolded?.marks?.map((mark) => mark.type)).toContain("bold"); + + await page.keyboard.press("ControlOrMeta+b"); + const cleared = findText(await docJson(page), "hello"); + expect(cleared?.marks?.map((mark) => mark.type) ?? []).not.toContain("bold"); + }); + + test("Mod-I applies italic to the selected word", async ({ page }) => { + await openHarness(page); + await mount(page, noteDoc(p("hello"))); + + const word = await spanBox(page, 0, 5); + await page.mouse.dblclick(word.x, word.y); + await page.keyboard.press("ControlOrMeta+i"); + + const italicized = findText(await docJson(page), "hello"); + expect(italicized?.marks?.map((mark) => mark.type)).toContain("italic"); + }); + + test("Mod-B bolds only the drag-selected word", async ({ page }) => { + await openHarness(page); + await mount(page, noteDoc(p("alpha beta gamma"))); + + // Screen coords of the word "beta" (text offsets 6..10) via a Range — + // keyboard selection is unreliable in headless Chromium, but a mouse + // drag over exact glyph boxes is deterministic. + const box = await page.evaluate(() => { + const pEl = document.querySelector(".texto-editor .ProseMirror > p"); + const text = pEl?.firstChild; + if (text == null) { + return null; + } + const range = document.createRange(); + range.setStart(text, 6); + range.setEnd(text, 10); + const rect = range.getBoundingClientRect(); + return { x1: rect.left, x2: rect.right, y: rect.top + rect.height / 2 }; + }); + if (box == null) { + throw new Error("paragraph text node not found"); + } + + await page.mouse.move(box.x1 + 1, box.y); + await page.mouse.down(); + await page.mouse.move(box.x2 - 1, box.y, { steps: 5 }); + await page.mouse.up(); + await page.keyboard.press("ControlOrMeta+b"); + + const doc = await docJson(page); + expect(findText(doc, "beta")?.marks?.map((mark) => mark.type)).toContain( + "bold", + ); + expect(findText(doc, "alpha")?.marks ?? []).toEqual([]); + expect(findText(doc, "gamma")?.marks ?? []).toEqual([]); + }); +}); diff --git a/tests/e2e/harness/index.html b/tests/e2e/harness/index.html new file mode 100644 index 0000000..d5b1ba8 --- /dev/null +++ b/tests/e2e/harness/index.html @@ -0,0 +1,32 @@ + + + + +inscriptum e2e harness + + + + +
+ + + diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts new file mode 100644 index 0000000..b7488c3 --- /dev/null +++ b/tests/e2e/helpers.ts @@ -0,0 +1,146 @@ +import { type Locator, type Page } from "@playwright/test"; + +export const HARNESS_URL = "/tests/e2e/harness/index.html"; + +/** Minimal structural view of the ProseMirror doc JSON the harness returns. */ +export interface JsonMark { + type?: string; +} + +export interface JsonNode { + type?: string; + text?: string; + marks?: JsonMark[]; + attrs?: Record; + content?: JsonNode[]; +} + +export async function openHarness(page: Page): Promise { + await page.goto(HARNESS_URL); + await page.waitForFunction(() => + Boolean((window as unknown as { __e2e?: unknown }).__e2e), + ); +} + +export async function mount(page: Page, doc: JsonNode): Promise { + await page.evaluate((d) => { + window.__e2e.mount(d); + }, doc); + // Let the first paint/layout settle — a click fired immediately after a + // mount can be mapped by ProseMirror to the wrong block (the title). + await page.waitForTimeout(100); +} + +/** {from, to} of the exact text node inside the current document. */ +export async function textRange( + page: Page, + text: string, +): Promise<{ from: number; to: number }> { + const range = await page.evaluate((t) => { + let out: { from: number; to: number } | null = null; + window.__e2e.editor?.state.doc.descendants((node, pos) => { + if (out == null && node.isText && node.text === t) { + out = { from: pos, to: pos + node.nodeSize }; + } + return true; + }); + return out; + }, text); + if (range == null) { + throw new Error(`text not found in document: ${text}`); + } + return range; +} + +/** + * Click and make sure the caret landed inside the given text. ProseMirror + * occasionally maps the first click after a mount to the note title, so + * verify the PM selection and retry. + * + * at: "end" additionally requires the caret to sit right after the text — + * what a click past the line's glyphs maps to, and what typing tests need. + */ +export async function clickInsideText( + page: Page, + locator: Locator, + text: string, + at: "inside" | "end" = "inside", +): Promise { + const range = await textRange(page, text); + for (let attempt = 0; attempt < 3; attempt += 1) { + await locator.click(); + const from = await page.evaluate( + () => window.__e2e.editor?.state.selection.from ?? -1, + ); + const ok = + at === "end" ? from === range.to : from >= range.from && from <= range.to; + if (ok) { + return; + } + } + throw new Error(`click never landed inside text: ${text}`); +} + +export async function docJson(page: Page): Promise { + return (await page.evaluate(() => window.__e2e.docJson())) as JsonNode; +} + +/** All text-node strings of the doc, in document order. */ +export function textsOf(node: JsonNode): string[] { + const out: string[] = []; + const walk = (n: JsonNode): void => { + if (typeof n.text === "string") { + out.push(n.text); + } + for (const child of n.content ?? []) { + walk(child); + } + }; + walk(node); + return out; +} + +/** Concatenated text of each top-level block, excluding the note title. */ +export function topLevelTexts(doc: JsonNode): string[] { + return (doc.content ?? []) + .slice(1) + .map((block) => textsOf(block).join("")) + .filter((text) => text.length > 0); +} + +/** First text node with the exact given text. */ +export function findText(doc: JsonNode, text: string): JsonNode | undefined { + const stack: JsonNode[] = [doc]; + while (stack.length > 0) { + const node = stack.pop() as JsonNode; + if (node.text === text) { + return node; + } + stack.push(...(node.content ?? [])); + } + return undefined; +} + +/** All nodes of the given type, in document order. */ +export function nodesOfType(doc: JsonNode, type: string): JsonNode[] { + const out: JsonNode[] = []; + const walk = (node: JsonNode): void => { + if (node.type === type) { + out.push(node); + } + for (const child of node.content ?? []) { + walk(child); + } + }; + walk(doc); + return out; +} + +export function isMac(): boolean { + return process.platform === "darwin"; +} + +/** Move the caret to the end of the current visual line (mac-safe). */ +export async function pressEndOfLine(page: Page): Promise { + await page.keyboard.press(isMac() ? "Meta+ArrowRight" : "End"); +} diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts new file mode 100644 index 0000000..ff2fcfb --- /dev/null +++ b/tests/e2e/smoke.spec.ts @@ -0,0 +1,67 @@ +import { expect, test } from "@playwright/test"; +import { noteDoc, p } from "./fixtures/docs"; +import { + clickInsideText, + docJson, + mount, + openHarness, + topLevelTexts, +} from "./helpers"; + +test.describe("editor smoke", () => { + test("renders the mounted document", async ({ page }) => { + await openHarness(page); + await mount(page, noteDoc(p("plain p1"), p("plain p2"))); + + const editor = page.locator(".texto-editor .ProseMirror"); + await expect(editor).toBeVisible(); + await expect( + page.locator(".texto-editor .ProseMirror > p", { hasText: "plain p1" }), + ).toBeVisible(); + await expect( + page.locator(".texto-editor .ProseMirror > p", { hasText: "plain p2" }), + ).toBeVisible(); + }); + + test("typing inserts text at the caret", async ({ page }) => { + await openHarness(page); + await mount(page, noteDoc(p("plain p1"))); + + const para = page.locator(".texto-editor .ProseMirror > p", { + hasText: "plain p1", + }); + await clickInsideText(page, para, "plain p1", "end"); + await page.keyboard.type(" hello"); + + expect(topLevelTexts(await docJson(page))).toEqual(["plain p1 hello"]); + }); + + test("undo reverts typed text", async ({ page }) => { + await openHarness(page); + await mount(page, noteDoc(p("plain p1"))); + + const para = page.locator(".texto-editor .ProseMirror > p", { + hasText: "plain p1", + }); + await clickInsideText(page, para, "plain p1", "end"); + await page.keyboard.type(" hello"); + await page.keyboard.press("ControlOrMeta+z"); + + expect(topLevelTexts(await docJson(page))).toEqual(["plain p1"]); + }); + + test("the editor reports no errors while mounting and typing", async ({ + page, + }) => { + await openHarness(page); + await mount(page, noteDoc(p("plain p1"))); + const para = page.locator(".texto-editor .ProseMirror > p", { + hasText: "plain p1", + }); + await clickInsideText(page, para, "plain p1", "end"); + await page.keyboard.type(" more"); + + const error = await page.evaluate(() => window.__e2e.lastError()); + expect(error).toBeNull(); + }); +}); diff --git a/tests/e2e/tables.spec.ts b/tests/e2e/tables.spec.ts new file mode 100644 index 0000000..9eb12c0 --- /dev/null +++ b/tests/e2e/tables.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from "@playwright/test"; +import { noteDoc, table } from "./fixtures/docs"; +import { clickInsideText, mount, openHarness, pressEndOfLine } from "./helpers"; + +const EDITOR = ".texto-editor"; + +test.describe("tables", () => { + test("typing in a cell edits its paragraph", async ({ page }) => { + await openHarness(page); + await mount(page, noteDoc(table([["A1", "B1"], ["A2", "B2"]]))); + + const cell = page.locator(`${EDITOR} td`, { hasText: "A2" }); + await clickInsideText(page, cell.locator("p"), "A2"); + await pressEndOfLine(page); + await page.keyboard.type("X"); + + await expect(cell.locator("p")).toHaveText("A2X"); + }); + + test("Tab moves the selection to the next cell", async ({ page }) => { + await openHarness(page); + await mount(page, noteDoc(table([["A1", "B1"], ["A2", "B2"]]))); + + await clickInsideText( + page, + page.locator(`${EDITOR} th`, { hasText: "A1" }).locator("p"), + "A1", + ); + await page.keyboard.press("Tab"); + // Tab selects the whole next cell, so typing replaces its content. + // (Locate the target cell by index: its old text "B1" is gone now.) + await page.keyboard.type("!"); + + const headers = page.locator(`${EDITOR} th`); + await expect(headers.nth(0).locator("p")).toHaveText("A1"); + await expect(headers.nth(1).locator("p")).toHaveText("!"); + }); +}); diff --git a/tests/e2e/tasks.spec.ts b/tests/e2e/tasks.spec.ts new file mode 100644 index 0000000..a0e1fd8 --- /dev/null +++ b/tests/e2e/tasks.spec.ts @@ -0,0 +1,74 @@ +import { expect, test } from "@playwright/test"; +import { noteDoc, p, taskList } from "./fixtures/docs"; +import { docJson, mount, nodesOfType, openHarness } from "./helpers"; + +const EDITOR = ".texto-editor"; + +test.describe("tasks and folding", () => { + test("clicking a task checkbox updates checked", async ({ page }) => { + await openHarness(page); + await mount( + page, + noteDoc(taskList([{ text: "todo one" }, { text: "todo two" }])), + ); + + // Task items render as custom-element hosts (no li in the live DOM); + // the checkbox lives inside the host's label. + await page + .locator(`${EDITOR} .texto-extension-task-item-host`, { + hasText: "todo one", + }) + .locator("label") + .click(); + + const items = nodesOfType(await docJson(page), "taskItem"); + expect(items).toHaveLength(2); + expect(items[0]?.attrs?.checked).toBe(true); + expect(items[1]?.attrs?.checked).toBe(false); + }); + + test("clicking the heading chevron folds its section", async ({ page }) => { + await openHarness(page); + await mount( + page, + noteDoc( + p("before heading"), + { type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "S1" }] }, + p("under heading"), + ), + ); + + const chevron = page.locator(".texto-heading-fold-chevron-host"); + await chevron.hover(); + await chevron.click(); + + await expect + .poll(() => page.evaluate(() => window.__e2e.headingFolds())) + .not.toEqual([]); + await expect( + page.locator(`${EDITOR} .ProseMirror > p`, { hasText: "under heading" }), + ).toHaveClass(/texto-folded-content/); + }); + + test("clicking a nested task chevron folds its subtasks", async ({ + page, + }) => { + await openHarness(page); + await mount( + page, + noteDoc( + taskList([ + { text: "todo parent", sub: [{ text: "sub 1" }, { text: "sub 2" }] }, + ]), + ), + ); + + const chevron = page.locator(".texto-task-fold-chevron").first(); + await chevron.hover(); + await chevron.click(); + + await expect + .poll(() => page.evaluate(() => window.__e2e.taskFolds())) + .not.toEqual([]); + }); +}); diff --git a/tests/is-pressed-command.test.ts b/tests/is-pressed-command.test.ts index 8bcbec5..177cc09 100644 --- a/tests/is-pressed-command.test.ts +++ b/tests/is-pressed-command.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import type { Plugin } from "prosemirror-state"; import { Platform } from "obsidian"; import { createPhysicalShortcutPlugin, @@ -273,6 +274,13 @@ describe("findCommandsCollidingWith", () => { }); describe("createPhysicalShortcutPlugin", () => { + /** The handleKeyDown prop is typed with `this: Plugin`, but prosemirror + * invokes it unbound and the handler only reads the event — bind `this` + * to the plugin to satisfy the signature. */ + function callHandleKeyDown(plugin: Plugin, event: KeyboardEvent) { + return plugin.props.handleKeyDown?.call(plugin, {} as never, event); + } + function makePlugin(handler: (event: KeyboardEvent) => boolean) { return createPhysicalShortcutPlugin({ getCommands: () => ["Mod-b"], @@ -284,9 +292,9 @@ describe("createPhysicalShortcutPlugin", () => { Platform.isMacOS = true; const handle = vi.fn(() => true); const plugin = makePlugin(handle); - const result = plugin.props.handleKeyDown?.( - {} as never, - evt({ key: "и", code: "KeyB", metaKey: true }) as KeyboardEvent, + const result = callHandleKeyDown( + plugin, + evt({ key: "и", code: "KeyB", metaKey: true }), ); expect(result).toBe(true); expect(handle).toHaveBeenCalled(); @@ -296,15 +304,12 @@ describe("createPhysicalShortcutPlugin", () => { Platform.isMacOS = true; const handle = vi.fn(() => true); const plugin = makePlugin(handle); - expect( - plugin.props.handleKeyDown?.( - {} as never, - evt({ key: "b", code: "KeyB" }), - ), - ).toBe(false); + expect(callHandleKeyDown(plugin, evt({ key: "b", code: "KeyB" }))).toBe( + false, + ); const forwarded = evt({ key: "k", code: "KeyK", metaKey: true }); markForwardedShortcut(forwarded); - expect(plugin.props.handleKeyDown?.({} as never, forwarded)).toBe(false); + expect(callHandleKeyDown(plugin, forwarded)).toBe(false); expect(handle).not.toHaveBeenCalled(); }); @@ -313,10 +318,7 @@ describe("createPhysicalShortcutPlugin", () => { const handle = vi.fn(() => true); const plugin = makePlugin(handle); expect( - plugin.props.handleKeyDown?.( - {} as never, - evt({ key: "c", code: "KeyC", metaKey: true }), - ), + callHandleKeyDown(plugin, evt({ key: "c", code: "KeyC", metaKey: true })), ).toBe(false); expect(handle).not.toHaveBeenCalled(); }); diff --git a/tsconfig.json b/tsconfig.json index d2e1756..e75ece3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ "noEmit": true, "esModuleInterop": true, "skipLibCheck": true, + "allowJs": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "isolatedModules": true, @@ -16,5 +17,5 @@ "obsidian": ["./node_modules/obsidian/obsidian.d.ts"] } }, - "include": ["src/**/*.ts", "src/**/*.tsx", "vite-env.d.ts"] + "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "vite-env.d.ts"] } diff --git a/vite.config.mts b/vite.config.mts index 1c95ec9..cb893f4 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -10,6 +10,10 @@ export default defineConfig(({ mode }) => ({ // Suffix for custom element tags (see src/tags.ts): unique per build so // plugin reloads/updates register fresh classes. Dev gets a per-build // hash so `vite build --watch` reloads also pick up changes fully. + // Production builds without an explicit INSCRIPTUM_BUILD_TAG get one + // too — otherwise a plugin toggle-off/on keeps the OLD classes in the + // window's CustomElementRegistry and `new ElementClass()` throws + // (Illegal constructor) for every image/attachment node. // ios-debug.mjs passes INSCRIPTUM_BUILD_TAG so a deploy can be verified // on-device (shown in the toolbar, logged on note open). "process.env.EDITOR_VERSION": JSON.stringify( @@ -17,7 +21,7 @@ export default defineConfig(({ mode }) => ({ ? `${version}-dev-${Date.now().toString(36)}` : process.env.INSCRIPTUM_BUILD_TAG ? `${version}-${process.env.INSCRIPTUM_BUILD_TAG}` - : version, + : `${version}-${Date.now().toString(36)}`, ), "process.env.EDITOR_BUILD_TAG": JSON.stringify( process.env.INSCRIPTUM_BUILD_TAG ?? "", diff --git a/vitest.config.mts b/vitest.config.mts index f06794e..cd7f7cd 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -18,5 +18,8 @@ export default defineConfig({ globals: true, environment: "jsdom", setupFiles: ["vitest.setup.ts"], + // Playwright specs in tests/e2e run through `npm run test:e2e` + // (real Chromium), not under vitest. + exclude: ["**/node_modules/**", "**/dist/**", "tests/e2e/**"], }, });