melhorias3
jump to
@@ -0,0 +1,18 @@
+{ + "version": "0.0.1", + "configurations": [ + { + "name": "backend", + "runtimeExecutable": "node", + "runtimeArgs": ["src/server.js"], + "cwd": "backend", + "port": 41738 + }, + { + "name": "frontend", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 41737 + } + ] +}
@@ -0,0 +1,71 @@
+name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + package-lock.json + backend/package-lock.json + + - name: Install frontend dependencies + run: npm ci + + - name: Install backend dependencies + run: npm ci + working-directory: backend + + - name: Backend unit tests + run: npm test + working-directory: backend + + - name: Frontend unit tests + run: npm test + + - name: Build + run: npm run build + + e2e: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + package-lock.json + backend/package-lock.json + + - name: Install frontend dependencies + run: npm ci + + - name: Install backend dependencies + run: npm ci + working-directory: backend + + - name: Install Playwright browsers + run: npx playwright install chromium --with-deps + + - name: Run e2e smoke tests + run: npm run test:e2e + + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: playwright-report/ + retention-days: 7
@@ -34,6 +34,9 @@ *.local
# Test / coverage coverage/ +test-results/ +playwright-report/ +backend/.e2e/ # Docker (optional local overrides) docker-compose.override.yml
@@ -1,8 +1,21 @@
# Snow Editor -Markdown and Org-mode editor with live preview. Local drafts in `localStorage`. Shared docs via link + SQLite backend. +Markdown and Org-mode editor with live preview. Multiple local drafts in `localStorage`. Shared docs via link + SQLite backend. Pablo Murad — pablomurad@pm.me + +## Highlights (0.0.2) + +- CodeMirror editor for both Markdown and Org (highlight, checklist click-toggle). +- Multiple local drafts — drafts menu in the toolbar (list, create, delete). Imports open as a new draft. +- Outline sidebar for Markdown and Org on wide screens. +- Dark mode (follows `prefers-color-scheme`). +- Write/Read tabs on mobile; draggable split divider on desktop (double-click resets). +- Syntax highlighting in preview code blocks (highlight.js, lazy-loaded). +- Editor → preview scroll sync; capped preview line width for readability. +- Print styles: Ctrl+P prints only the rendered document. +- Exported filenames derive from the document title. +- Server: version snapshots coalesce (5 min window); expired documents are purged hourly. ## Requirements@@ -45,9 +58,12 @@
## Tests ```bash -cd backend && npm test -npm test +cd backend && npm test # API unit tests +npm test # Org pipeline unit tests +npm run test:e2e # Playwright smoke (starts backend + Vite dev) ``` + +First e2e run needs `npx playwright install chromium`. CI (GitHub Actions) runs unit tests, build, and e2e on every push/PR. ## Config@@ -68,7 +84,9 @@ Share from `/`: pick title and expiry, get view + edit URLs.
`POST /api/documents` needs a browser `Origin` on the allowlist. No origin → 403. -Edit lock: one editor per doc, 2 min TTL, refreshed every 30s while tab is open. +Edit lock: one editor per doc, 2 min TTL, refreshed every 30s while tab is open. Released on `pagehide` (re-acquired when restored from bfcache). + +Versions: saving snapshots the previous content, coalesced to at most one snapshot per 5 minutes (restores always snapshot). Up to 50 versions per doc. Expired documents (and their locks/versions) are purged at boot and hourly. ## API@@ -91,7 +109,7 @@ Health returns `{ ok, db, uptime, version }`. DB down → 503.
## Stack -React, Vite, Express, SQLite (`node:sqlite`), marked, Orga, CodeMirror 6, DOMPurify. +React, Vite, Express, SQLite (`node:sqlite`), marked, Orga, CodeMirror 6, DOMPurify, highlight.js, Playwright. ## Org-mode@@ -117,6 +135,7 @@
## Notes - No accounts. Edit links are capability tokens — anyone with the link can edit when unlocked. +- Token leak mitigation: `Referrer-Policy: no-referrer` (nginx + meta) and `noindex` on `/v/` and `/e/` routes. - No realtime collab. - Preview HTML is sanitized. - Monitor production with `GET /api/health`.
@@ -1,7 +1,7 @@
{ "name": "snow-editor-backend", "private": true, - "version": "0.0.1", + "version": "0.0.2", "type": "module", "engines": { "node": ">=22.5.0"
@@ -69,6 +69,18 @@ const now = new Date().toISOString();
database.prepare('DELETE FROM edit_locks WHERE expires_at <= ?').run(now); } +// Expired documents only ever answered 410 before; the rows (and their +// versions) stayed in SQLite forever. Locks and versions cascade via FK. +export function purgeExpiredDocuments(database) { + const now = new Date().toISOString(); + const result = database + .prepare( + 'DELETE FROM documents WHERE expires_at IS NOT NULL AND expires_at <= ?', + ) + .run(now); + return result.changes; +} + export function checkDbHealth(database) { try { database.prepare('SELECT 1 AS ok').get();
@@ -1,3 +1,5 @@
+import { readFileSync } from 'fs'; + export const MSG = { NOT_FOUND: 'Document not found.', EXPIRED: 'This link has expired.',@@ -18,7 +20,11 @@ 'Document creation is only allowed from the Snow Editor website.',
VERSION_NOT_FOUND: 'Version not found.', }; -export const APP_VERSION = '0.0.1'; +const pkg = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +); + +export const APP_VERSION = pkg.version; export const MAX_VERSIONS_PER_DOCUMENT = 50; export const DEFAULT_DOCUMENT_TITLE = 'Untitled document';
@@ -7,7 +7,7 @@ DEFAULT_DOCUMENT_TITLE,
MSG, } from '../messages.js'; import { requireAllowedOrigin } from '../originGuard.js'; -import { saveDocumentVersion } from '../versionUtils.js'; +import { maybeSaveDocumentVersion, saveDocumentVersion } from '../versionUtils.js'; import { assertContentSize, assertMode,@@ -385,7 +385,7 @@ typeof title === 'string' && title.trim() ? title.trim() : doc.title;
const now = new Date().toISOString(); const db = getDb(); - saveDocumentVersion(db, doc, now); + maybeSaveDocumentVersion(db, doc, now); db.prepare( `UPDATE documents SET title = ?, mode = ?, content = ?, updated_at = ? WHERE id = ?`,
@@ -1,4 +1,4 @@
-import { initDb } from './db.js'; +import { getDb, initDb, purgeExpiredDocuments } from './db.js'; import { createApp } from './app.js'; const PORT = Number(process.env.PORT) || 41738;@@ -6,7 +6,23 @@ const DATABASE_PATH =
process.env.DATABASE_PATH || (process.env.NODE_ENV === 'production' ? '/app/data/snow.db' : './data/snow.db'); +const PURGE_INTERVAL_MS = 60 * 60 * 1000; + initDb(DATABASE_PATH); + +function sweepExpiredDocuments() { + try { + const removed = purgeExpiredDocuments(getDb()); + if (removed > 0) { + console.log(`[purge] removed ${removed} expired document(s)`); + } + } catch (err) { + console.error('[purge] failed to remove expired documents', err); + } +} + +sweepExpiredDocuments(); +setInterval(sweepExpiredDocuments, PURGE_INTERVAL_MS).unref(); const app = createApp();
@@ -1,6 +1,10 @@
import { MAX_VERSIONS_PER_DOCUMENT } from './messages.js'; import { newId } from './utils.js'; +// Autosave fires every second while typing; without coalescing the version +// history fills up with near-identical snapshots within minutes. +export const VERSION_COALESCE_MS = 5 * 60 * 1000; + export function saveDocumentVersion(db, doc, createdAt) { const versionId = newId(); db.prepare(@@ -24,3 +28,26 @@ ...excess.map((row) => row.id),
); } } + +// Snapshot only when the latest version is older than the coalescing window +// (or when there is no version yet). Returns true when a snapshot was taken. +export function maybeSaveDocumentVersion(db, doc, createdAt) { + const latest = db + .prepare( + `SELECT created_at FROM document_versions + WHERE document_id = ? + ORDER BY created_at DESC + LIMIT 1`, + ) + .get(doc.id); + + if (latest) { + const age = Date.parse(createdAt) - Date.parse(latest.created_at); + if (age < VERSION_COALESCE_MS) { + return false; + } + } + + saveDocumentVersion(db, doc, createdAt); + return true; +}
@@ -1,7 +1,8 @@
import assert from 'node:assert'; import { after, before, describe, test } from 'node:test'; import { createApp } from '../src/app.js'; -import { getDb, initDb } from '../src/db.js'; +import { getDb, initDb, purgeExpiredDocuments } from '../src/db.js'; +import { APP_VERSION } from '../src/messages.js'; const ALLOWED_ORIGIN = 'http://localhost:41737'; let server;@@ -62,7 +63,8 @@ assert.equal(res.status, 200);
assert.equal(data.ok, true); assert.equal(data.db, 'ok'); assert.equal(typeof data.uptime, 'number'); - assert.equal(data.version, '0.0.1'); + assert.equal(data.version, APP_VERSION); + assert.match(data.version, /^\d+\.\d+\.\d+$/); }); test('POST /documents without Origin is rejected', async () => {@@ -225,5 +227,71 @@ const restored = await readJson(restoreRes);
assert.equal(restoreRes.status, 200); assert.equal(restored.content, 'v1'); + }); + + test('rapid consecutive PUTs coalesce into a single version', async () => { + const doc = await createDocument({ content: 'first' }); + const lockRes = await api(`/api/documents/edit/${doc.editToken}/lock`, { + method: 'POST', + body: { clientId: 'coalesce-client' }, + }); + const lock = await readJson(lockRes); + + for (const content of ['second', 'third', 'fourth']) { + const res = await api(`/api/documents/edit/${doc.editToken}`, { + method: 'PUT', + body: { + clientId: 'coalesce-client', + lockToken: lock.lockToken, + title: doc.title, + mode: 'markdown', + content, + }, + }); + assert.equal(res.status, 200); + } + + const count = getDb() + .prepare( + 'SELECT COUNT(*) AS n FROM document_versions WHERE document_id = ?', + ) + .get(doc.id); + + assert.equal(count.n, 1); + }); + + test('purgeExpiredDocuments removes expired rows and cascades', () => { + // Inserted directly to avoid the 10 req/min create limiter shared by tests. + const db = getDb(); + const docId = 'purge-doc'; + const past = new Date(Date.now() - 60_000).toISOString(); + db.prepare( + `INSERT INTO documents (id, title, mode, content, view_token, edit_token, expires_at, created_at, updated_at) + VALUES (?, 'Purge me', 'markdown', 'bye', 'purge-view', 'purge-edit', ?, ?, ?)`, + ).run(docId, past, past, past); + db.prepare( + `INSERT INTO edit_locks (id, document_id, lock_token, client_id, expires_at, created_at, updated_at) + VALUES ('purge-lock', ?, 'purge-lock-token', 'purge-client', ?, ?, ?)`, + ).run(docId, past, past, past); + db.prepare( + `INSERT INTO document_versions (id, document_id, title, mode, content, created_at) + VALUES ('purge-version', ?, 'Purge me', 'markdown', 'v0', ?)`, + ).run(docId, past); + + const removed = purgeExpiredDocuments(db); + assert.ok(removed >= 1); + + const row = db.prepare('SELECT id FROM documents WHERE id = ?').get(docId); + assert.equal(row, undefined); + + const locks = db + .prepare('SELECT COUNT(*) AS n FROM edit_locks WHERE document_id = ?') + .get(docId); + assert.equal(locks.n, 0); + + const versions = db + .prepare('SELECT COUNT(*) AS n FROM document_versions WHERE document_id = ?') + .get(docId); + assert.equal(versions.n, 0); }); });
@@ -0,0 +1,97 @@
+import { expect, test } from '@playwright/test'; + +async function replaceEditorContent(page, text) { + const editor = page.locator('.cm-content'); + await editor.click(); + await page.keyboard.press('ControlOrMeta+a'); + await page.keyboard.press('Delete'); + await page.keyboard.type(text); +} + +test.describe('local editor', () => { + test('renders markdown preview while typing', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('heading', { name: 'Snow Editor' })).toBeVisible(); + + await replaceEditorContent(page, '# E2E Title\n\nHello from Playwright.'); + + const preview = page.locator('.preview-content'); + await expect(preview.getByRole('heading', { name: 'E2E Title' })).toBeVisible(); + await expect(preview.getByText('Hello from Playwright.')).toBeVisible(); + }); + + test('switches to Org-mode and renders org preview', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Org-mode' }).click(); + await replaceEditorContent(page, '* Org Heading\nBody text here.'); + + const preview = page.locator('.preview-content'); + await expect(preview.getByText('Org Heading')).toBeVisible(); + }); + + test('drafts menu creates and switches drafts', async ({ page }) => { + await page.goto('/'); + await replaceEditorContent(page, '# First draft'); + + await page.getByRole('button', { name: 'Drafts' }).click(); + await page.getByRole('menuitem', { name: /New draft/ }).click(); + + // New draft starts empty → preview shows the placeholder. + await expect(page.locator('.preview-empty')).toBeVisible(); + + await page.getByRole('button', { name: 'Drafts' }).click(); + await page.getByRole('menuitem', { name: /First draft/ }).click(); + await expect( + page.locator('.preview-content').getByRole('heading', { name: 'First draft' }), + ).toBeVisible(); + }); +}); + +test.describe('share flow', () => { + test('share → view → edit → save round-trip', async ({ page }) => { + await page.goto('/'); + await replaceEditorContent(page, '# Shared doc\n\nOriginal body.'); + + await page.getByRole('button', { name: 'Share', exact: true }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('button', { name: 'Create links' }).click(); + + // Links may be absolute (VITE_PUBLIC_ORIGIN); keep only the path so the + // test always talks to the local dev server. + const viewUrl = new URL( + await dialog.locator('label', { hasText: 'View link' }).locator('input').inputValue(), + 'http://localhost:41737', + ).pathname; + const editUrl = new URL( + await dialog.locator('label', { hasText: 'Edit link' }).locator('input').inputValue(), + 'http://localhost:41737', + ).pathname; + expect(viewUrl).toContain('/v/'); + expect(editUrl).toContain('/e/'); + + // Read-only view. + await page.goto(viewUrl); + await expect(page.getByText('Read-only')).toBeVisible(); + await expect( + page.locator('.preview-content').getByText('Original body.'), + ).toBeVisible(); + + // Edit with lock. + await page.goto(editUrl); + await expect(page.getByText('Editing')).toBeVisible(); + + const editor = page.locator('.cm-content'); + await editor.click(); + await page.keyboard.press('ControlOrMeta+End'); + await page.keyboard.type('\n\nAdded by e2e.'); + await page.getByRole('button', { name: 'Save to server' }).click(); + await expect(page.getByText('Saved')).toBeVisible(); + + // The view link shows the updated content. + await page.goto(viewUrl); + await expect( + page.locator('.preview-content').getByText('Added by e2e.'), + ).toBeVisible(); + }); +});
@@ -3,9 +3,12 @@ <html lang="en">
<head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <!-- Share links carry capability tokens in the URL; never leak them via Referer. --> + <meta name="referrer" content="no-referrer" /> <meta name="description" content="Cozy Markdown and Org-mode editor with live preview" /> <meta name="author" content="Pablo Murad" /> - <meta name="theme-color" content="#fefefe" /> + <meta name="theme-color" content="#fefefe" media="(prefers-color-scheme: light)" /> + <meta name="theme-color" content="#14181e" media="(prefers-color-scheme: dark)" /> <title>Snow Editor</title> <link rel="icon" type="image/png" href="/favicon.png" sizes="72x72" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />@@ -13,7 +16,7 @@ <link rel="apple-touch-icon" href="/favicon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link - href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&family=JetBrains+Mono:wght@400;500&display=swap" + href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;1,400;1,500&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" /> </head>
@@ -8,22 +8,29 @@ gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml; gzip_min_length 256; + # Share links carry capability tokens in the URL; never leak them via Referer. + # Repeated inside locations that use add_header (nginx drops inherited headers there). + add_header Referrer-Policy "no-referrer" always; + location /api/ { proxy_pass http://backend:41738; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; add_header X-Robots-Tag "noindex" always; + add_header Referrer-Policy "no-referrer" always; } location = /index.html { add_header Cache-Control "no-cache"; + add_header Referrer-Policy "no-referrer" always; try_files $uri =404; } location /assets/ { expires 1y; add_header Cache-Control "public, immutable"; + add_header Referrer-Policy "no-referrer" always; try_files $uri =404; }
@@ -1,21 +1,24 @@
{ "name": "snow-editor", - "version": "0.0.1", + "version": "0.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "snow-editor", - "version": "0.0.1", + "version": "0.0.2", "dependencies": { "@codemirror/commands": "^6.10.3", + "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language": "^6.12.3", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.43.0", + "@lezer/highlight": "^1.2.3", "@orgajs/cm-lang": "^1.3.0", "@orgajs/reorg-parse": "^4.4.1", "@orgajs/reorg-rehype": "^4.3.11", "dompurify": "^3.2.4", + "highlight.js": "^11.11.1", "marked": "^15.0.7", "orga": "^4.7.1", "react": "^19.0.0",@@ -25,6 +28,7 @@ "rehype-stringify": "^10.0.1",
"unified": "^11.0.5" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@vitejs/plugin-react": "^4.3.4", "jsdom": "^29.1.1", "vite": "^6.2.0"@@ -385,6 +389,18 @@ "bin": {
"specificity": "bin/cli.js" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, "node_modules/@codemirror/commands": { "version": "6.10.3", "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",@@ -397,6 +413,66 @@ "@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0" } }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz", + "integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" + } + }, "node_modules/@codemirror/language": { "version": "6.12.3", "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",@@ -409,6 +485,17 @@ "@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" } }, "node_modules/@codemirror/state": {@@ -1088,6 +1175,17 @@ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", "license": "MIT" }, + "node_modules/@lezer/css": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.4.tgz", + "integrity": "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, "node_modules/@lezer/highlight": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",@@ -1097,6 +1195,28 @@ "dependencies": {
"@lezer/common": "^1.3.0" } }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, "node_modules/@lezer/lr": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",@@ -1106,6 +1226,16 @@ "dependencies": {
"@lezer/common": "^1.0.0" } }, + "node_modules/@lezer/markdown": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.4.tgz", + "integrity": "sha512-N0SxazMj4k65DBfaf1azqtMZd6u7MqluP84/NZnB/io8Td9aleFmAhz9hcbvSfsxT5tdYlJ5qgv5aMJGY4zEtA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0" + } + }, "node_modules/@marijn/find-cluster-break": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",@@ -1150,6 +1280,22 @@ "integrity": "sha512-YO1GyjRq3YqOj1W7wKy9crrVRYYZX+h1hLo43KAjGUcPmxJzXTg/zpucANUXzk0oc/TilRY8WHul5yJ5Oz4YoQ==",
"license": "MIT", "dependencies": { "oast-to-hast": "4.5.3" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" } }, "node_modules/@rolldown/pluginutils": {@@ -2145,6 +2291,15 @@ "type": "opencollective",
"url": "https://opencollective.com/unified" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",@@ -2538,6 +2693,53 @@ "node": ">=12"
}, "funding": { "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, "node_modules/postcss": {
@@ -1,23 +1,27 @@
{ "name": "snow-editor", "private": true, - "version": "0.0.1", + "version": "0.0.2", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0 --port 41737", "build": "vite build", "preview": "vite preview --config vite.preview.config.js --host 0.0.0.0 --port 41737", - "test": "node --test src/lib/org/**/*.test.js" + "test": "node --test src/lib/org/**/*.test.js", + "test:e2e": "playwright test" }, "dependencies": { "@codemirror/commands": "^6.10.3", + "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language": "^6.12.3", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.43.0", + "@lezer/highlight": "^1.2.3", "@orgajs/cm-lang": "^1.3.0", "@orgajs/reorg-parse": "^4.4.1", "@orgajs/reorg-rehype": "^4.3.11", "dompurify": "^3.2.4", + "highlight.js": "^11.11.1", "marked": "^15.0.7", "orga": "^4.7.1", "react": "^19.0.0",@@ -27,6 +31,7 @@ "rehype-stringify": "^10.0.1",
"unified": "^11.0.5" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@vitejs/plugin-react": "^4.3.4", "jsdom": "^29.1.1", "vite": "^6.2.0"
@@ -0,0 +1,35 @@
+import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + timeout: 60_000, + fullyParallel: false, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL: 'http://localhost:41737', + trace: 'on-first-retry', + }, + webServer: [ + { + name: 'backend', + command: 'node src/server.js', + cwd: './backend', + url: 'http://localhost:41738/api/health', + reuseExistingServer: !process.env.CI, + timeout: 30_000, + env: { + DATABASE_PATH: './.e2e/snow-e2e.db', + SHARE_ALLOWED_ORIGINS: + 'http://localhost:41737,http://127.0.0.1:41737', + }, + }, + { + name: 'frontend', + command: 'npm run dev', + url: 'http://localhost:41737', + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, + ], +});
@@ -0,0 +1,122 @@
+import { useCallback, useEffect, useRef, useState } from 'react'; +import { STR, formatVersionDate } from '../lib/strings.js'; +import IconButton from './IconButton.jsx'; +import { DraftsIcon } from './icons/index.js'; + +export default function DraftsMenu({ + currentDraftId, + onListDrafts, + onSelectDraft, + onCreateDraft, + onDeleteDraft, +}) { + const [open, setOpen] = useState(false); + const [drafts, setDrafts] = useState([]); + const rootRef = useRef(null); + + const refresh = useCallback(() => { + setDrafts(onListDrafts()); + }, [onListDrafts]); + + const toggle = useCallback(() => { + setOpen((was) => { + if (!was) refresh(); + return !was; + }); + }, [refresh]); + + useEffect(() => { + if (!open) return undefined; + + const onPointerDown = (event) => { + if (rootRef.current && !rootRef.current.contains(event.target)) { + setOpen(false); + } + }; + const onKeyDown = (event) => { + if (event.key === 'Escape') setOpen(false); + }; + + document.addEventListener('pointerdown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('pointerdown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [open]); + + const handleSelect = (id) => { + setOpen(false); + if (id !== currentDraftId) onSelectDraft(id); + }; + + const handleCreate = () => { + setOpen(false); + onCreateDraft(); + }; + + const handleDelete = (event, draft) => { + event.stopPropagation(); + const confirmed = window.confirm( + STR.DELETE_DRAFT_CONFIRM(draft.title || STR.UNTITLED_DOCUMENT), + ); + if (!confirmed) return; + onDeleteDraft(draft.id); + refresh(); + }; + + return ( + <div className="drafts-menu" ref={rootRef}> + <IconButton + icon={<DraftsIcon />} + label={STR.DRAFTS} + aria-expanded={open} + aria-haspopup="menu" + onClick={toggle} + /> + {open && ( + <div className="drafts-menu__popover" role="menu" aria-label={STR.DRAFTS}> + <button + type="button" + className="drafts-menu__new" + role="menuitem" + onClick={handleCreate} + > + + {STR.NEW_DRAFT} + </button> + <ul className="drafts-menu__list"> + {drafts.map((draft) => ( + <li key={draft.id} className="drafts-menu__item"> + <button + type="button" + role="menuitem" + className={`drafts-menu__entry${draft.id === currentDraftId ? ' is-current' : ''}`} + onClick={() => handleSelect(draft.id)} + > + <span className="drafts-menu__entry-title"> + {draft.title || STR.UNTITLED_DOCUMENT} + </span> + <span className="drafts-menu__entry-meta"> + {draft.mode === 'org' ? 'Org' : 'Markdown'} ·{' '} + {formatVersionDate(draft.updatedAt)} + </span> + </button> + {drafts.length > 1 && ( + <button + type="button" + className="drafts-menu__delete" + aria-label={`${STR.DELETE_DRAFT}: ${draft.title || STR.UNTITLED_DOCUMENT}`} + title={STR.DELETE_DRAFT} + onClick={(event) => handleDelete(event, draft)} + > + × + </button> + )} + </li> + ))} + </ul> + </div> + )} + </div> + ); +}
@@ -1,23 +1,63 @@
-import { lazy, Suspense, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'; +import { + lazy, + Suspense, + useCallback, + useDeferredValue, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { MODES } from '../lib/editorConstants.js'; +import { parseMarkdownHeadings } from '../lib/markdownOutline.js'; import { parseOrgDocument } from '../lib/org/parseDocument.js'; import { buildPreviewHtml, ensureMarkedLoaded, ensureOrgLoaded } from '../lib/previewHtml.js'; -import OrgOutline from './OrgOutline.jsx'; +import { STR } from '../lib/strings.js'; +import DocumentOutline from './DocumentOutline.jsx'; + +const CodeEditor = lazy(() => import('./CodeEditor.jsx')); + +const SPLIT_STORAGE_KEY = 'snow_editor_split'; +const SPLIT_MIN = 0.25; +const SPLIT_MAX = 0.75; + +function clampSplit(value) { + if (!Number.isFinite(value)) return 0.5; + return Math.min(SPLIT_MAX, Math.max(SPLIT_MIN, value)); +} + +function loadSplit() { + try { + const saved = window.localStorage.getItem(SPLIT_STORAGE_KEY); + if (saved !== null) return clampSplit(Number.parseFloat(saved)); + } catch { + /* ignore */ + } + return 0.5; +} -const OrgEditor = lazy(() => import('./OrgEditor.jsx')); +function persistSplit(value) { + try { + window.localStorage.setItem(SPLIT_STORAGE_KEY, String(value)); + } catch { + /* ignore */ + } +} -function useDividerOrientation() { - const [orientation, setOrientation] = useState('vertical'); +function useIsMobile() { + const [mobile, setMobile] = useState(() => + typeof window !== 'undefined' ? window.matchMedia('(max-width: 768px)').matches : false, + ); useEffect(() => { const media = window.matchMedia('(max-width: 768px)'); - const update = () => setOrientation(media.matches ? 'horizontal' : 'vertical'); + const update = () => setMobile(media.matches); update(); media.addEventListener('change', update); return () => media.removeEventListener('change', update); }, []); - return orientation; + return mobile; } function useWideLayout() {@@ -42,6 +82,15 @@ if (!trimmed) return 0;
return trimmed.split(/\s+/).filter(Boolean).length; } +let hljsPromise = null; +function ensureHighlightLoaded() { + if (!hljsPromise) { + // The "common" build ships the ~40 popular languages only. + hljsPromise = import('highlight.js/lib/common').then((m) => m.default); + } + return hljsPromise; +} + export default function EditorLayout({ mode, content,@@ -53,8 +102,14 @@ previewOnly = false,
}) { const [markedReady, setMarkedReady] = useState(false); const [orgReady, setOrgReady] = useState(false); + const [activeTab, setActiveTab] = useState('write'); + const [split, setSplit] = useState(loadSplit); + const [dragging, setDragging] = useState(false); const scrollToLineRef = useRef(null); - const dividerOrientation = useDividerOrientation(); + const previewScrollRef = useRef(null); + const previewContentRef = useRef(null); + const editorPanelRef = useRef(null); + const isMobile = useIsMobile(); const wideLayout = useWideLayout(); const deferredContent = useDeferredValue(content); const previewIsStale = content !== deferredContent;@@ -86,10 +141,35 @@ if (mode !== MODES.ORG) return null;
return parseOrgDocument(deferredContent); }, [mode, deferredContent]); + const headings = useMemo(() => { + if (mode === MODES.ORG) return orgMeta?.headings ?? []; + return parseMarkdownHeadings(deferredContent); + }, [mode, orgMeta, deferredContent]); + const html = useMemo(() => { return buildPreviewHtml(mode, deferredContent); }, [mode, deferredContent, markedReady, orgReady]); + // Lazy syntax highlighting for fenced/SRC code blocks in the preview. + useEffect(() => { + const container = previewContentRef.current; + if (!container || !html || !html.includes('<pre')) return undefined; + + let cancelled = false; + ensureHighlightLoaded().then((hljs) => { + if (cancelled || !previewContentRef.current) return; + const blocks = previewContentRef.current.querySelectorAll('pre code, pre.org-src'); + blocks.forEach((el) => { + if (el.tagName === 'PRE' && el.querySelector('code')) return; + if (el.dataset.highlighted === 'yes') return; + hljs.highlightElement(el); + }); + }); + return () => { + cancelled = true; + }; + }, [html]); + const handleRegisterScroll = useCallback((scrollFn) => { scrollToLineRef.current = scrollFn; }, []);@@ -98,77 +178,178 @@ const handleOutlineSelect = useCallback((line) => {
scrollToLineRef.current?.(line); }, []); + // Editor → preview proportional scroll sync. + const handleEditorScrollRatio = useCallback((ratio) => { + const preview = previewScrollRef.current; + if (!preview) return; + const max = preview.scrollHeight - preview.clientHeight; + if (max > 0) { + preview.scrollTop = ratio * max; + } + }, []); + + // Draggable splitter (desktop only). + const handleDividerPointerDown = useCallback( + (event) => { + if (isMobile) return; + event.preventDefault(); + setDragging(true); + + const editorPanel = editorPanelRef.current; + const previewPanel = previewScrollRef.current?.closest('.panel-preview'); + if (!editorPanel || !previewPanel) return; + + const onMove = (moveEvent) => { + const left = editorPanel.getBoundingClientRect().left; + const right = previewPanel.getBoundingClientRect().right; + if (right - left <= 0) return; + setSplit(clampSplit((moveEvent.clientX - left) / (right - left))); + }; + + const onUp = () => { + setDragging(false); + setSplit((value) => { + persistSplit(value); + return value; + }); + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + }; + + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + }, + [isMobile], + ); + + const handleDividerReset = useCallback(() => { + setSplit(0.5); + persistSplit(0.5); + }, []); + const wordCount = useMemo(() => countWords(content), [content]); const charCount = content.length; const editorAriaLabel = mode === MODES.ORG ? 'Org-mode editing area' : 'Markdown editing area'; + const splitLayout = showEditor && !previewOnly; + const useTabs = isMobile && splitLayout; + const showWrite = splitLayout && (!useTabs || activeTab === 'write'); + const showRead = previewOnly || !useTabs || activeTab === 'read'; + const showOutline = - mode === MODES.ORG && showEditor && !previewOnly && wideLayout && orgMeta?.headings?.length > 0; + splitLayout && !useTabs && wideLayout && headings.length > 0; + + const rendererReady = mode === MODES.ORG ? orgReady : markedReady; + const contentEmpty = !deferredContent || !deferredContent.trim(); + + const gridStyle = + splitLayout && !useTabs + ? { + gridTemplateColumns: showOutline + ? `10.5rem ${split}fr 2rem ${1 - split}fr` + : `${split}fr 2rem ${1 - split}fr`, + } + : undefined; return ( <> + {useTabs && ( + <div className="mobile-tabs" role="tablist" aria-label="Editor view"> + <button + type="button" + role="tab" + aria-selected={activeTab === 'write'} + className={`mobile-tabs__btn${activeTab === 'write' ? ' is-active' : ''}`} + onClick={() => setActiveTab('write')} + > + {STR.TAB_WRITE} + </button> + <button + type="button" + role="tab" + aria-selected={activeTab === 'read'} + className={`mobile-tabs__btn${activeTab === 'read' ? ' is-active' : ''}`} + onClick={() => setActiveTab('read')} + > + {STR.TAB_READ} + </button> + </div> + )} + <main - className={`app-layout${previewOnly ? ' app-layout--preview-only' : ''}${showOutline ? ' app-layout--with-outline' : ''}`} + className={`app-layout${previewOnly ? ' app-layout--preview-only' : ''}${showOutline ? ' app-layout--with-outline' : ''}${useTabs ? ' app-layout--tabs' : ''}${dragging ? ' app-layout--dragging' : ''}`} + style={gridStyle} > {showOutline && ( - <OrgOutline content={content} onSelectHeading={handleOutlineSelect} /> + <DocumentOutline headings={headings} onSelectHeading={handleOutlineSelect} /> )} - {showEditor && !previewOnly && ( + {showWrite && ( <> <section + ref={editorPanelRef} className="panel panel-editor" aria-label={mode === MODES.ORG ? 'Org-mode editor' : 'Markdown editor'} > - <div className="panel-label">Write</div> - {mode === MODES.ORG ? ( - <Suspense fallback={<div className="editor editor--loading">Loading Org editor…</div>}> - <OrgEditor - value={content} - onChange={onContentChange} - readOnly={readOnly} - editorRef={editorRef} - ariaLabel={editorAriaLabel} - onRegisterScroll={handleRegisterScroll} - /> - </Suspense> - ) : ( - <textarea - ref={editorRef} - className="editor" + <div className="panel-label">{STR.TAB_WRITE}</div> + <Suspense + fallback={<div className="editor editor--loading">Loading editor…</div>} + > + <CodeEditor + mode={mode} value={content} - onChange={onContentChange ? (e) => onContentChange(e.target.value) : undefined} + onChange={onContentChange} readOnly={readOnly} - spellCheck="true" - aria-label={editorAriaLabel} - placeholder="Start writing..." + editorRef={editorRef} + ariaLabel={editorAriaLabel} + placeholderText="Start writing..." + onRegisterScroll={handleRegisterScroll} + onScrollRatio={handleEditorScrollRatio} /> - )} + </Suspense> </section> - <div - className="layout-divider" - role="separator" - aria-orientation={dividerOrientation} - /> + {!useTabs && ( + <div + className="layout-divider" + role="separator" + aria-orientation="vertical" + aria-label={STR.RESIZE_PANELS} + title={STR.RESIZE_PANELS} + onPointerDown={handleDividerPointerDown} + onDoubleClick={handleDividerReset} + /> + )} </> )} - <section - className={`panel panel-preview${previewOnly ? ' panel-preview--full' : ''}`} - aria-label="Document preview" - > - <div className="panel-label">Read</div> - {orgMeta?.title && ( - <p className="org-doc-title">{orgMeta.title}</p> - )} - <div - className={`preview-paper${previewIsStale ? ' preview-updating' : ''}`} - dangerouslySetInnerHTML={{ __html: html }} - /> - </section> + {showRead && ( + <section + className={`panel panel-preview${previewOnly ? ' panel-preview--full' : ''}`} + aria-label="Document preview" + > + <div className="panel-label">{STR.TAB_READ}</div> + {orgMeta?.title && <p className="org-doc-title">{orgMeta.title}</p>} + <div + ref={previewScrollRef} + className={`preview-paper${previewIsStale ? ' preview-updating' : ''}`} + > + {contentEmpty ? ( + <p className="preview-empty">{STR.PREVIEW_EMPTY}</p> + ) : !rendererReady && !html ? ( + <p className="preview-empty">{STR.PREVIEW_RENDERING}</p> + ) : ( + <div + ref={previewContentRef} + className="preview-content" + dangerouslySetInnerHTML={{ __html: html }} + /> + )} + </div> + </section> + )} </main> <div className="app-footer-stats app-footer-stats--inline">
@@ -5,30 +5,46 @@ EditorView,
highlightActiveLine, keymap, lineNumbers, + placeholder, } from '@codemirror/view'; import { defaultKeymap, indentWithTab } from '@codemirror/commands'; +import { markdown } from '@codemirror/lang-markdown'; import { org } from '@orgajs/cm-lang'; import { useEffect, useRef } from 'react'; +import { MODES } from '../lib/editorConstants.js'; +import { markdownHighlight } from '../lib/markdownHighlight.js'; import { checklistPlugin } from '../lib/org/checklistPlugin.js'; import { orgKeymap } from '../lib/org/keymap.js'; import { orgTheme } from '../lib/org/orgTheme.js'; -export default function OrgEditor({ +function languageExtensions(mode) { + if (mode === MODES.ORG) { + return [org(), orgKeymap]; + } + return [markdown(), markdownHighlight]; +} + +export default function CodeEditor({ + mode = MODES.MARKDOWN, value, onChange, readOnly = false, editorRef, ariaLabel, + placeholderText, onRegisterScroll, + onScrollRatio, }) { const containerRef = useRef(null); const viewRef = useRef(null); const readOnlyRef = useRef(readOnly); const onChangeRef = useRef(onChange); + const onScrollRatioRef = useRef(onScrollRatio); const editableCompartment = useRef(new Compartment()); readOnlyRef.current = readOnly; onChangeRef.current = onChange; + onScrollRatioRef.current = onScrollRatio; useEffect(() => { if (!containerRef.current) return undefined;@@ -42,7 +58,7 @@
const state = EditorState.create({ doc: value, extensions: [ - org(), + ...languageExtensions(mode), orgTheme, lineNumbers(), highlightActiveLine(),@@ -52,7 +68,7 @@ EditorState.readOnly.of(readOnlyRef.current),
EditorView.contentAttributes.of({ 'aria-label': ariaLabel }), updateListener, checklistPlugin(() => readOnlyRef.current), - orgKeymap, + placeholderText ? placeholder(placeholderText) : [], keymap.of([...defaultKeymap, indentWithTab]), ], });@@ -78,12 +94,29 @@ });
view.focus(); }); + // Proportional scroll position for editor → preview sync. + let scrollFrame = null; + const scroller = view.scrollDOM; + const handleScroll = () => { + if (!onScrollRatioRef.current || scrollFrame != null) return; + scrollFrame = window.requestAnimationFrame(() => { + scrollFrame = null; + const max = scroller.scrollHeight - scroller.clientHeight; + if (max > 0) { + onScrollRatioRef.current(scroller.scrollTop / max); + } + }); + }; + scroller.addEventListener('scroll', handleScroll, { passive: true }); + return () => { + scroller.removeEventListener('scroll', handleScroll); + if (scrollFrame != null) window.cancelAnimationFrame(scrollFrame); view.destroy(); viewRef.current = null; if (editorRef) editorRef.current = null; }; - }, [ariaLabel, editorRef, onRegisterScroll]); + }, [mode, ariaLabel, editorRef, onRegisterScroll, placeholderText]); useEffect(() => { const view = viewRef.current;@@ -108,5 +141,5 @@ ),
}); }, [readOnly]); - return <div ref={containerRef} className="org-editor cm-host" />; + return <div ref={containerRef} className="code-editor cm-host" />; }
@@ -1,11 +1,8 @@
-import { useMemo } from 'react'; -import { parseOrgDocument } from '../lib/org/parseDocument.js'; import { STR } from '../lib/strings.js'; -export default function OrgOutline({ content, onSelectHeading, collapsed = false }) { - const { headings } = useMemo(() => parseOrgDocument(content), [content]); - - if (collapsed || headings.length === 0) { +// Outline shared by Markdown and Org — receives pre-parsed headings. +export default function DocumentOutline({ headings, onSelectHeading }) { + if (!headings || headings.length === 0) { return null; }
@@ -0,0 +1,12 @@
+import { ICON_PROPS } from './iconProps.js'; + +export default function DraftsIcon({ className }) { + return ( + <svg {...ICON_PROPS} className={className}> + <path d="M6 3.75h8.5L19 8.25v12H6z" /> + <path d="M14.5 3.75v4.5H19" /> + <path d="M9 12.5h7" /> + <path d="M9 16h7" /> + </svg> + ); +}
@@ -2,3 +2,4 @@ export { default as ShareIcon } from './ShareIcon.jsx';
export { default as DownloadIcon } from './DownloadIcon.jsx'; export { default as UploadIcon } from './UploadIcon.jsx'; export { default as ClearIcon } from './ClearIcon.jsx'; +export { default as DraftsIcon } from './DraftsIcon.jsx';
@@ -110,7 +110,9 @@
useEffect(() => { if (!enabled || !editToken) return; - const onUnload = () => { + // pagehide is far more reliable than beforeunload (which mobile browsers + // often skip and which disables the back/forward cache). + const onPageHide = () => { if (!lockTokenRef.current) return; const body = JSON.stringify({ clientId: clientIdRef.current,@@ -123,11 +125,24 @@ body,
keepalive: true, headers: { 'Content-Type': 'application/json' }, }).catch(() => {}); + lockTokenRef.current = null; }; - window.addEventListener('beforeunload', onUnload); - return () => window.removeEventListener('beforeunload', onUnload); - }, [enabled, editToken]); + // Restored from the back/forward cache: the lock was released on the way + // out, so grab it again to keep editing seamless. + const onPageShow = (event) => { + if (event.persisted && !lockTokenRef.current) { + acquire().catch(() => {}); + } + }; + + window.addEventListener('pagehide', onPageHide); + window.addEventListener('pageshow', onPageShow); + return () => { + window.removeEventListener('pagehide', onPageHide); + window.removeEventListener('pageshow', onPageShow); + }; + }, [enabled, editToken, acquire]); return { clientId: clientIdRef.current,
@@ -0,0 +1,18 @@
+import { useEffect } from 'react'; + +// Shared-document routes must never be indexed even when the site itself +// allows indexing (the URL is the capability token). +export function useNoIndex() { + useEffect(() => { + const existing = document.querySelector('meta[name="robots"]'); + if (existing) return undefined; // site-wide noindex already active + + const meta = document.createElement('meta'); + meta.name = 'robots'; + meta.content = 'noindex, nofollow'; + document.head.appendChild(meta); + return () => { + meta.remove(); + }; + }, []); +}
@@ -52,7 +52,10 @@ }, [enabled, editToken, clientId, lockToken, title, mode, content]);
useEffect(() => { if (!enabled) { - setSaveStatus('no_permission'); + // Not being allowed to save *yet* (lock still being acquired, or viewer + // without a lock) is not a save failure — keep the status quiet. + // 'no_permission' is reserved for saves actually refused by the server. + setSaveStatus('idle'); return; }
@@ -0,0 +1,19 @@
+import { MODES } from './editorConstants.js'; +import { STR } from './strings.js'; + +// First meaningful line of the document, used for draft names and filenames. +export function deriveTitle(content, mode) { + const line = (content ?? '').split('\n').find((l) => l.trim()); + if (!line) return STR.UNTITLED_DOCUMENT; + if (mode === MODES.MARKDOWN) { + const m = line.match(/^#+\s+(.+)$/); + if (m) return m[1].trim(); + } + if (mode === MODES.ORG) { + const title = line.match(/^#\+TITLE:\s*(.+)$/i); + if (title) return title[1].trim(); + const m = line.match(/^\*+\s+(.+)$/); + if (m) return m[1].replace(/^(TODO|DONE)\s+/, '').trim(); + } + return line.trim().slice(0, 80) || STR.UNTITLED_DOCUMENT; +}
@@ -1,14 +1,28 @@
import { MODES } from './editorConstants.js'; +// Strip Windows-illegal filename characters; collapse whitespace runs. +const ILLEGAL_FILENAME_CHARS = /[\\/:*?"<>|]/g; + +function sanitizeFilenameBase(name) { + const cleaned = (name ?? '') + .replace(ILLEGAL_FILENAME_CHARS, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 60) + .replace(/[. ]+$/, ''); + return cleaned || 'document'; +} + export function downloadDocument(content, mode, filenameBase = 'document') { const isOrg = mode === MODES.ORG; const blob = new Blob([content], { type: isOrg ? 'text/plain;charset=utf-8' : 'text/markdown;charset=utf-8', }); + const base = sanitizeFilenameBase(filenameBase); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; - link.download = isOrg ? `${filenameBase}.org` : `${filenameBase}.md`; + link.download = isOrg ? `${base}.org` : `${base}.md`; link.click(); URL.revokeObjectURL(url); }
@@ -0,0 +1,185 @@
+import { + MODES, + STORAGE_MARKDOWN, + STORAGE_ORG, + initStorage, + loadContent, + loadMode, +} from './editorConstants.js'; +import { deriveTitle } from './deriveTitle.js'; + +const INDEX_KEY = 'snow_drafts_index_v1'; +const CURRENT_KEY = 'snow_current_draft_id'; +const CONTENT_PREFIX = 'snow_draft_'; + +function contentKey(id) { + return `${CONTENT_PREFIX}${id}`; +} + +function newDraftId() { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + return `draft-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; +} + +function readIndex() { + try { + const raw = localStorage.getItem(INDEX_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return null; + return parsed.filter((entry) => entry && typeof entry.id === 'string'); + } catch { + return null; + } +} + +function writeIndex(index) { + try { + localStorage.setItem(INDEX_KEY, JSON.stringify(index)); + return true; + } catch { + return false; + } +} + +export function listDrafts() { + const index = readIndex() ?? []; + return [...index].sort( + (a, b) => Date.parse(b.updatedAt ?? 0) - Date.parse(a.updatedAt ?? 0), + ); +} + +export function loadDraftContent(id) { + try { + return localStorage.getItem(contentKey(id)) ?? ''; + } catch { + return ''; + } +} + +export function getCurrentDraftId() { + try { + return localStorage.getItem(CURRENT_KEY); + } catch { + return null; + } +} + +export function setCurrentDraftId(id) { + try { + localStorage.setItem(CURRENT_KEY, id); + } catch { + /* ignore */ + } +} + +// Persist draft content + refresh its index entry. Returns false on quota. +export function saveDraft(id, { mode, content }) { + const now = new Date().toISOString(); + let saved = true; + try { + localStorage.setItem(contentKey(id), content); + } catch { + saved = false; + } + + const index = readIndex() ?? []; + const entry = { + id, + title: deriveTitle(content, mode), + mode, + updatedAt: now, + }; + const position = index.findIndex((item) => item.id === id); + if (position >= 0) { + index[position] = entry; + } else { + index.push(entry); + } + if (!writeIndex(index)) saved = false; + return saved; +} + +export function createDraft(mode = MODES.MARKDOWN, content = '') { + const id = newDraftId(); + saveDraft(id, { mode, content }); + setCurrentDraftId(id); + return { id, mode, content }; +} + +export function deleteDraft(id) { + try { + localStorage.removeItem(contentKey(id)); + } catch { + /* ignore */ + } + const index = (readIndex() ?? []).filter((entry) => entry.id !== id); + writeIndex(index); +} + +// One-time migration: turn the single markdown/org drafts of 0.0.1 into +// entries of the drafts index, preserving whichever mode was last used. +function migrateLegacyDrafts() { + initStorage(); + const index = []; + const lastMode = loadMode(); + let currentId = null; + + for (const mode of [MODES.MARKDOWN, MODES.ORG]) { + const legacyKey = mode === MODES.ORG ? STORAGE_ORG : STORAGE_MARKDOWN; + let hasLegacy = false; + try { + hasLegacy = localStorage.getItem(legacyKey) !== null; + } catch { + /* ignore */ + } + if (!hasLegacy && mode !== lastMode) continue; + + const content = loadContent(mode); + const id = newDraftId(); + index.push({ + id, + title: deriveTitle(content, mode), + mode, + updatedAt: new Date().toISOString(), + }); + try { + localStorage.setItem(contentKey(id), content); + } catch { + /* ignore */ + } + if (mode === lastMode) currentId = id; + } + + writeIndex(index); + if (currentId) setCurrentDraftId(currentId); +} + +// Returns the draft to open: migrates legacy storage on first run and +// guarantees at least one draft exists. +export function ensureDraftsInitialized() { + if (readIndex() === null) { + migrateLegacyDrafts(); + } + + let index = readIndex() ?? []; + if (index.length === 0) { + const created = createDraft(loadMode(), loadContent(loadMode())); + return created; + } + + const currentId = getCurrentDraftId(); + let entry = index.find((item) => item.id === currentId); + if (!entry) { + [entry] = listDrafts(); + setCurrentDraftId(entry.id); + } + + return { + id: entry.id, + mode: entry.mode === MODES.ORG ? MODES.ORG : MODES.MARKDOWN, + content: loadDraftContent(entry.id), + }; +}
@@ -0,0 +1,42 @@
+import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; +import { tags } from '@lezer/highlight'; + +// Syntax colors for the Markdown editor, tuned to the snow palette. +// Kept in JS (CodeMirror HighlightStyle) but pointing at CSS variables where +// possible so dark mode follows the stylesheet. +const markdownHighlightStyle = HighlightStyle.define([ + { + tag: tags.heading1, + fontWeight: '700', + fontSize: '1.25em', + color: 'var(--text-heading)', + }, + { + tag: tags.heading2, + fontWeight: '700', + fontSize: '1.15em', + color: 'var(--text-heading)', + }, + { + tag: tags.heading3, + fontWeight: '600', + fontSize: '1.05em', + color: 'var(--text-heading)', + }, + { tag: tags.heading4, fontWeight: '600', color: 'var(--text-heading)' }, + { tag: tags.heading5, fontWeight: '600', color: 'var(--text-heading)' }, + { tag: tags.heading6, fontWeight: '600', color: 'var(--text-heading)' }, + { tag: tags.strong, fontWeight: '700', color: 'var(--text-heading)' }, + { tag: tags.emphasis, fontStyle: 'italic' }, + { tag: tags.strikethrough, textDecoration: 'line-through' }, + { tag: tags.monospace, color: 'var(--accent-hover)' }, + { tag: tags.link, color: 'var(--accent-hover)' }, + { tag: tags.url, color: 'var(--accent)' }, + { tag: tags.quote, color: 'var(--text-muted)', fontStyle: 'italic' }, + { tag: tags.contentSeparator, color: 'var(--accent)' }, + { tag: tags.meta, color: 'var(--text-muted)' }, + { tag: tags.processingInstruction, color: 'var(--accent)' }, + { tag: tags.labelName, color: 'var(--accent-hover)' }, +]); + +export const markdownHighlight = syntaxHighlighting(markdownHighlightStyle);
@@ -0,0 +1,33 @@
+const HEADING_RE = /^(#{1,6})\s+(.+)$/; +const FENCE_RE = /^(```|~~~)/; + +// Cheap line-based heading scan for the Markdown outline. +// Skips fenced code blocks so commented "# lines" inside code don't show up. +export function parseMarkdownHeadings(content) { + if (!content?.trim()) return []; + + const headings = []; + let inFence = false; + + const lines = content.split('\n'); + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + if (FENCE_RE.test(line.trimStart())) { + inFence = !inFence; + continue; + } + if (inFence) continue; + + const match = line.match(HEADING_RE); + if (match) { + headings.push({ + level: match[1].length, + title: match[2].replace(/\s+#+\s*$/, '').trim(), + todo: null, + line: i + 1, + }); + } + } + + return headings; +}
@@ -22,13 +22,13 @@ borderRight: '1px solid var(--border-soft)',
color: 'var(--text-muted)', }, '.cm-activeLine': { - backgroundColor: 'rgba(122, 155, 184, 0.08)', + backgroundColor: 'var(--active-line)', }, '.cm-cursor': { borderLeftColor: 'var(--accent-hover)', }, '&.cm-focused .cm-selectionBackground, .cm-selectionBackground': { - backgroundColor: 'rgba(122, 155, 184, 0.2) !important', + backgroundColor: 'rgba(122, 155, 184, 0.25) !important', }, '.cm-line': { lineHeight: '1.55',
@@ -77,6 +77,18 @@ VERSION_RESTORE_CONFIRM:
'Restore this version? Your current content will be saved as a new version first.', ORG_OUTLINE: 'Outline', + + TAB_WRITE: 'Write', + TAB_READ: 'Read', + PREVIEW_EMPTY: 'The preview appears here as you write…', + PREVIEW_RENDERING: 'Preparing preview…', + RESIZE_PANELS: 'Drag to resize panels. Double-click to reset.', + + DRAFTS: 'Drafts', + NEW_DRAFT: 'New draft', + DELETE_DRAFT: 'Delete draft', + DELETE_DRAFT_CONFIRM: (title) => + `Delete "${title}"? This cannot be undone.`, }; export const EXPIRY_OPTIONS = [
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'; +import DraftsMenu from '../components/DraftsMenu.jsx'; import IconButton from '../components/IconButton.jsx'; import ShareModal from '../components/ShareModal.jsx'; import StatusBadge from '../components/StatusBadge.jsx';@@ -9,19 +10,24 @@ DownloadIcon,
ShareIcon, UploadIcon, } from '../components/icons/index.js'; +import { deriveTitle } from '../lib/deriveTitle.js'; +import { downloadDocument } from '../lib/download.js'; import { - MODES, - isDefaultContent, - loadContent, - loadMode, - persistContent, - persistMode, -} from '../lib/editorConstants.js'; + createDraft, + deleteDraft, + ensureDraftsInitialized, + listDrafts, + loadDraftContent, + saveDraft, + setCurrentDraftId, +} from '../lib/drafts.js'; +import { MODES, persistMode } from '../lib/editorConstants.js'; import { ensureMarkedLoaded } from '../lib/previewHtml.js'; import { STR } from '../lib/strings.js'; const STORAGE_DEBOUNCE_MS = 500; -const APP_VERSION = '0.0.1'; +// Injected by Vite from package.json — single source of truth for the version. +const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'; const CREATOR_NAME = 'Pablo Murad'; const CREATOR_EMAIL = 'pablomurad@pm.me';@@ -31,37 +37,23 @@ if (parts.length < 2) return 'txt';
return parts[parts.length - 1]; } -function deriveTitle(content, mode) { - const line = content.split('\n').find((l) => l.trim()); - if (!line) return STR.UNTITLED_DOCUMENT; - if (mode === MODES.MARKDOWN) { - const m = line.match(/^#+\s+(.+)$/); - if (m) return m[1].trim(); - } - if (mode === MODES.ORG) { - const m = line.match(/^\*+\s+(.+)$/); - if (m) return m[1].replace(/^(TODO|DONE)\s+/, '').trim(); - } - return line.trim().slice(0, 80) || STR.UNTITLED_DOCUMENT; -} - export default function LocalEditorPage() { - const initialMode = loadMode(); - const [mode, setMode] = useState(initialMode); - const [content, setContent] = useState(() => loadContent(initialMode)); + const [draft, setDraft] = useState(() => ensureDraftsInitialized()); const [storageWarning, setStorageWarning] = useState(false); const [shareOpen, setShareOpen] = useState(false); const fileInputRef = useRef(null); const editorRef = useRef(null); + const { id: draftId, mode, content } = draft; + useEffect(() => { const timer = window.setTimeout(() => { - const saved = persistContent(mode, content); + const saved = saveDraft(draftId, { mode, content }); setStorageWarning(!saved); }, STORAGE_DEBOUNCE_MS); return () => window.clearTimeout(timer); - }, [content, mode]); + }, [draftId, mode, content]); const saveLabel = mode === MODES.ORG ? 'Save .org' : 'Save .md';@@ -69,31 +61,68 @@ const prefetchMarkdown = useCallback(() => {
ensureMarkedLoaded(); }, []); + const setContent = useCallback((nextContent) => { + setDraft((current) => ({ ...current, content: nextContent })); + }, []); + const handleModeChange = useCallback( (nextMode) => { if (nextMode === mode) return; - persistContent(mode, content); persistMode(nextMode); - setMode(nextMode); - setContent(loadContent(nextMode)); + setDraft((current) => { + saveDraft(current.id, { mode: nextMode, content: current.content }); + return { ...current, mode: nextMode }; + }); setStorageWarning(false); if (nextMode === MODES.MARKDOWN) prefetchMarkdown(); editorRef.current?.focus(); }, - [mode, content, prefetchMarkdown], + [mode, prefetchMarkdown], + ); + + const flushCurrentDraft = useCallback(() => { + saveDraft(draftId, { mode, content }); + }, [draftId, mode, content]); + + const handleSelectDraft = useCallback( + (id) => { + flushCurrentDraft(); + const entry = listDrafts().find((item) => item.id === id); + if (!entry) return; + setCurrentDraftId(id); + persistMode(entry.mode); + setDraft({ + id, + mode: entry.mode === MODES.ORG ? MODES.ORG : MODES.MARKDOWN, + content: loadDraftContent(id), + }); + setStorageWarning(false); + editorRef.current?.focus(); + }, + [flushCurrentDraft], + ); + + const handleCreateDraft = useCallback(() => { + flushCurrentDraft(); + const created = createDraft(mode, ''); + setDraft(created); + setStorageWarning(false); + editorRef.current?.focus(); + }, [flushCurrentDraft, mode]); + + const handleDeleteDraft = useCallback( + (id) => { + deleteDraft(id); + if (id !== draftId) return; + const next = ensureDraftsInitialized(); + persistMode(next.mode); + setDraft(next); + }, + [draftId], ); const handleSave = useCallback(() => { - const isOrg = mode === MODES.ORG; - const blob = new Blob([content], { - type: isOrg ? 'text/plain;charset=utf-8' : 'text/markdown;charset=utf-8', - }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = isOrg ? 'document.org' : 'document.md'; - link.click(); - URL.revokeObjectURL(url); + downloadDocument(content, mode, deriveTitle(content, mode)); }, [content, mode]); const handleImport = useCallback(@@ -111,36 +140,35 @@ reader.onload = () => {
const result = reader.result; if (typeof result !== 'string') return; - if (targetMode !== mode) { - persistContent(mode, content); - persistMode(targetMode); - setMode(targetMode); - if (targetMode === MODES.MARKDOWN) prefetchMarkdown(); - } - - setContent(result); - persistContent(targetMode, result); + // Imports land in a fresh draft so the current one is never clobbered. + flushCurrentDraft(); + const created = createDraft(targetMode, result); + persistMode(targetMode); + setDraft(created); setStorageWarning(false); + if (targetMode === MODES.MARKDOWN) prefetchMarkdown(); }; reader.readAsText(file); event.target.value = ''; }, - [mode, content, prefetchMarkdown], + [mode, flushCurrentDraft, prefetchMarkdown], ); const handleClear = useCallback(() => { - if (!isDefaultContent(mode, content)) { + if (content.trim()) { const confirmed = window.confirm( 'Clear the editor and start a blank document? Current content will be replaced.', ); if (!confirmed) return; } - setContent(''); - persistContent(mode, ''); + setDraft((current) => { + saveDraft(current.id, { mode: current.mode, content: '' }); + return { ...current, content: '' }; + }); setStorageWarning(false); editorRef.current?.focus(); - }, [mode, content]); + }, [content]); return ( <div className="app">@@ -173,6 +201,13 @@ </button>
</div> </div> <div className="toolbar" role="toolbar" aria-label="Editor actions"> + <DraftsMenu + currentDraftId={draftId} + onListDrafts={listDrafts} + onSelectDraft={handleSelectDraft} + onCreateDraft={handleCreateDraft} + onDeleteDraft={handleDeleteDraft} + /> <IconButton icon={<ShareIcon />} label={STR.SHARE}@@ -225,13 +260,15 @@ <a href={`mailto:${CREATOR_EMAIL}`}>{CREATOR_EMAIL}</a>
</p> </footer> - <ShareModal - open={shareOpen} - onClose={() => setShareOpen(false)} - title={deriveTitle(content, mode)} - mode={mode} - content={content} - /> + {shareOpen && ( + <ShareModal + open + onClose={() => setShareOpen(false)} + title={deriveTitle(content, mode)} + mode={mode} + content={content} + /> + )} </div> ); }
@@ -13,10 +13,72 @@ --border-soft: rgba(122, 155, 184, 0.2);
--shadow-soft: 0 4px 24px rgba(42, 51, 64, 0.06); --radius-btn: 10px; --surface-glass: rgba(255, 255, 255, 0.65); + --surface-active: rgba(255, 255, 255, 0.92); + --surface-hover: rgba(255, 255, 255, 0.88); + --surface-ghost-hover: rgba(245, 240, 232, 0.9); + --outline-bg: rgba(255, 255, 255, 0.35); + --quote-bg: rgba(232, 240, 247, 0.75); + --org-quote-bg: rgba(232, 240, 247, 0.45); + --code-bg: #f9fbfd; + --pre-bg: rgba(238, 242, 246, 0.9); + --org-src-bg: rgba(42, 51, 64, 0.04); + --table-stripe: rgba(122, 155, 184, 0.06); + --active-line: rgba(122, 155, 184, 0.08); + --success-fg: #3d5c4a; + --success-bg: #e5efe8; + --success-border: rgba(61, 92, 74, 0.25); + --danger-fg: #8b5a4a; + --todo-fg: #7a4a52; + --todo-bg: #f3e8eb; + --warning-bg: #fdf8f6; + --warning-border: rgba(139, 90, 74, 0.25); + --warning-fg: #6b4a42; + --modal-backdrop: rgba(42, 51, 64, 0.25); + --modal-bg: #fefefe; --font-mono: 'JetBrains Mono', Menlo, Consolas, monospace; --font-serif: 'Cormorant Garamond', Georgia, 'Times New Roman', serif; } +@media (prefers-color-scheme: dark) { + :root { + --snow: #14181e; + --ice-blue: #161b22; + --warm-beige: #12161c; + --canvas: #14181e; + --paper: #181d24; + --text-soft: #c4ccd6; + --text-muted: #8b95a1; + --text-heading: #e6ebf1; + --accent: #7a9bb8; + --accent-hover: #93b1cc; + --border-soft: rgba(122, 155, 184, 0.28); + --shadow-soft: 0 4px 24px rgba(0, 0, 0, 0.4); + --surface-glass: rgba(24, 29, 36, 0.65); + --surface-active: rgba(40, 48, 58, 0.95); + --surface-hover: rgba(255, 255, 255, 0.08); + --surface-ghost-hover: rgba(255, 255, 255, 0.06); + --outline-bg: rgba(255, 255, 255, 0.04); + --quote-bg: rgba(122, 155, 184, 0.14); + --org-quote-bg: rgba(122, 155, 184, 0.1); + --code-bg: #1e242c; + --pre-bg: rgba(30, 36, 44, 0.9); + --org-src-bg: rgba(255, 255, 255, 0.05); + --table-stripe: rgba(122, 155, 184, 0.08); + --active-line: rgba(122, 155, 184, 0.12); + --success-fg: #8fc4a5; + --success-bg: rgba(61, 92, 74, 0.35); + --success-border: rgba(143, 196, 165, 0.3); + --danger-fg: #d09a87; + --todo-fg: #d9a3ad; + --todo-bg: rgba(122, 74, 82, 0.35); + --warning-bg: rgba(60, 40, 34, 0.5); + --warning-border: rgba(208, 154, 135, 0.3); + --warning-fg: #d9b3a6; + --modal-backdrop: rgba(0, 0, 0, 0.55); + --modal-bg: #1a2028; + } +} + *, *::before, *::after {@@ -98,7 +160,7 @@ }
.mode-switch__btn { font-family: var(--font-mono); - font-size: 0.72rem; + font-size: 0.78rem; padding: 0.4rem 0.85rem; border: none; border-radius: 999px;@@ -116,7 +178,7 @@ color: var(--text-soft);
} .mode-switch__btn.is-active { - background: rgba(255, 255, 255, 0.92); + background: var(--surface-active); color: var(--text-heading); box-shadow: 0 1px 4px rgba(42, 51, 64, 0.06); }@@ -146,7 +208,7 @@ backdrop-filter: blur(6px);
} .btn:hover { - background: rgba(255, 255, 255, 0.88); + background: var(--surface-hover); border-color: rgba(122, 155, 184, 0.35); box-shadow: var(--shadow-soft); transform: translateY(-1px);@@ -157,7 +219,7 @@ transform: translateY(0);
} .btn-ghost:hover { - background: rgba(245, 240, 232, 0.9); + background: var(--surface-ghost-hover); } .btn-icon {@@ -212,7 +274,25 @@ position: relative;
align-self: stretch; width: 2rem; flex-shrink: 0; - pointer-events: none; + cursor: col-resize; + touch-action: none; +} + +.layout-divider:hover::before, +.app-layout--dragging .layout-divider::before { + background: linear-gradient( + to bottom, + transparent 0%, + rgba(122, 155, 184, 0.25) 12%, + rgba(122, 155, 184, 0.55) 50%, + rgba(122, 155, 184, 0.25) 88%, + transparent 100% + ); +} + +.app-layout--dragging { + user-select: none; + cursor: col-resize; } .layout-divider::before {@@ -254,33 +334,15 @@ 0 0 14px rgba(232, 240, 247, 0.9);
} @media (max-width: 768px) { - .app-layout { - grid-template-columns: 1fr; - grid-template-rows: minmax(240px, 1fr) auto minmax(240px, 1fr); + /* One panel at a time on small screens — the Write/Read tabs switch views. */ + .app-layout, + .app-layout--tabs { + grid-template-columns: 1fr !important; + grid-template-rows: 1fr; } - .layout-divider { - width: 100%; - height: 2rem; - margin: 0.5rem 0; - } - - .layout-divider::before { - top: 50%; - bottom: auto; - left: 0; - right: 0; - width: auto; - height: 1px; - transform: translateY(-50%); - background: linear-gradient( - to right, - transparent 0%, - rgba(122, 155, 184, 0.12) 12%, - rgba(122, 155, 184, 0.32) 50%, - rgba(122, 155, 184, 0.12) 88%, - transparent 100% - ); + .panel { + min-height: 55vh; } .app {@@ -292,6 +354,38 @@ font-size: 1.6rem;
} } +.mobile-tabs { + display: flex; + margin: 0 0 0.75rem; + padding: 0.2rem; + background: var(--surface-glass); + border: 1px solid var(--border-soft); + border-radius: 999px; + gap: 0.15rem; + backdrop-filter: blur(6px); +} + +.mobile-tabs__btn { + flex: 1; + font-family: var(--font-mono); + font-size: 0.85rem; + padding: 0.5rem 0.85rem; + border: none; + border-radius: 999px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: + background 0.2s ease, + color 0.2s ease; +} + +.mobile-tabs__btn.is-active { + background: var(--surface-active); + color: var(--text-heading); + box-shadow: 0 1px 4px rgba(42, 51, 64, 0.06); +} + .panel { display: flex; flex-direction: column;@@ -359,12 +453,29 @@ overflow-y: auto;
min-height: 0; padding: 0.5rem 0.75rem 1.5rem; font-family: var(--font-serif); - font-size: 1.15rem; + font-size: 1.2rem; + font-weight: 500; line-height: 1.75; color: var(--text-soft); transition: opacity 0.2s ease; } +/* Cap the measure — long lines on wide monitors kill readability. */ +.preview-content { + max-width: 68ch; + margin-inline: auto; +} + +.preview-empty { + max-width: 68ch; + margin: 2.5rem auto 0; + text-align: center; + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--text-muted); + opacity: 0.7; +} + .preview-paper.preview-updating { opacity: 0.72; }@@ -412,7 +523,7 @@ .preview-paper blockquote {
margin: 1em 0; padding: 0.5em 1em; border-left: 3px solid var(--accent); - background: rgba(232, 240, 247, 0.75); + background: var(--quote-bg); color: var(--text-muted); font-style: italic; }@@ -420,7 +531,7 @@
.preview-paper code { font-family: var(--font-mono); font-size: 0.85em; - background: var(--ice-blue); + background: var(--code-bg); padding: 0.15em 0.4em; border-radius: 4px; }@@ -428,7 +539,7 @@
.preview-paper pre { margin: 1em 0; padding: 1em 1.25em; - background: rgba(238, 242, 246, 0.9); + background: var(--pre-bg); border-radius: 8px; overflow-x: auto; border: 1px solid var(--border-soft);@@ -475,13 +586,13 @@ vertical-align: middle;
} .org-todo { - background: #f3e8eb; - color: #7a4a52; + background: var(--todo-bg); + color: var(--todo-fg); } .org-done { - background: #e5efe8; - color: #3d5c4a; + background: var(--success-bg); + color: var(--success-fg); } .app-layout--with-outline {@@ -494,13 +605,13 @@ flex-direction: column;
min-height: 0; padding: 0.35rem 0.5rem 0.75rem; border-right: 1px solid var(--border-soft); - background: rgba(255, 255, 255, 0.35); + background: var(--outline-bg); overflow-y: auto; } .org-outline__title { font-family: var(--font-mono); - font-size: 0.68rem; + font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-muted);@@ -524,7 +635,7 @@ border: none;
border-radius: 4px; background: transparent; font-family: var(--font-mono); - font-size: 0.72rem; + font-size: 0.78rem; line-height: 1.35; text-align: left; color: var(--text-soft);@@ -545,7 +656,7 @@ font-weight: 600;
color: var(--text-heading); } -.panel-editor .org-editor, +.panel-editor .code-editor, .panel-editor .cm-host { flex: 1; min-height: 280px;@@ -575,7 +686,7 @@ padding: 0.4rem 0.55rem;
} .preview-paper .org-table tbody tr:nth-child(even) { - background: rgba(122, 155, 184, 0.06); + background: var(--table-stripe); } .preview-paper .org-heading--1 {@@ -586,14 +697,14 @@ .preview-paper .org-quote {
margin: 1rem 0; padding: 0.65rem 0.9rem; border-left: 3px solid rgba(122, 155, 184, 0.35); - background: rgba(232, 240, 247, 0.45); + background: var(--org-quote-bg); } .preview-paper .org-src { margin: 1rem 0; padding: 0.75rem; border-radius: var(--radius-btn); - background: rgba(42, 51, 64, 0.04); + background: var(--org-src-bg); overflow-x: auto; font-family: var(--font-mono); font-size: 0.82rem;@@ -624,7 +735,7 @@ }
.app-meta { margin: 0; - font-size: 0.7rem; + font-size: 0.75rem; color: var(--text-muted); opacity: 0.85; }@@ -641,8 +752,8 @@ }
.app-storage-warning { margin: 0; - font-size: 0.7rem; - color: #8b5a4a; + font-size: 0.75rem; + color: var(--danger-fg); text-align: center; max-width: 28rem; }@@ -657,7 +768,7 @@
.badge { display: inline-block; padding: 0.15rem 0.55rem; - font-size: 0.65rem; + font-size: 0.72rem; font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase;@@ -673,17 +784,17 @@ border-color: rgba(122, 155, 184, 0.35);
} .badge--shared { - color: #4a6a85; + color: var(--accent-hover); } .badge--readonly { - color: #6b7580; + color: var(--text-muted); } .badge--editing { - color: #3d5c4a; - border-color: rgba(61, 92, 74, 0.25); - background: #e5efe8; + color: var(--success-fg); + border-color: var(--success-border); + background: var(--success-bg); } .save-status {@@ -693,7 +804,7 @@ align-self: center;
} .save-status--saved { - color: #3d5c4a; + color: var(--success-fg); } .save-status--saving {@@ -702,7 +813,7 @@ }
.save-status--error, .save-status--no_permission { - color: #8b5a4a; + color: var(--danger-fg); } .alert-banner {@@ -725,9 +836,9 @@ color: var(--text-muted);
} .alert-banner--warning { - background: #fdf8f6; - border-color: rgba(139, 90, 74, 0.25); - color: #6b4a42; + background: var(--warning-bg); + border-color: var(--warning-border); + color: var(--warning-fg); } .modal-backdrop {@@ -738,7 +849,7 @@ display: flex;
align-items: center; justify-content: center; padding: 1rem; - background: rgba(42, 51, 64, 0.25); + background: var(--modal-backdrop); backdrop-filter: blur(4px); }@@ -749,7 +860,7 @@ overflow-y: auto;
padding: 1.25rem 1.35rem; border-radius: 14px; border: 1px solid var(--border-soft); - background: var(--snow); + background: var(--modal-bg); box-shadow: var(--shadow-soft); }@@ -813,7 +924,7 @@
.share-error { margin: 0; font-size: 0.8rem; - color: #8b5a4a; + color: var(--danger-fg); } .share-result__expiry {@@ -865,7 +976,7 @@ .mode-switch__experimental {
margin-left: 0.35rem; padding: 0.1rem 0.35rem; border-radius: 4px; - font-size: 0.62rem; + font-size: 0.68rem; font-weight: 600; letter-spacing: 0.02em; text-transform: uppercase;@@ -932,9 +1043,241 @@ font-size: 1.5rem;
color: var(--text-heading); } +.drafts-menu { + position: relative; +} + +.drafts-menu__popover { + position: absolute; + top: calc(100% + 0.4rem); + right: 0; + z-index: 60; + width: min(19rem, calc(100vw - 2rem)); + padding: 0.5rem; + border: 1px solid var(--border-soft); + border-radius: var(--radius-btn); + background: var(--modal-bg); + box-shadow: var(--shadow-soft); +} + +.drafts-menu__new { + width: 100%; + padding: 0.45rem 0.6rem; + margin-bottom: 0.35rem; + border: 1px dashed var(--border-soft); + border-radius: 8px; + background: transparent; + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--accent-hover); + text-align: left; + cursor: pointer; +} + +.drafts-menu__new:hover { + background: var(--surface-hover); +} + +.drafts-menu__list { + margin: 0; + padding: 0; + list-style: none; + max-height: 55vh; + overflow-y: auto; +} + +.drafts-menu__item { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.drafts-menu__entry { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.1rem; + padding: 0.4rem 0.6rem; + border: none; + border-radius: 8px; + background: transparent; + text-align: left; + cursor: pointer; + color: var(--text-soft); +} + +.drafts-menu__entry:hover { + background: var(--surface-hover); +} + +.drafts-menu__entry.is-current { + background: var(--active-line); +} + +.drafts-menu__entry-title { + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--text-heading); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.drafts-menu__entry-meta { + font-family: var(--font-mono); + font-size: 0.68rem; + color: var(--text-muted); +} + +.drafts-menu__delete { + flex-shrink: 0; + width: 1.6rem; + height: 1.6rem; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-muted); + font-size: 1rem; + line-height: 1; + cursor: pointer; +} + +.drafts-menu__delete:hover { + background: var(--todo-bg); + color: var(--todo-fg); +} + +/* Syntax highlighting (highlight.js) tuned to the snow palette. */ +.preview-paper .hljs-comment, +.preview-paper .hljs-quote { + color: var(--text-muted); + font-style: italic; +} + +.preview-paper .hljs-keyword, +.preview-paper .hljs-selector-tag, +.preview-paper .hljs-built_in, +.preview-paper .hljs-tag { + color: #5f82a0; +} + +.preview-paper .hljs-string, +.preview-paper .hljs-attr, +.preview-paper .hljs-template-string, +.preview-paper .hljs-regexp { + color: #4a7a5c; +} + +.preview-paper .hljs-number, +.preview-paper .hljs-literal, +.preview-paper .hljs-symbol { + color: #8a6a4a; +} + +.preview-paper .hljs-title, +.preview-paper .hljs-function, +.preview-paper .hljs-name, +.preview-paper .hljs-section { + color: #7a5a80; +} + +.preview-paper .hljs-variable, +.preview-paper .hljs-template-variable, +.preview-paper .hljs-property { + color: var(--text-soft); +} + +.preview-paper .hljs-meta, +.preview-paper .hljs-doctag { + color: var(--text-muted); +} + +.preview-paper .hljs-emphasis { + font-style: italic; +} + +.preview-paper .hljs-strong { + font-weight: 700; +} + +@media (prefers-color-scheme: dark) { + .preview-paper .hljs-keyword, + .preview-paper .hljs-selector-tag, + .preview-paper .hljs-built_in, + .preview-paper .hljs-tag { + color: #8fb3d4; + } + + .preview-paper .hljs-string, + .preview-paper .hljs-attr, + .preview-paper .hljs-template-string, + .preview-paper .hljs-regexp { + color: #93c4a5; + } + + .preview-paper .hljs-number, + .preview-paper .hljs-literal, + .preview-paper .hljs-symbol { + color: #d4b48f; + } + + .preview-paper .hljs-title, + .preview-paper .hljs-function, + .preview-paper .hljs-name, + .preview-paper .hljs-section { + color: #c4a3cc; + } +} + +/* Print: only the rendered document — Ctrl+P becomes "export to PDF". */ +@media print { + body { + background: #ffffff; + color: #1a1a1a; + } + + .app { + padding: 0; + } + + .app-header, + .toolbar, + .mobile-tabs, + .panel-editor, + .layout-divider, + .org-outline, + .panel-label, + .app-footer, + .app-footer-stats, + .alert-banner { + display: none !important; + } + + .app-layout { + display: block !important; + } + + .panel-preview { + min-height: 0; + overflow: visible; + } + + .preview-paper { + overflow: visible; + padding: 0; + color: #1a1a1a; + } + + .preview-content { + max-width: none; + } +} + @media (prefers-reduced-motion: reduce) { .btn, .mode-switch__btn, + .mobile-tabs__btn, .preview-paper { transition: none; }
@@ -1,3 +1,4 @@
+import fs from 'fs'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import {@@ -6,6 +7,8 @@ resolveAllowSearchIndexing,
resolveAllowedHosts, robotsSeoPlugin, } from './vite.shared.js'; + +const pkg = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')); export default defineConfig(({ mode }) => { const allowedHosts = resolveAllowedHosts(mode);@@ -13,6 +16,9 @@ const allowSearchIndexing = resolveAllowSearchIndexing(mode);
return { plugins: [react(), robotsSeoPlugin(allowSearchIndexing)], + define: { + __APP_VERSION__: JSON.stringify(pkg.version), + }, build: { target: 'es2020', rollupOptions: {@@ -23,6 +29,9 @@ return 'vendor-react';
} if (id.includes('node_modules/marked') || id.includes('node_modules/dompurify')) { return 'vendor-markdown'; + } + if (id.includes('node_modules/highlight.js')) { + return 'vendor-highlight'; } if ( id.includes('node_modules/orga') ||