From 882ca489fe5aa4a1e2b0ee651f29e3c54d217e11 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 13:45:52 +0700 Subject: [PATCH 1/7] feat: drag & drop files onto the page to open/activate tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropping one or more files onto the page opens each in a new tab, or — if a tab already has that file open — activates the existing tab instead of creating a duplicate. "Same file" is decided by FileSystemFileHandle identity (isSameEntry) when the browser exposes a handle, falling back to file name. Files open in the focused split pane; the editor is focused afterwards. - FileActions.openDropped() + _findOpenDoc() + _readFile() reuse the existing size-guard / EOL / BOM / disk-baseline logic. - App wires capture-phase dragover/drop guarded to file drags (stopPropagation) so file drops never reach CodeMirror's text-drop handling, while dragging selected text within the editor still works. - CHANGELOG: note under 0.3.0 Added. Tests: file-actions.test.ts (+4: new tab, activate by handle identity, distinct entry same name, activate by name); drag-drop.spec.ts e2e (+2). 644 unit + 185 e2e pass; typecheck and lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 +++ src/app/app.ts | 56 ++++++++++++++++++++++++ src/app/file-actions.test.ts | 66 ++++++++++++++++++++++++++++ src/app/file-actions.ts | 84 ++++++++++++++++++++++++++++++++++++ tests/e2e/drag-drop.spec.ts | 63 +++++++++++++++++++++++++++ 5 files changed, 274 insertions(+) create mode 100644 tests/e2e/drag-drop.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 17ea971..f8cff1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 on. Choosing Split again collapses back to a single pane, as does closing the last tab in the secondary pane. The split layout — which files are in which pane, the orientation, and each pane's active tab — is restored on reload. +- **Drag & drop files onto the page.** Dropping a file opens it in a new tab; + if a tab already has that file open, it is activated instead of duplicated. + Same-file detection uses the file-system handle identity when the browser + provides one, falling back to the file name. Multiple files can be dropped at + once, and they open in the focused split pane. ### Fixed diff --git a/src/app/app.ts b/src/app/app.ts index e59131f..4f07c57 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -446,6 +446,62 @@ export class App { }); fileActionsRef.current = fileActions; + // ── Drag & drop files onto the page ────────────────────────────────────── + // Dropping a file opens it — or, if a tab already has that file open, + // activates that tab. Handled in the CAPTURE phase (with stopPropagation) + // so a file drop never reaches CodeMirror's own text-drop handling; only + // file drops are intercepted, so dragging selected text within the editor + // still works normally. + const isFileDrag = (dt: DataTransfer | null): boolean => + !!dt && Array.from(dt.types).includes('Files'); + document.addEventListener( + 'dragover', + (e: DragEvent) => { + if (!isFileDrag(e.dataTransfer)) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; + }, + true, + ); + document.addEventListener( + 'drop', + (e: DragEvent) => { + const dt = e.dataTransfer; + if (!isFileDrag(dt)) return; + e.preventDefault(); + e.stopPropagation(); + const items = Array.from(dt!.items).filter((i) => i.kind === 'file'); + // getAsFile() / getAsFileSystemHandle() must be called synchronously — + // DataTransferItems are invalidated once the event handler returns. + const files = items.map((i) => i.getAsFile()); + const handlePromises = items.map((i) => { + const withHandle = i as DataTransferItem & { + getAsFileSystemHandle?: () => Promise; + }; + return typeof withHandle.getAsFileSystemHandle === 'function' + ? withHandle.getAsFileSystemHandle().catch(() => null) + : Promise.resolve(null); + }); + void (async () => { + const handles = await Promise.all(handlePromises); + const entries: Array<{ file: File; handle?: FileSystemFileHandle }> = []; + for (let k = 0; k < items.length; k++) { + const file = files[k]; + if (!file) continue; + const h = handles[k]; + entries.push({ + file, + handle: h && h.kind === 'file' ? (h as FileSystemFileHandle) : undefined, + }); + } + if (entries.length === 0) return; + await fileActions.openDropped(entries); + this.view.focus(); + })(); + }, + true, + ); + // Bug 2: detect files changed on disk by another program. Re-check on // startup and whenever the editor window/tab regains focus, then the tab // shows a red disk for any externally-changed file. diff --git a/src/app/file-actions.test.ts b/src/app/file-actions.test.ts index aab4024..97cb6d3 100644 --- a/src/app/file-actions.test.ts +++ b/src/app/file-actions.test.ts @@ -339,3 +339,69 @@ describe('FileActions.reloadActive — try/catch robustness', () => { expect(controller.showDoc).not.toHaveBeenCalled(); }); }); + +// ── openDropped (drag & drop) tests ────────────────────────────────────────── + +/** A fake FileSystemFileHandle whose isSameEntry compares a shared identity tag. */ +function makeIdentityHandle(name: string, id: string): FileSystemFileHandle { + return { + name, + __id: id, + getFile: async () => ({ lastModified: 1 }) as unknown as File, + isSameEntry: async (other: unknown) => (other as { __id?: string }).__id === id, + } as unknown as FileSystemFileHandle; +} + +describe('FileActions.openDropped (drag & drop)', () => { + let store: DocumentStore; + let file: FileService; + let controller: EditorController; + let actions: FileActions; + + beforeEach(() => { + store = new DocumentStore(); + file = makeFileService(); + controller = makeController(); + actions = new FileActions({ file, store, controller }); + }); + + it('opens a dropped file in a new tab', async () => { + const dropped = new File(['hello world'], 'notes.txt'); + await actions.openDropped([{ file: dropped }]); + + const docs = store.list(); + expect(docs).toHaveLength(1); + expect(docs[0]!.name).toBe('notes.txt'); + expect(docs[0]!.content).toBe('hello world'); + expect(controller.showDoc).toHaveBeenCalledWith(docs[0]!.id); + }); + + it('activates the existing tab (by handle identity) instead of opening a duplicate', async () => { + const openHandle = makeIdentityHandle('a.txt', 'ENTRY-1'); + const existing = store.create({ name: 'a.txt', content: 'old', handle: openHandle }); + // A second untitled doc so we can prove focus actually changes. + store.create({ name: 'untitled-1', content: '' }); + + const droppedHandle = makeIdentityHandle('a.txt', 'ENTRY-1'); // same entry + await actions.openDropped([{ file: new File(['new'], 'a.txt'), handle: droppedHandle }]); + + // No new tab created (still 2 docs), and the existing one is active. + expect(store.list()).toHaveLength(2); + expect(store.activeId).toBe(existing.id); + expect(controller.showDoc).toHaveBeenLastCalledWith(existing.id); + }); + + it('opens a new tab when a dropped handle is a different entry with the same name', async () => { + store.create({ name: 'a.txt', content: 'one', handle: makeIdentityHandle('a.txt', 'ENTRY-1') }); + const droppedHandle = makeIdentityHandle('a.txt', 'ENTRY-2'); // different entry + await actions.openDropped([{ file: new File(['two'], 'a.txt'), handle: droppedHandle }]); + expect(store.list()).toHaveLength(2); + }); + + it('activates an existing handle-less tab by name when the drop has no handle', async () => { + const existing = store.create({ name: 'memo.md', content: 'x' }); + await actions.openDropped([{ file: new File(['y'], 'memo.md') }]); + expect(store.list()).toHaveLength(1); + expect(store.activeId).toBe(existing.id); + }); +}); diff --git a/src/app/file-actions.ts b/src/app/file-actions.ts index 5c4292f..259102b 100644 --- a/src/app/file-actions.ts +++ b/src/app/file-actions.ts @@ -182,6 +182,90 @@ export class FileActions { this.store.update(doc.id, { diskModified: await this._handleModified(handle) }); } + /** + * Open files dropped onto the page (drag & drop). For each dropped file: + * - if a tab already has that file open, activate it (no duplicate tab); + * - otherwise open it in a new tab in the focused pane. + * + * "Already open" is decided by FileSystemFileHandle identity (isSameEntry) when + * a handle is available (Chrome exposes one via getAsFileSystemHandle), and by + * file name otherwise (drops without a handle can't be compared any other way). + * + * @param entries dropped items; `handle` is present only when the browser + * exposed a FileSystemFileHandle for the drop. + */ + async openDropped(entries: Array<{ file: File; handle?: FileSystemFileHandle }>): Promise { + for (const { file, handle } of entries) { + const existing = await this._findOpenDoc(file.name, handle); + if (existing) { + this.store.setActive(existing.id); + this.controller.showDoc(existing.id); + continue; + } + const verdict = classifySize(file.size); + if (verdict === 'reject') { + alert(`File too large (> ${PERF_MAX_BYTES / 1_000_000} MB). Open it in a desktop editor.`); + continue; + } + if (verdict === 'warn' && !this.confirmFn('Large file (> 25 MB) may be slow. Open anyway?')) { + continue; + } + const { content, eol, bom } = await this._readFile(file); + const doc = this.store.create({ + name: file.name, + content, + languageId: 'plaintext', + handle, + eol, + bom, + dirty: false, + }); + this.controller.showDoc(doc.id); + if (handle) { + this.store.update(doc.id, { diskModified: await this._handleModified(handle) }); + } + } + } + + /** + * Find an already-open document matching a dropped file. Prefers robust handle + * identity (isSameEntry); if the drop carries a handle, a name match is only + * accepted for handle-less docs (so two distinct files sharing a name but each + * backed by its own handle are not conflated). With no handle, matches by name. + */ + private async _findOpenDoc( + name: string, + handle?: FileSystemFileHandle, + ): Promise { + if (handle) { + for (const doc of this.store.list()) { + if (!doc.handle) continue; + try { + if (await doc.handle.isSameEntry(handle)) return doc; + } catch { + /* isSameEntry unsupported/failed — fall through to name match */ + } + } + return this.store.list().find((d) => !d.handle && d.name === name); + } + return this.store.list().find((d) => d.name === name); + } + + /** Read content + EOL/BOM from a dropped File (mirrors _readHandle). */ + private async _readFile( + file: File, + ): Promise<{ content: string; eol: 'lf' | 'crlf' | 'cr'; bom: boolean }> { + const raw = await file.text(); + const bom = raw.startsWith(''); + const content = bom ? raw.slice(1) : raw; + const eol: 'lf' | 'crlf' | 'cr' = content.includes('\r\n') + ? 'crlf' + : content.includes('\r') + ? 'cr' + : 'lf'; + return { content, eol, bom }; + } + // ── Close-variant helpers ──────────────────────────────────────────────────── /** diff --git a/tests/e2e/drag-drop.spec.ts b/tests/e2e/drag-drop.spec.ts new file mode 100644 index 0000000..0c4c4d5 --- /dev/null +++ b/tests/e2e/drag-drop.spec.ts @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +/** + * E2E: dragging & dropping a file onto the page opens it in a new tab, and + * dropping a file that is already open activates its existing tab instead of + * creating a duplicate. + * + * The drop is synthesised with a DataTransfer built in the page (an OS drag + * can't be produced headlessly), so the browser exposes no FileSystemFileHandle + * — exercising the filename-match path. Handle-identity matching is covered by + * the FileActions unit tests. + */ +import { test, expect } from '@playwright/test'; + +type Win = Window & { __appReady?: unknown; __editor: { getValue(): string } }; + +async function ready(page: Parameters[1]>[0]['page']) { + page.on('dialog', (d) => void d.accept()); + await page.goto('/editor.html'); + await page.waitForFunction(() => (window as unknown as Win).__appReady !== undefined); + await page.evaluate(() => (window as unknown as Win).__appReady); +} + +/** Dispatch a synthetic file drop on the document. */ +async function dropFile( + page: Parameters[1]>[0]['page'], + name: string, + content: string, +) { + await page.evaluate( + ({ name, content }) => { + const dt = new DataTransfer(); + dt.items.add(new File([content], name, { type: 'text/plain' })); + document.dispatchEvent( + new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true }), + ); + }, + { name, content }, + ); +} + +test.describe('Drag & drop file', () => { + test('dropping a file opens it in a new tab', async ({ page }) => { + await ready(page); + const before = await page.locator('#tabbar .tab').count(); + await dropFile(page, 'dropped.txt', 'DROPPED CONTENT'); + await expect(page.locator('#tabbar .tab')).toHaveCount(before + 1); + await expect + .poll(() => page.evaluate(() => (window as unknown as Win).__editor.getValue())) + .toContain('DROPPED CONTENT'); + }); + + test('dropping an already-open file activates its tab (no duplicate)', async ({ page }) => { + await ready(page); + await dropFile(page, 'same.txt', 'FIRST'); + await expect(page.locator('.tab', { hasText: 'same.txt' })).toHaveCount(1); + const count = await page.locator('#tabbar .tab').count(); + // Drop a file with the same name again → activates, no new tab. + await dropFile(page, 'same.txt', 'SECOND'); + await page.waitForTimeout(200); + await expect(page.locator('#tabbar .tab')).toHaveCount(count); + await expect(page.locator('.tab.active', { hasText: 'same.txt' })).toHaveCount(1); + }); +}); From 05b49212f632054dfaed08358c82c952f759a242 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:09:33 +0700 Subject: [PATCH 2/7] feat(plugins): plugin system + MarkdownViewer++ (lazy-loaded) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Notepad++-style Plugins system where plugin code is NEVER bundled into editor.js — it loads on demand (dynamic import → separate webpack chunk) only when the user enables it, keeping startup fast. Plugin system (Phase A): - plugin-api.ts: Plugin + PluginContext (store, focused view/controller, dockManager, namespaced storage, menu contribution). - registry.ts: lazy enable/disable via dynamic-import loaders; enabled ids persisted and re-activated AFTER app-ready (never blocks first paint). - New "Plugins" top-level menu (between Macro and Help) + Plugin Manager modal (Enable/Disable with a Loading… state). - Wired through app.ts (menu render + refresh) and editor-page.ts (registry construction + post-appReady restore). MarkdownViewer++ (Phase B): - Docked live preview: CommonMark + GFM (tables, task lists, strikethrough) via marked, sanitized with DOMPurify, rendered in a shadow root so custom CSS is isolated. Auto-refresh (debounced) + synced scroll + Ctrl+Shift+M toggle. - Options (custom CSS, persisted), Export HTML; PDF via browser Print. - marked + dompurify live only in the plugin chunk (verified: 0 DOMPurify refs in editor.js; they're in the on-demand 214.js chunk). Tests: registry (lazy-load, persistence, disable, failure, namespacing); markdownToHtml (GFM); e2e markdown-viewer.spec.ts (enable→lazy-load→preview, auto-refresh, real-browser XSS sanitization). Menu-count tests updated to 10. 653 unit + e2e pass; typecheck, lint, build clean. Docs: docs/plugins/README.md, docs/plugins/markdown-viewer.md (user flows). ComparePlus (Phase C) and CHANGELOG/perf finalize (Phase D) still to come. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/plugins/README.md | 33 +++++ docs/plugins/markdown-viewer.md | 49 +++++++ package-lock.json | 30 ++++ package.json | 2 + src/app/app.ts | 24 ++++ src/app/menu-bar.test.ts | 9 +- src/app/menu-bar.ts | 24 ++++ src/app/plugin-manager.ts | 101 ++++++++++++++ src/app/toolbar.test.ts | 2 + src/editor-page.ts | 22 +++ src/plugins/available.ts | 17 +++ src/plugins/markdown/index.ts | 208 ++++++++++++++++++++++++++++ src/plugins/markdown/render.test.ts | 34 +++++ src/plugins/markdown/render.ts | 40 ++++++ src/plugins/plugin-api.ts | 84 +++++++++++ src/plugins/registry.test.ts | 114 +++++++++++++++ src/plugins/registry.ts | 130 +++++++++++++++++ src/styles.css | 61 ++++++++ tests/e2e/markdown-viewer.spec.ts | 89 ++++++++++++ tests/e2e/menu.spec.ts | 9 +- 20 files changed, 1075 insertions(+), 7 deletions(-) create mode 100644 docs/plugins/README.md create mode 100644 docs/plugins/markdown-viewer.md create mode 100644 src/app/plugin-manager.ts create mode 100644 src/plugins/available.ts create mode 100644 src/plugins/markdown/index.ts create mode 100644 src/plugins/markdown/render.test.ts create mode 100644 src/plugins/markdown/render.ts create mode 100644 src/plugins/plugin-api.ts create mode 100644 src/plugins/registry.test.ts create mode 100644 src/plugins/registry.ts create mode 100644 tests/e2e/markdown-viewer.spec.ts diff --git a/docs/plugins/README.md b/docs/plugins/README.md new file mode 100644 index 0000000..caccf57 --- /dev/null +++ b/docs/plugins/README.md @@ -0,0 +1,33 @@ +# Plugins + +Notepad Web supports optional plugins, mirroring Notepad++'s **Plugins** menu. +Plugins are **not loaded at startup** — their code and libraries are fetched on +demand (a separate JS chunk) only when you enable them, so they never slow the +editor's launch. + +## Enabling / disabling a plugin + +1. Open **Plugins → Plugins Manager…**. +2. Each available plugin is listed with a short description and an **Enable** / + **Disable** button. +3. Click **Enable**. The button shows **Loading…** while the plugin's chunk is + fetched, then the plugin activates and adds its own submenu under **Plugins**. +4. Click **Disable** to deactivate it and remove its panels/menus. + +Your enabled plugins are remembered across reloads. On the next launch they are +re-activated in the background *after* the editor is ready, so startup stays fast. + +## Available plugins + +| Plugin | Purpose | Docs | +|--------|---------|------| +| **MarkdownViewer++** | Live Markdown preview panel | [markdown-viewer.md](markdown-viewer.md) | +| **ComparePlus** | Side-by-side file diff | [compareplus.md](compareplus.md) | + +## Notes + +- Plugins run in the same sandbox as the app (MV3 CSP: no remote code; everything + is bundled locally). +- A few desktop-only Notepad++ plugin features are not possible in a browser and + are marked **not supported** in each plugin's doc (e.g. ComparePlus's Git/SVN + diff, which needs version-control access the browser sandbox does not grant). diff --git a/docs/plugins/markdown-viewer.md b/docs/plugins/markdown-viewer.md new file mode 100644 index 0000000..31f16ad --- /dev/null +++ b/docs/plugins/markdown-viewer.md @@ -0,0 +1,49 @@ +# MarkdownViewer++ + +A live HTML preview of the current document, ported from the Notepad++ +[MarkdownViewer++](https://github.com/nea/MarkdownViewerPlusPlus) plugin. + +## Enable + +**Plugins → Plugins Manager… → Enable** next to *MarkdownViewer++*. The preview +panel opens on the right immediately. + +## User flows + +### Preview a Markdown file + +1. Open or create a `.md` file and start typing. +2. Enable the plugin (above), or if already enabled, press **Ctrl+Shift+M** (or + **Plugins → MarkdownViewer++ → Toggle Preview**). +3. The right-hand **Markdown Preview** panel shows the rendered document and + **updates as you type** (debounced). Scrolling the editor scrolls the preview + to the matching position. + +Rendering is **CommonMark + GitHub-Flavored Markdown**: tables, task lists, +strikethrough, and autolinks. The HTML is **sanitized** (scripts, inline event +handlers, and `javascript:` links are stripped), so previewing untrusted +Markdown is safe. Links open in a new browser tab. + +### Style the preview with custom CSS + +1. **Plugins → MarkdownViewer++ → Options…** +2. Enter CSS in the **Custom CSS** box (it targets the preview body). Click + **Save** — the preview updates instantly, and your CSS is remembered. + +### Export + +- **Export to HTML** — **Plugins → MarkdownViewer++ → Export HTML…** downloads a + self-contained `.html` file (your custom CSS is inlined). +- **Export to PDF** — open the preview and use your browser's **Print** dialog + (**Ctrl/Cmd+P**) → *Save as PDF*. + +## Toggle off + +Disable it from the Plugin Manager, or press **Ctrl+Shift+M** to hide the panel. + +## Not included (vs. the desktop plugin) + +- Per-language **syntax highlighting inside code blocks** (code renders in a + monospace block). A bundled highlighter may be added later. +- A **built-in PDF engine** with page-size/margin options — use the browser's + Print-to-PDF instead. diff --git a/package-lock.json b/package-lock.json index 09351a8..d175bd1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,8 @@ "@codemirror/view": "^6.43.4", "@lezer/highlight": "^1.2.3", "dockview-core": "^7.0.2", + "dompurify": "^3.4.11", + "marked": "^18.0.5", "wasmoon": "^1.16.0" }, "devDependencies": { @@ -1711,6 +1713,13 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.62.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", @@ -2990,6 +2999,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", @@ -4316,6 +4334,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/package.json b/package.json index 1a98bf3..9a1fb65 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,8 @@ "@codemirror/view": "^6.43.4", "@lezer/highlight": "^1.2.3", "dockview-core": "^7.0.2", + "dompurify": "^3.4.11", + "marked": "^18.0.5", "wasmoon": "^1.16.0" } } diff --git a/src/app/app.ts b/src/app/app.ts index 4f07c57..6b980c1 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -18,6 +18,8 @@ import { RecentFilesService } from '../services/recent-files-service'; import { EditorController } from '../editor/editor-controller'; import { TabBar } from './tabbar'; import { showContextMenu } from './context-menu'; +import { openPluginManager } from './plugin-manager'; +import type { PluginRegistry } from '../plugins/registry'; import { FileActions } from './file-actions'; import { SessionSync } from './session-sync'; import { StatusBar } from './statusbar'; @@ -126,6 +128,8 @@ export interface AppDeps { controllerRef?: { current: EditorController }; /** DockManager for split-view group management. Omitted in unit tests. */ dockManager?: DockManager; + /** Plugin registry for the Plugins menu + Plugin Manager. Omitted in unit tests. */ + pluginRegistry?: PluginRegistry; /** Factory that builds the secondary editor host on first split. Omitted in unit tests. */ createSecondaryEditor?: () => SecondaryEditorHost; } @@ -146,6 +150,8 @@ export class App { private lastSettings: Settings | null = null; /** Refresh the status bar cursor from the focused view (set during start()). */ private refreshStatusCursor: (() => void) | null = null; + /** Re-render the menu bar (set during start(); used by the plugin registry). */ + private renderMenuBarFn: (() => void) | null = null; /** Lazily-populated refs the tab-bar context menu closes over. */ private fileActionsRef: { current: FileActions | null } = { current: null }; private doSaveAsRef: { current: (() => Promise) | null } = { current: null }; @@ -1146,6 +1152,8 @@ export class App { viewLuaConsole: doLuaConsole, viewSplitHorizontal: () => this.doSplit('h'), viewSplitVertical: () => this.doSplit('v'), + pluginsManager: () => this.openPluginsManager(), + pluginMenus: this.deps.pluginRegistry?.menuContributions() ?? [], langItems: buildLangItems(), macroStartRecording: () => { startRecording(); @@ -1270,6 +1278,8 @@ export class App { viewLuaConsole: doLuaConsole, viewSplitHorizontal: () => this.doSplit('h'), viewSplitVertical: () => this.doSplit('v'), + pluginsManager: () => this.openPluginsManager(), + pluginMenus: this.deps.pluginRegistry?.menuContributions() ?? [], fileOpenFolder: doOpenFolder, langItems: [], searchToggleBookmark: () => runCmd(cmdToggleBookmark), @@ -1323,6 +1333,10 @@ export class App { // Wire the lazy toolbar reference now that renderToolbar is defined. toolbarRef.render = renderToolbar; + // Expose menu re-render so the plugin registry can refresh the Plugins menu + // when a plugin is enabled/disabled or contributes/updates its submenu. + this.renderMenuBarFn = renderMenuBar; + // Initial render (lang list may be empty if registry not ready). renderMenuBar(); renderToolbar(); @@ -1618,4 +1632,14 @@ export class App { focusActiveEditor(): void { this.view.focus(); } + + /** Re-render the menu bar (called by the plugin registry when plugins change). */ + refreshMenus(): void { + this.renderMenuBarFn?.(); + } + + /** Open the Plugin Manager modal (wired to Plugins → Plugins Manager…). */ + private openPluginsManager(): void { + if (this.deps.pluginRegistry) openPluginManager(this.deps.pluginRegistry); + } } diff --git a/src/app/menu-bar.test.ts b/src/app/menu-bar.test.ts index 4442b3d..7a8409a 100644 --- a/src/app/menu-bar.test.ts +++ b/src/app/menu-bar.test.ts @@ -87,6 +87,8 @@ function makeActions(overrides: Partial = {}): MenuBarActions { viewLuaConsole: noop, viewSplitHorizontal: noop, viewSplitVertical: noop, + pluginsManager: noop, + pluginMenus: [], fileOpenFolder: noop, langItems: [], searchToggleBookmark: noop, @@ -132,13 +134,13 @@ function buildBar(overrides: Partial = {}): { // ── Structure tests ─────────────────────────────────────────────────────────── describe('MenuBar structure', () => { - it('renders exactly 9 top-level menus (Encoding added between View and Language)', () => { + it('renders exactly 10 top-level menus (Plugins added between Macro and Help)', () => { const { container } = buildBar(); const buttons = container.querySelectorAll('[role="menuitem"]'); - expect(buttons).toHaveLength(9); + expect(buttons).toHaveLength(10); }); - it('top-level labels are correct and ordered (with Encoding between View and Language)', () => { + it('top-level labels are correct and ordered (Plugins between Macro and Help)', () => { const { container } = buildBar(); const labels = Array.from(container.querySelectorAll('[role="menuitem"]')).map( (el) => el.textContent, @@ -152,6 +154,7 @@ describe('MenuBar structure', () => { 'Language', 'Settings', 'Macro', + 'Plugins', 'Help', ]); }); diff --git a/src/app/menu-bar.ts b/src/app/menu-bar.ts index 5bf6895..e160405 100644 --- a/src/app/menu-bar.ts +++ b/src/app/menu-bar.ts @@ -22,6 +22,8 @@ * stable for the lifetime of the MenuBar instance. */ +import type { PluginMenu } from '../plugins/plugin-api'; + export interface MenuItem { label: string; accelerator?: string; @@ -482,6 +484,8 @@ export class MenuBar { viewSplitHorizontal, viewSplitVertical, langItems, + pluginsManager, + pluginMenus, searchToggleBookmark, searchNextBookmark, searchPrevBookmark, @@ -889,6 +893,21 @@ export class MenuBar { items: macroMenuItems, }; + // ── Plugins ───────────────────────────────────────────────────────────── + // Faithful to Notepad++'s Plugins menu. "Plugins Manager…" opens the modal; + // each enabled plugin contributes its own submenu (lazily loaded on enable). + const pluginItems: MenuItem[] = [enabled('Plugins Manager…', pluginsManager)]; + if (pluginMenus.length > 0) { + pluginItems.push(sep()); + for (const pm of pluginMenus) { + const subItems: MenuItem[] = pm.items.map((it) => + it.separator ? sep() : item(it.label, it.accelerator, it.action, it.enabled ?? true), + ); + pluginItems.push(submenu(pm.label, subItems, true)); + } + } + const pluginsMenu: MenuDef = { label: 'Plugins', items: pluginItems }; + // ── Help ────────────────────────────────────────────────────────────────── const helpMenu: MenuDef = { label: 'Help', @@ -923,6 +942,7 @@ export class MenuBar { languageMenu, settingsMenu, macroMenu, + pluginsMenu, helpMenu, ]; } @@ -1030,6 +1050,10 @@ export interface MenuBarActions { /** View → Split Vertical (secondary pane side-by-side). */ viewSplitVertical: () => void; langItems: LangItem[]; + /** Open the Plugin Manager modal. */ + pluginsManager: () => void; + /** Submenus contributed by currently-enabled plugins (under the Plugins menu). */ + pluginMenus: PluginMenu[]; // Bookmarks (Search → Bookmarks submenu) searchToggleBookmark: () => void; searchNextBookmark: () => void; diff --git a/src/app/plugin-manager.ts b/src/app/plugin-manager.ts new file mode 100644 index 0000000..cd465de --- /dev/null +++ b/src/app/plugin-manager.ts @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +/** + * Plugin Manager modal — lists available plugins and lets the user enable/disable + * them. Enabling triggers the registry's lazy load (dynamic import); a "Loading…" + * state is shown while the plugin's chunk is fetched and activated. + * + * Reuses the shared .dialog-overlay / .dialog-box chrome. + */ +import type { PluginRegistry } from '../plugins/registry'; + +/** Open the Plugin Manager modal. Returns a function that closes it. */ +export function openPluginManager(registry: PluginRegistry): () => void { + const overlay = document.createElement('div'); + overlay.className = 'dialog-overlay'; + + const box = document.createElement('div'); + box.className = 'dialog-box'; + box.setAttribute('role', 'dialog'); + box.setAttribute('aria-label', 'Plugin Manager'); + overlay.appendChild(box); + + const title = document.createElement('div'); + title.className = 'dialog-title'; + title.textContent = 'Plugins Manager'; + box.appendChild(title); + + const listEl = document.createElement('div'); + listEl.className = 'plugin-list'; + box.appendChild(listEl); + + const actions = document.createElement('div'); + actions.className = 'dialog-actions'; + const closeBtn = document.createElement('button'); + closeBtn.textContent = 'Close'; + closeBtn.addEventListener('click', () => close()); + actions.appendChild(closeBtn); + box.appendChild(actions); + + function renderList(): void { + listEl.innerHTML = ''; + for (const p of registry.list()) { + const row = document.createElement('div'); + row.className = 'plugin-row'; + + const info = document.createElement('div'); + info.className = 'plugin-info'; + const name = document.createElement('div'); + name.className = 'plugin-name'; + name.textContent = p.name; + const desc = document.createElement('div'); + desc.className = 'plugin-desc'; + desc.textContent = p.description; + info.appendChild(name); + info.appendChild(desc); + row.appendChild(info); + + const btn = document.createElement('button'); + btn.className = 'plugin-toggle'; + const enabled = registry.isEnabled(p.id); + btn.textContent = enabled ? 'Disable' : 'Enable'; + btn.addEventListener('click', () => void toggle(p.id, btn)); + row.appendChild(btn); + + listEl.appendChild(row); + } + } + + async function toggle(id: string, btn: HTMLButtonElement): Promise { + const wasEnabled = registry.isEnabled(id); + btn.disabled = true; + try { + if (wasEnabled) { + await registry.disable(id); + } else { + btn.textContent = 'Loading…'; + await registry.enable(id); + } + } catch (e) { + alert(`Failed to ${wasEnabled ? 'disable' : 'enable'} plugin: ${(e as Error).message}`); + } finally { + renderList(); + } + } + + const onKeydown = (e: KeyboardEvent): void => { + if (e.key === 'Escape') close(); + }; + overlay.addEventListener('click', (e) => { + if (e.target === overlay) close(); + }); + document.addEventListener('keydown', onKeydown); + + function close(): void { + document.removeEventListener('keydown', onKeydown); + overlay.remove(); + } + + renderList(); + document.body.appendChild(overlay); + return close; +} diff --git a/src/app/toolbar.test.ts b/src/app/toolbar.test.ts index 75c83b3..c287d74 100644 --- a/src/app/toolbar.test.ts +++ b/src/app/toolbar.test.ts @@ -88,6 +88,8 @@ function makeActions(overrides: Partial = {}): MenuBarActions { viewLuaConsole: noop, viewSplitHorizontal: noop, viewSplitVertical: noop, + pluginsManager: noop, + pluginMenus: [], fileOpenFolder: noop, langItems: [], searchToggleBookmark: noop, diff --git a/src/editor-page.ts b/src/editor-page.ts index 253291a..a112c1c 100644 --- a/src/editor-page.ts +++ b/src/editor-page.ts @@ -21,6 +21,8 @@ import { EditorController } from './editor/editor-controller'; import { notepadBase } from './editor/notepad-light-theme'; import { notepadHighlight } from './editor/notepad-theme'; import { dockManager } from './app/dock-manager'; +import { PluginRegistry } from './plugins/registry'; +import { AVAILABLE_PLUGINS } from './plugins/available'; import { debugLog, mountDebugLogPanel } from './app/debug-log-panel'; import { mountFileListPanel } from './app/file-list-panel'; import { mountWorkspacePanel } from './app/workspace-panel'; @@ -483,6 +485,18 @@ const storage = createStorage(); const settings = new SettingsService(storage); const theme = new ThemeService(() => 'system'); +// Plugin registry — tiny; plugin CODE loads lazily on enable (dynamic import). +// onMenusChanged is wired to the App after it is constructed (below). +let refreshPluginMenus: () => void = () => {}; +const pluginRegistry = new PluginRegistry(AVAILABLE_PLUGINS, { + store, + getFocusedView: () => focusedViewRef.current, + getFocusedController: () => focusedControllerRef.current, + dockManager, + kv: storage, + onMenusChanged: () => refreshPluginMenus(), +}); + const app = new App({ view, controller, @@ -502,8 +516,12 @@ const app = new App({ controllerRef: focusedControllerRef, dockManager, createSecondaryEditor, + pluginRegistry, }); +// Now that App exists, route plugin-driven menu refreshes to it. +refreshPluginMenus = () => app.refreshMenus(); + // ── Expose dockManager + panel toggles globally for App to wire up menu ────── window.__dockManager = dockManager; window.__debugLogToggle = () => dockManager.togglePanel('debug-log'); @@ -692,6 +710,10 @@ window.__appReady = (async () => { await new Promise((resolve) => requestAnimationFrame(() => resolve())); app.focusActiveEditor(); + + // Re-activate any plugins the user had enabled — deferred to here so plugin + // chunks load AFTER first paint and never slow startup. + void pluginRegistry.restoreEnabled(); })(); // ── PWA service worker (installability + offline) ──────────────────────────── diff --git a/src/plugins/available.ts b/src/plugins/available.ts new file mode 100644 index 0000000..413db39 --- /dev/null +++ b/src/plugins/available.ts @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +/** + * The catalogue of plugins the user can enable. Each `load` is a dynamic import + * — the webpack code-split point — so a plugin's code and libraries are fetched + * only when it is enabled, never at startup. + */ +import type { AvailablePlugin } from './plugin-api'; + +export const AVAILABLE_PLUGINS: AvailablePlugin[] = [ + { + id: 'markdown-viewer', + name: 'MarkdownViewer++', + description: + 'Live Markdown preview in a docked panel — synced scroll, GFM, custom CSS, and HTML export.', + load: () => import('./markdown/index'), + }, +]; diff --git a/src/plugins/markdown/index.ts b/src/plugins/markdown/index.ts new file mode 100644 index 0000000..43db95a --- /dev/null +++ b/src/plugins/markdown/index.ts @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +/** + * MarkdownViewer++ plugin — a live, docked HTML preview of the active document. + * + * Ported behaviour (browser-feasible core): dockable preview panel, auto-refresh + * on edit (debounced), synced scrolling, Ctrl+Shift+M toggle, custom CSS, and + * HTML export (PDF via the browser's Print dialog). Rendering is CommonMark + + * GFM, sanitized (see ./render). The preview lives in a shadow root so custom + * CSS is isolated from the app chrome. + * + * This module is reached only via a dynamic import from the plugin registry, so + * it and its libs (marked, DOMPurify) are emitted as a separate on-demand chunk. + */ +import type { Plugin, PluginContext } from '../plugin-api'; +import { renderMarkdown } from './render'; + +const PANEL_ID = 'markdown-preview'; + +/** Minimal readable stylesheet for the preview body (applied inside the shadow root). */ +const BASE_CSS = ` +.md-body { font: 14px/1.6 'Segoe UI', Tahoma, sans-serif; color: #222; padding: 12px 16px; } +.md-body h1, .md-body h2 { border-bottom: 1px solid #e0e0e0; padding-bottom: 4px; } +.md-body code { background: #f0f0f0; padding: 1px 4px; border-radius: 3px; font-family: 'Consolas', monospace; } +.md-body pre { background: #f6f6f6; padding: 10px; border-radius: 4px; overflow: auto; } +.md-body pre code { background: none; padding: 0; } +.md-body table { border-collapse: collapse; } +.md-body th, .md-body td { border: 1px solid #ccc; padding: 4px 8px; } +.md-body blockquote { border-left: 3px solid #ccc; margin: 0; padding-left: 12px; color: #555; } +.md-body img { max-width: 100%; } +`; + +let ctx: PluginContext; +let disposers: Array<() => void> = []; +let customCss = ''; +/** Re-apply CSS to the mounted preview, if any. */ +let applyCssToMounted: (() => void) | null = null; + +function activeContent(): string { + return ctx.store.active()?.content ?? ''; +} + +function togglePreview(forceOpen = false): void { + const visible = ctx.dockManager.isPanelVisible(PANEL_ID); + if (forceOpen && visible) return; + ctx.dockManager.togglePanel(PANEL_ID); +} + +/** Mount the preview UI into a dock panel element; returns a cleanup function. */ +function mountPreview(host: HTMLElement): () => void { + host.style.cssText = 'height:100%;width:100%;overflow:auto;box-sizing:border-box;'; + const shadow = host.attachShadow({ mode: 'open' }); + const styleEl = document.createElement('style'); + const body = document.createElement('div'); + body.className = 'md-body'; + shadow.append(styleEl, body); + + const applyCss = (): void => { + styleEl.textContent = `${BASE_CSS}\n${customCss}`; + }; + applyCss(); + applyCssToMounted = applyCss; + + const render = (): void => { + body.innerHTML = renderMarkdown(activeContent()); + }; + render(); + + // Auto-refresh on any store change (edits, tab switch), debounced. + let timer: ReturnType | null = null; + const unsub = ctx.store.subscribe(() => { + if (timer) clearTimeout(timer); + timer = setTimeout(render, 150); + }); + + // Synced scroll: map the editor's scroll fraction onto the preview. + const scroller = ctx.getFocusedView().scrollDOM; + const onScroll = (): void => { + const sMax = scroller.scrollHeight - scroller.clientHeight; + if (sMax <= 0) return; + const pct = scroller.scrollTop / sMax; + host.scrollTop = pct * (host.scrollHeight - host.clientHeight); + }; + scroller.addEventListener('scroll', onScroll, { passive: true }); + + // Open links in a new tab rather than navigating the panel. + body.addEventListener('click', (e) => { + const a = (e.target as HTMLElement).closest('a'); + if (a && a.getAttribute('href')) { + e.preventDefault(); + window.open(a.getAttribute('href')!, '_blank', 'noopener,noreferrer'); + } + }); + + const cleanup = (): void => { + unsub(); + scroller.removeEventListener('scroll', onScroll); + if (timer) clearTimeout(timer); + if (applyCssToMounted === applyCss) applyCssToMounted = null; + }; + disposers.push(cleanup); + return cleanup; +} + +/** Download the rendered document as a self-contained HTML file. */ +function exportHtml(): void { + const doc = ctx.store.active(); + const bodyHtml = renderMarkdown(doc?.content ?? ''); + const full = `${ + doc?.name ?? 'document' + }${bodyHtml}`; + const blob = new Blob([full], { type: 'text/html' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${(doc?.name ?? 'document').replace(/\.[^.]+$/, '')}.html`; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +/** Options dialog: custom CSS + the file extensions to treat as Markdown. */ +function openOptions(): void { + const overlay = document.createElement('div'); + overlay.className = 'dialog-overlay'; + const box = document.createElement('div'); + box.className = 'dialog-box'; + box.setAttribute('role', 'dialog'); + box.setAttribute('aria-label', 'MarkdownViewer++ Options'); + overlay.appendChild(box); + + box.innerHTML = ` +
MarkdownViewer++ Options
+ +
+ + +
`; + const textarea = box.querySelector('#md-css')!; + textarea.value = customCss; + + const close = (): void => overlay.remove(); + box.querySelector('#md-cancel')!.addEventListener('click', close); + box.querySelector('#md-save')!.addEventListener('click', () => { + customCss = textarea.value; + void ctx.storage.set('customCss', customCss); + applyCssToMounted?.(); + close(); + }); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) close(); + }); + + document.body.appendChild(overlay); + textarea.focus(); +} + +function setMenu(): void { + ctx.setMenu({ + label: 'MarkdownViewer++', + items: [ + { label: 'Toggle Preview', accelerator: 'Ctrl+Shift+M', action: () => togglePreview() }, + { label: 'Export HTML…', action: () => exportHtml() }, + { label: '', separator: true }, + { label: 'Options…', action: () => openOptions() }, + ], + }); + ctx.refreshMenu(); +} + +const plugin: Plugin = { + async activate(c: PluginContext): Promise { + ctx = c; + customCss = (await c.storage.get('customCss')) ?? ''; + + c.dockManager.registerPanel({ + id: PANEL_ID, + title: 'Markdown Preview', + position: 'right', + render: (el) => mountPreview(el), + }); + + setMenu(); + + const onKey = (e: KeyboardEvent): void => { + if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'M' || e.key === 'm')) { + e.preventDefault(); + togglePreview(); + } + }; + document.addEventListener('keydown', onKey); + disposers.push(() => document.removeEventListener('keydown', onKey)); + + // Open the preview immediately so enabling gives instant feedback. + togglePreview(true); + }, + + deactivate(): void { + if (ctx.dockManager.isPanelVisible(PANEL_ID)) ctx.dockManager.togglePanel(PANEL_ID); + disposers.forEach((d) => d()); + disposers = []; + applyCssToMounted = null; + ctx.setMenu(null); + ctx.refreshMenu(); + }, +}; + +export default plugin; diff --git a/src/plugins/markdown/render.test.ts b/src/plugins/markdown/render.test.ts new file mode 100644 index 0000000..2f3fa8b --- /dev/null +++ b/src/plugins/markdown/render.test.ts @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import { describe, it, expect } from 'vitest'; +import { markdownToHtml } from './render'; + +// Note: markdownToHtml is the deterministic marked step. The DOMPurify +// sanitization step (renderMarkdown) needs a real DOM and is verified in the +// browser by tests/e2e/markdown-viewer.spec.ts (XSS stripping). + +describe('markdownToHtml', () => { + it('renders CommonMark basics', () => { + const html = markdownToHtml('# Title\n\nSome **bold** and *italic*.'); + expect(html).toContain('

Title

'); + expect(html).toContain('bold'); + expect(html).toContain('italic'); + }); + + it('renders GFM tables', () => { + const html = markdownToHtml('| a | b |\n|---|---|\n| 1 | 2 |'); + expect(html).toContain(''); + expect(html).toContain(''); + }); + + it('renders GFM task lists as checkboxes', () => { + const html = markdownToHtml('- [x] done\n- [ ] todo'); + expect(html).toContain('type="checkbox"'); + expect(html).toContain('checked'); + }); + + it('renders links and fenced code blocks', () => { + const html = markdownToHtml('[site](https://example.com)\n\n```\ncode\n```'); + expect(html).toContain('href="https://example.com"'); + expect(html).toContain('
');
+  });
+});
diff --git a/src/plugins/markdown/render.ts b/src/plugins/markdown/render.ts
new file mode 100644
index 0000000..21484a7
--- /dev/null
+++ b/src/plugins/markdown/render.ts
@@ -0,0 +1,40 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+/**
+ * Markdown → sanitized HTML rendering for the MarkdownViewer++ plugin.
+ *
+ * Pure and framework-free so it can be unit-tested directly. Uses `marked`
+ * (CommonMark + GitHub-Flavored Markdown: tables, task lists, strikethrough,
+ * autolinks) and sanitizes the result with DOMPurify — mandatory, since the
+ * output is injected as HTML and Markdown allows raw HTML passthrough.
+ *
+ * Both libraries are pure JS (no eval) and are imported ONLY from this plugin,
+ * so webpack keeps them in the plugin's on-demand chunk — never in editor.js.
+ */
+import { marked } from 'marked';
+import createDOMPurify from 'dompurify';
+
+marked.setOptions({ gfm: true, breaks: false });
+
+// Bind DOMPurify explicitly to the current window so it is active both in the
+// browser and under the test DOM (happy-dom) — the bare default export detects
+// no window in some environments and then sanitize() is a no-op passthrough.
+const DOMPurify = createDOMPurify(window);
+
+/**
+ * Convert Markdown to (unsanitized) HTML. Deterministic and DOM-free, so it is
+ * unit-tested directly. NEVER inject this without sanitizing — see renderMarkdown.
+ */
+export function markdownToHtml(src: string): string {
+  return marked.parse(src, { async: false }) as string;
+}
+
+/**
+ * Convert Markdown source to sanitized HTML safe to inject via innerHTML.
+ * DOMPurify's defaults strip '),
+    );
+    await enableMarkdown(page);
+    await expect(page.locator('.md-body h1')).toHaveText('Safe');
+    // No script element survived sanitization, and it never executed.
+    expect(await page.locator('.md-body script').count()).toBe(0);
+    expect(
+      await page.evaluate(() => (window as unknown as { __xss?: number }).__xss),
+    ).toBeUndefined();
+  });
+});
diff --git a/tests/e2e/menu.spec.ts b/tests/e2e/menu.spec.ts
index 617e514..b068b2e 100644
--- a/tests/e2e/menu.spec.ts
+++ b/tests/e2e/menu.spec.ts
@@ -12,12 +12,12 @@ async function gotoEditor(page: Parameters[1]>[0]['page'
 }
 
 test.describe('menu bar', () => {
-  test('menu bar is visible with 9 top-level menus (Encoding added between View and Language)', async ({
+  test('menu bar is visible with 10 top-level menus (Plugins added between Macro and Help)', async ({
     page,
   }) => {
     await gotoEditor(page);
     const menuItems = page.locator('#menubar [role="menuitem"]');
-    await expect(menuItems).toHaveCount(9);
+    await expect(menuItems).toHaveCount(10);
     const labels = await menuItems.allTextContents();
     expect(labels).toEqual([
       'File',
@@ -28,6 +28,7 @@ test.describe('menu bar', () => {
       'Language',
       'Settings',
       'Macro',
+      'Plugins',
       'Help',
     ]);
   });
@@ -120,8 +121,8 @@ test.describe('menu bar', () => {
       () => (window as unknown as Record).__appReady !== undefined,
     );
     await page.evaluate(() => (window as unknown as Record).__appReady);
-    // Open Help (9th button, index 8 — Encoding was added before Language).
-    await page.locator('#menubar button').nth(8).click();
+    // Open Help (10th button, index 9 — Plugins was added before Help).
+    await page.locator('#menubar button').nth(9).click();
     await page.locator('[role="menu"] .menubar-entry').filter({ hasText: 'About Notepad' }).click();
     expect(dialogSeen).toBe(true);
   });

From 878ce57afe06610bdbfbda534bfb8102f131bd31 Mon Sep 17 00:00:00 2001
From: Claude 
Date: Thu, 9 Jul 2026 17:09:18 +0700
Subject: [PATCH 3/7] fix(plugins): markdown preview blank after reload
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

A persisted dock layout restores the Markdown Preview panel on reload BEFORE
the lazily-loaded plugin re-registers its renderer, so the panel mounted as a
blank fallback with no content. On activate, close any such stale restored
instance so togglePreview() re-mounts it with the real renderer.

Adds an e2e regression test: enable → reload → preview still renders.

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 src/plugins/markdown/index.ts     |  8 ++++++++
 tests/e2e/markdown-viewer.spec.ts | 21 +++++++++++++++++++++
 2 files changed, 29 insertions(+)

diff --git a/src/plugins/markdown/index.ts b/src/plugins/markdown/index.ts
index 43db95a..4037a90 100644
--- a/src/plugins/markdown/index.ts
+++ b/src/plugins/markdown/index.ts
@@ -180,6 +180,14 @@ const plugin: Plugin = {
       render: (el) => mountPreview(el),
     });
 
+    // A persisted dock layout can restore this panel on reload BEFORE the (lazy)
+    // plugin re-registers its renderer above — leaving a blank fallback with no
+    // content. Close any such stale instance so togglePreview() below re-mounts
+    // it with the real renderer.
+    if (c.dockManager.isPanelVisible(PANEL_ID)) {
+      c.dockManager.togglePanel(PANEL_ID);
+    }
+
     setMenu();
 
     const onKey = (e: KeyboardEvent): void => {
diff --git a/tests/e2e/markdown-viewer.spec.ts b/tests/e2e/markdown-viewer.spec.ts
index 9bddf4b..3ca0fff 100644
--- a/tests/e2e/markdown-viewer.spec.ts
+++ b/tests/e2e/markdown-viewer.spec.ts
@@ -73,6 +73,27 @@ test.describe('MarkdownViewer++ plugin', () => {
     await expect(page.locator('.md-body h1')).toHaveText('Two', { timeout: 5000 });
   });
 
+  test('preview still renders after a reload (persisted enable + restored layout)', async ({
+    page,
+  }) => {
+    await ready(page);
+    await page.evaluate(() =>
+      (window as unknown as Win).__setActiveDocContent('# Persisted'),
+    );
+    await enableMarkdown(page);
+    await expect(page.locator('.md-body h1')).toHaveText('Persisted');
+
+    // Let the debounced session save flush so the doc content persists.
+    await page.waitForTimeout(800);
+
+    // Reload: the plugin is restored from persistence and the dock layout is
+    // restored too — the preview must re-mount with real content, not a blank.
+    await page.reload();
+    await page.waitForFunction(() => (window as unknown as Win).__appReady !== undefined);
+    await page.evaluate(() => (window as unknown as Win).__appReady);
+    await expect(page.locator('.md-body h1')).toHaveText('Persisted', { timeout: 5000 });
+  });
+
   test('sanitizes dangerous HTML (no script element in the preview)', async ({ page }) => {
     await ready(page);
     await page.evaluate(() =>

From e55356d5c1816d1bfed43079c55899f068532e6e Mon Sep 17 00:00:00 2001
From: Claude 
Date: Thu, 9 Jul 2026 21:38:52 +0700
Subject: [PATCH 4/7] =?UTF-8?q?feat(plugins):=20ComparePlus=20=E2=80=94=20?=
 =?UTF-8?q?side-by-side=20diff=20plugin?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Ports the browser-feasible subset of Notepad++ ComparePlus, lazy-loaded like
MarkdownViewer++ (jsdiff lives only in the plugin chunk, never in editor.js).

- diff-engine.ts (pure, tested): line diff with change-pairing + inline char
  diff, ignore whitespace/case/empty-lines, heuristic move detection, and a
  set-based "find unique lines" mode.
- Renders into a generic always-present overlay decoration slot
  (src/editor/plugin-overlay.ts) added to both editor panes — avoids appending
  a StateField to a freshly-created split pane (which doesn't render reliably).
  Decorations are painted after a layout frame so the new pane is sized.
- Uses the split view (new SplitApi on PluginContext / App.getSplitApi): left =
  previous tab, right = active tab. Modes: Compare, vs Clipboard, vs Last Save,
  Find Unique Lines. Next/Prev/First/Last diff navigation. Clear Results.
  Settings dialog for the ignore/move/char options.
- Git/SVN diff and Compare Selections are disabled (not feasible / not yet).

Also: CHANGELOG entry for the plugins feature; docs/plugins/compareplus.md
(user flows). Perf verified: editor.js has 0 refs to jsdiff/DOMPurify.

Tests: diff-engine unit tests (add/remove/change/char/ignore/moves/unique);
compare-plus.spec.ts e2e (split + diff highlights, Clear Results). 660 unit +
192 e2e pass; typecheck, lint, build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 CHANGELOG.md                            |  13 ++
 docs/plugins/compareplus.md             |  58 ++++++
 package-lock.json                       |  10 ++
 package.json                            |   1 +
 src/app/app.ts                          |  20 +++
 src/editor-page.ts                      |  11 +-
 src/editor/plugin-overlay.ts            |  28 +++
 src/plugins/available.ts                |   7 +
 src/plugins/compare/decorations.ts      |  46 +++++
 src/plugins/compare/diff-engine.test.ts |  57 ++++++
 src/plugins/compare/diff-engine.ts      | 203 +++++++++++++++++++++
 src/plugins/compare/index.ts            | 228 ++++++++++++++++++++++++
 src/plugins/plugin-api.ts               |  17 +-
 src/plugins/registry.test.ts            |   1 +
 src/plugins/registry.ts                 |   5 +-
 src/styles.css                          |  21 +++
 tests/e2e/compare-plus.spec.ts          |  93 ++++++++++
 tests/e2e/markdown-viewer.spec.ts       |   4 +-
 18 files changed, 816 insertions(+), 7 deletions(-)
 create mode 100644 docs/plugins/compareplus.md
 create mode 100644 src/editor/plugin-overlay.ts
 create mode 100644 src/plugins/compare/decorations.ts
 create mode 100644 src/plugins/compare/diff-engine.test.ts
 create mode 100644 src/plugins/compare/diff-engine.ts
 create mode 100644 src/plugins/compare/index.ts
 create mode 100644 tests/e2e/compare-plus.spec.ts

diff --git a/CHANGELOG.md b/CHANGELOG.md
index f8cff1b..647dcc2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   provides one, falling back to the file name. Multiple files can be dropped at
   once, and they open in the focused split pane.
 
+- **Plugins system + first two plugins.** A Notepad++-style **Plugins** menu with
+  a **Plugins Manager** modal. Plugins are lazy-loaded — their code and libraries
+  are fetched on demand only when enabled (never bundled into startup), and the
+  enabled set is remembered across reloads.
+  - **MarkdownViewer++** — live, docked Markdown preview (CommonMark + GFM),
+    sanitized, with synced scroll, auto-refresh, **Ctrl+Shift+M** toggle, custom
+    CSS, and HTML export (PDF via the browser Print dialog).
+  - **ComparePlus** — side-by-side file comparison using the split view, with
+    line + inline character diffs (added / removed / changed / moved), compare
+    against clipboard or last-saved, find unique lines, diff navigation, and
+    ignore-whitespace/case/empty-line + move-detection options. (Git/SVN diff is
+    not possible in a browser and is omitted.)
+
 ### Fixed
 
 - **New tabs focus the editor.** Opening a tab via the `+` button or
diff --git a/docs/plugins/compareplus.md b/docs/plugins/compareplus.md
new file mode 100644
index 0000000..be195a3
--- /dev/null
+++ b/docs/plugins/compareplus.md
@@ -0,0 +1,58 @@
+# ComparePlus
+
+Side-by-side file comparison, ported (browser-feasible subset) from the Notepad++
+[ComparePlus](https://github.com/pnedev/comparePlus) plugin.
+
+## Enable
+
+**Plugins → Plugins Manager… → Enable** next to *ComparePlus*. A **ComparePlus**
+submenu appears under **Plugins**.
+
+## User flows
+
+### Compare two open files
+
+1. Open the two files you want to compare (e.g. drag both onto the window).
+2. Make the newer/edited file the active tab.
+3. **Plugins → ComparePlus → Compare**. The window splits: the previous file on
+   the left, the active file on the right, with differences highlighted:
+   - **green** = line only in the right file (added)
+   - **red** = line only in the left file (removed)
+   - **yellow** = changed line (with the exact changed characters highlighted inline)
+   - **blue** = moved line (when *Detect moved lines* is on)
+4. Jump between differences with **Next / Previous / First / Last Diff**.
+5. **Clear Results** removes the highlighting (the files stay open).
+
+### Compare against the clipboard
+
+**Plugins → ComparePlus → Compare against Clipboard** compares the active file
+against the current clipboard text (opened as a temporary "Clipboard" tab).
+
+### Compare against the last saved version
+
+**Plugins → ComparePlus → Compare against Last Save** compares the active file
+(with your unsaved edits) against its on-disk contents — useful to review what
+you changed since the last save. Requires the file to have been opened from disk.
+
+### Find unique lines
+
+**Plugins → ComparePlus → Find Unique Lines** highlights lines that appear in one
+file but not the other, regardless of position.
+
+## Settings
+
+**Plugins → ComparePlus → Settings…**:
+
+- **Ignore whitespace** — treat lines differing only in spacing as equal.
+- **Ignore letter case**.
+- **Ignore empty lines**.
+- **Detect moved lines** — mark relocated lines (blue) instead of add/remove.
+- **Show inline character differences** — highlight the exact changed characters
+  within changed lines.
+
+## Not supported (vs. the desktop plugin)
+
+- **Diff against Git / SVN** — the browser sandbox has no access to a version
+  control system, so this is disabled.
+- **Compare Selections** — not yet implemented (planned).
+- Only **one comparison at a time** (the desktop plugin allows several pairs).
diff --git a/package-lock.json b/package-lock.json
index d175bd1..54d5dc7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,6 +29,7 @@
         "@codemirror/state": "^6.7.0",
         "@codemirror/view": "^6.43.4",
         "@lezer/highlight": "^1.2.3",
+        "diff": "^9.0.0",
         "dockview-core": "^7.0.2",
         "dompurify": "^3.4.11",
         "marked": "^18.0.5",
@@ -2929,6 +2930,15 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/diff": {
+      "version": "9.0.0",
+      "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz",
+      "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.3.1"
+      }
+    },
     "node_modules/dockview-core": {
       "version": "7.0.2",
       "resolved": "https://registry.npmjs.org/dockview-core/-/dockview-core-7.0.2.tgz",
diff --git a/package.json b/package.json
index 9a1fb65..1fc6271 100644
--- a/package.json
+++ b/package.json
@@ -58,6 +58,7 @@
     "@codemirror/state": "^6.7.0",
     "@codemirror/view": "^6.43.4",
     "@lezer/highlight": "^1.2.3",
+    "diff": "^9.0.0",
     "dockview-core": "^7.0.2",
     "dompurify": "^3.4.11",
     "marked": "^18.0.5",
diff --git a/src/app/app.ts b/src/app/app.ts
index 6b980c1..e428824 100644
--- a/src/app/app.ts
+++ b/src/app/app.ts
@@ -1638,6 +1638,26 @@ export class App {
     this.renderMenuBarFn?.();
   }
 
+  /** Split-view control surface exposed to plugins (e.g. ComparePlus). */
+  getSplitApi(): import('../plugins/plugin-api').SplitApi {
+    return {
+      isActive: () => this.secondary !== null,
+      ensure: (o) => {
+        if (!this.secondary) this.ensureSecondary(o);
+      },
+      primaryView: () => this.primaryView,
+      secondaryView: () => this.secondary?.view ?? null,
+      showDoc: (view, docId) => {
+        this.deps.store.moveToView(docId, view);
+        this.deps.store.setActiveForView(view, docId);
+        this.controllerFor(view)?.showDoc(docId);
+        this.primaryTabBar?.render();
+        this.secondaryTabBar?.render();
+      },
+      collapse: () => this.collapseSplit(),
+    };
+  }
+
   /** Open the Plugin Manager modal (wired to Plugins → Plugins Manager…). */
   private openPluginsManager(): void {
     if (this.deps.pluginRegistry) openPluginManager(this.deps.pluginRegistry);
diff --git a/src/editor-page.ts b/src/editor-page.ts
index a112c1c..a0eca2c 100644
--- a/src/editor-page.ts
+++ b/src/editor-page.ts
@@ -18,6 +18,7 @@ import { FileService } from './services/file-service';
 import { createStorage } from './services/chrome-adapter';
 import { App } from './app/app';
 import { EditorController } from './editor/editor-controller';
+import { overlayDecorationsField } from './editor/plugin-overlay';
 import { notepadBase } from './editor/notepad-light-theme';
 import { notepadHighlight } from './editor/notepad-theme';
 import { dockManager } from './app/dock-manager';
@@ -216,6 +217,8 @@ function buildSharedExtensions(
   viewId: ViewId,
 ): Extension[] {
   return [
+    // Generic overlay-decoration slot plugins fill (e.g. ComparePlus diffs).
+    overlayDecorationsField,
     lineNumbers(),
     highlightActiveLine(),
     history(),
@@ -486,14 +489,17 @@ const settings = new SettingsService(storage);
 const theme = new ThemeService(() => 'system');
 
 // Plugin registry — tiny; plugin CODE loads lazily on enable (dynamic import).
-// onMenusChanged is wired to the App after it is constructed (below).
+// onMenusChanged + getSplitApi are resolved via typed holders to break the
+// construction cycle (registry ↔ App).
 let refreshPluginMenus: () => void = () => {};
+let appRef: App | null = null;
 const pluginRegistry = new PluginRegistry(AVAILABLE_PLUGINS, {
   store,
   getFocusedView: () => focusedViewRef.current,
   getFocusedController: () => focusedControllerRef.current,
   dockManager,
   kv: storage,
+  getSplitApi: () => appRef!.getSplitApi(),
   onMenusChanged: () => refreshPluginMenus(),
 });
 
@@ -519,7 +525,8 @@ const app = new App({
   pluginRegistry,
 });
 
-// Now that App exists, route plugin-driven menu refreshes to it.
+// Now that App exists, route plugin-driven menu refreshes + split access to it.
+appRef = app;
 refreshPluginMenus = () => app.refreshMenus();
 
 // ── Expose dockManager + panel toggles globally for App to wire up menu ──────
diff --git a/src/editor/plugin-overlay.ts b/src/editor/plugin-overlay.ts
new file mode 100644
index 0000000..2c0c3aa
--- /dev/null
+++ b/src/editor/plugin-overlay.ts
@@ -0,0 +1,28 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+/**
+ * A generic, always-present decoration slot that plugins can fill without having
+ * to append their own StateField to a running view (appending a field to a
+ * freshly-created split pane doesn't reliably render). Included in every editor
+ * view's shared extensions; empty until a plugin dispatches setOverlayDecos.
+ *
+ * Currently used by the ComparePlus plugin for diff highlights. If two plugins
+ * ever need independent overlays, promote this to a keyed registry.
+ */
+import { StateField, StateEffect } from '@codemirror/state';
+import { Decoration, EditorView } from '@codemirror/view';
+import type { DecorationSet } from '@codemirror/view';
+
+/** Replace the overlay decorations for a view (empty set clears them). */
+export const setOverlayDecos = StateEffect.define();
+
+export const overlayDecorationsField = StateField.define({
+  create: () => Decoration.none,
+  update(deco, tr) {
+    deco = deco.map(tr.changes);
+    for (const e of tr.effects) {
+      if (e.is(setOverlayDecos)) deco = e.value;
+    }
+    return deco;
+  },
+  provide: (f) => EditorView.decorations.from(f),
+});
diff --git a/src/plugins/available.ts b/src/plugins/available.ts
index 413db39..7e465cf 100644
--- a/src/plugins/available.ts
+++ b/src/plugins/available.ts
@@ -14,4 +14,11 @@ export const AVAILABLE_PLUGINS: AvailablePlugin[] = [
       'Live Markdown preview in a docked panel — synced scroll, GFM, custom CSS, and HTML export.',
     load: () => import('./markdown/index'),
   },
+  {
+    id: 'compare-plus',
+    name: 'ComparePlus',
+    description:
+      'Compare two files side-by-side with line + inline character diffs, move detection, and diff navigation.',
+    load: () => import('./compare/index'),
+  },
 ];
diff --git a/src/plugins/compare/decorations.ts b/src/plugins/compare/decorations.ts
new file mode 100644
index 0000000..4e1f4e7
--- /dev/null
+++ b/src/plugins/compare/decorations.ts
@@ -0,0 +1,46 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+/**
+ * CM6 decoration building for ComparePlus. The decorations are painted into the
+ * generic overlay slot that every editor view already carries (see
+ * src/editor/plugin-overlay.ts) — no per-view field appending, which does not
+ * render reliably on a freshly-created split pane.
+ */
+import type { EditorState } from '@codemirror/state';
+import { Decoration } from '@codemirror/view';
+import type { DecorationSet } from '@codemirror/view';
+import type { LineMark, CharSeg } from './diff-engine';
+
+export { setOverlayDecos } from '../../editor/plugin-overlay';
+
+/**
+ * Build a DecorationSet for one pane from its line marks + inline char ranges.
+ * `charClass` styles the inline ranges (deletion vs insertion colour).
+ */
+export function buildCompareDecorations(
+  state: EditorState,
+  marks: LineMark[],
+  charSegs: CharSeg[],
+  charClass: string,
+): DecorationSet {
+  const total = state.doc.lines;
+  const clampLine = (n: number): number => Math.max(1, Math.min(n, total));
+  const ranges = [];
+
+  for (const m of marks) {
+    const line = state.doc.line(clampLine(m.line));
+    ranges.push(Decoration.line({ class: `cmp-line-${m.type}` }).range(line.from));
+  }
+  for (const c of charSegs) {
+    const line = state.doc.line(clampLine(c.line));
+    const from = line.from + Math.max(0, Math.min(c.from, line.length));
+    const to = line.from + Math.max(0, Math.min(c.to, line.length));
+    if (to > from) ranges.push(Decoration.mark({ class: charClass }).range(from, to));
+  }
+  // Decoration.set(sort=true) orders line + mark ranges correctly.
+  return Decoration.set(ranges, true);
+}
+
+/** All distinct changed line numbers for a side, sorted ascending (for navigation). */
+export function diffLineNumbers(marks: LineMark[]): number[] {
+  return [...new Set(marks.map((m) => m.line))].sort((a, b) => a - b);
+}
diff --git a/src/plugins/compare/diff-engine.test.ts b/src/plugins/compare/diff-engine.test.ts
new file mode 100644
index 0000000..6e3586d
--- /dev/null
+++ b/src/plugins/compare/diff-engine.test.ts
@@ -0,0 +1,57 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+import { describe, it, expect } from 'vitest';
+import { computeCompare } from './diff-engine';
+
+const types = (marks: { line: number; type: string }[]) =>
+  marks.map((m) => `${m.line}:${m.type}`).sort();
+
+describe('computeCompare', () => {
+  it('marks added lines (only in B)', () => {
+    const r = computeCompare('a\nb\n', 'a\nx\nb\n');
+    expect(types(r.right)).toContain('2:added');
+    expect(r.left).toHaveLength(0);
+  });
+
+  it('marks removed lines (only in A)', () => {
+    const r = computeCompare('a\nb\nc\n', 'a\nc\n');
+    expect(types(r.left)).toContain('2:removed');
+    expect(r.right).toHaveLength(0);
+  });
+
+  it('marks changed lines on both sides and emits char ranges', () => {
+    const r = computeCompare('hello world\n', 'hello brave world\n', { charDiff: true });
+    expect(types(r.left)).toEqual(['1:changed']);
+    expect(types(r.right)).toEqual(['1:changed']);
+    // The inserted "brave " is highlighted on the right.
+    expect(r.rightChars.length).toBeGreaterThan(0);
+    expect(r.rightChars[0]!.line).toBe(1);
+  });
+
+  it('ignoreWhitespace treats whitespace-only differences as equal', () => {
+    const noOpt = computeCompare('a\nb\n', 'a\n  b  \n');
+    expect(noOpt.right.length + noOpt.left.length).toBeGreaterThan(0);
+    const ignore = computeCompare('a\nb\n', 'a\n  b  \n', { ignoreWhitespace: true });
+    expect(ignore.right.length + ignore.left.length).toBe(0);
+  });
+
+  it('ignoreEmptyLines skips blank-only add/remove', () => {
+    const r = computeCompare('a\nb\n', 'a\n\n\nb\n', { ignoreEmptyLines: true });
+    expect(r.right.filter((m) => m.type === 'added')).toHaveLength(0);
+  });
+
+  it('detects a moved line', () => {
+    // "move" is deleted near the top and re-appears at the bottom.
+    const r = computeCompare('move\na\nb\n', 'a\nb\nmove\n', { detectMoves: true });
+    expect(r.left.some((m) => m.type === 'moved')).toBe(true);
+    expect(r.right.some((m) => m.type === 'moved')).toBe(true);
+    // No stray removed/added left after the move is paired.
+    expect(r.left.every((m) => m.type === 'moved')).toBe(true);
+    expect(r.right.every((m) => m.type === 'moved')).toBe(true);
+  });
+
+  it('returns nothing for identical texts', () => {
+    const r = computeCompare('a\nb\nc\n', 'a\nb\nc\n');
+    expect(r.left).toHaveLength(0);
+    expect(r.right).toHaveLength(0);
+  });
+});
diff --git a/src/plugins/compare/diff-engine.ts b/src/plugins/compare/diff-engine.ts
new file mode 100644
index 0000000..6f1698a
--- /dev/null
+++ b/src/plugins/compare/diff-engine.ts
@@ -0,0 +1,203 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+/**
+ * ComparePlus diff engine — pure and DOM-free so it can be unit-tested.
+ *
+ * Produces per-line marks for two documents (A = left, B = right) plus optional
+ * inline character-level ranges on changed lines, mirroring ComparePlus's
+ * added / removed / changed / moved model. Uses jsdiff (pure JS, no eval).
+ */
+import { diffLines, diffWordsWithSpace } from 'diff';
+
+export type LineDiffType = 'added' | 'removed' | 'changed' | 'moved';
+
+/** A per-line mark. `line` is 1-based. */
+export interface LineMark {
+  line: number;
+  type: LineDiffType;
+}
+
+/** An inline char range on a changed line. `line` is 1-based; from/to are column offsets. */
+export interface CharSeg {
+  line: number;
+  from: number;
+  to: number;
+}
+
+export interface CompareResult {
+  left: LineMark[];
+  right: LineMark[];
+  leftChars: CharSeg[];
+  rightChars: CharSeg[];
+}
+
+export interface CompareOptions {
+  ignoreWhitespace?: boolean;
+  ignoreCase?: boolean;
+  ignoreEmptyLines?: boolean;
+  detectMoves?: boolean;
+  charDiff?: boolean;
+}
+
+/** Split into lines without a trailing empty element for a final newline. */
+function splitLines(text: string): string[] {
+  const lines = text.split('\n');
+  if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
+  return lines;
+}
+
+/** Character-level diff between two single lines → highlight ranges per side. */
+function charDiffForLine(
+  a: string,
+  b: string,
+): { left: [number, number][]; right: [number, number][] } {
+  const parts = diffWordsWithSpace(a, b);
+  const left: [number, number][] = [];
+  const right: [number, number][] = [];
+  let ca = 0;
+  let cb = 0;
+  for (const p of parts) {
+    const len = p.value.length;
+    if (p.added) {
+      right.push([cb, cb + len]);
+      cb += len;
+    } else if (p.removed) {
+      left.push([ca, ca + len]);
+      ca += len;
+    } else {
+      ca += len;
+      cb += len;
+    }
+  }
+  return { left, right };
+}
+
+const isBlank = (s: string): boolean => s.trim() === '';
+
+/**
+ * Compare two texts. Returns per-line marks (1-based) for each side and, when
+ * `charDiff` is set, inline char ranges on changed lines.
+ */
+export function computeCompare(
+  textA: string,
+  textB: string,
+  opts: CompareOptions = {},
+): CompareResult {
+  const changes = diffLines(textA, textB, {
+    ignoreWhitespace: opts.ignoreWhitespace,
+    // jsdiff exposes ignoreCase on the base options.
+    ...(opts.ignoreCase ? { ignoreCase: true } : {}),
+  });
+
+  const result: CompareResult = { left: [], right: [], leftChars: [], rightChars: [] };
+  let lineA = 1;
+  let lineB = 1;
+
+  const markLeft = (line: number, type: LineDiffType, content: string): void => {
+    if (opts.ignoreEmptyLines && isBlank(content)) return;
+    result.left.push({ line, type });
+  };
+  const markRight = (line: number, type: LineDiffType, content: string): void => {
+    if (opts.ignoreEmptyLines && isBlank(content)) return;
+    result.right.push({ line, type });
+  };
+
+  for (let i = 0; i < changes.length; i++) {
+    const change = changes[i]!;
+    const lines = splitLines(change.value);
+
+    if (!change.added && !change.removed) {
+      lineA += lines.length;
+      lineB += lines.length;
+      continue;
+    }
+
+    // A removed run immediately followed by an added run is a CHANGED block:
+    // pair lines 1:1 (and char-diff them); any surplus lines are pure add/remove.
+    if (change.removed && changes[i + 1]?.added) {
+      const removedLines = lines;
+      const addedLines = splitLines(changes[i + 1]!.value);
+      const paired = Math.min(removedLines.length, addedLines.length);
+      for (let k = 0; k < paired; k++) {
+        markLeft(lineA + k, 'changed', removedLines[k]!);
+        markRight(lineB + k, 'changed', addedLines[k]!);
+        if (opts.charDiff) {
+          const { left, right } = charDiffForLine(removedLines[k]!, addedLines[k]!);
+          for (const [from, to] of left) result.leftChars.push({ line: lineA + k, from, to });
+          for (const [from, to] of right) result.rightChars.push({ line: lineB + k, from, to });
+        }
+      }
+      for (let k = paired; k < removedLines.length; k++)
+        markLeft(lineA + k, 'removed', removedLines[k]!);
+      for (let k = paired; k < addedLines.length; k++)
+        markRight(lineB + k, 'added', addedLines[k]!);
+      lineA += removedLines.length;
+      lineB += addedLines.length;
+      i++; // consumed the paired added run
+      continue;
+    }
+
+    if (change.removed) {
+      lines.forEach((l, k) => markLeft(lineA + k, 'removed', l));
+      lineA += lines.length;
+    } else {
+      lines.forEach((l, k) => markRight(lineB + k, 'added', l));
+      lineB += lines.length;
+    }
+  }
+
+  if (opts.detectMoves) detectMoves(result, textA, textB);
+  return result;
+}
+
+/**
+ * "Find unique lines" mode: mark lines that appear in one document but not the
+ * other (set difference, position-independent), honoring the ignore options.
+ */
+export function computeUniqueLines(
+  textA: string,
+  textB: string,
+  opts: CompareOptions = {},
+): CompareResult {
+  const norm = (s: string): string => {
+    let x = s;
+    if (opts.ignoreWhitespace) x = x.trim();
+    if (opts.ignoreCase) x = x.toLowerCase();
+    return x;
+  };
+  const aLines = splitLines(textA);
+  const bLines = splitLines(textB);
+  const aSet = new Set(aLines.map(norm));
+  const bSet = new Set(bLines.map(norm));
+  const result: CompareResult = { left: [], right: [], leftChars: [], rightChars: [] };
+  aLines.forEach((l, i) => {
+    if (opts.ignoreEmptyLines && isBlank(l)) return;
+    if (!bSet.has(norm(l))) result.left.push({ line: i + 1, type: 'removed' });
+  });
+  bLines.forEach((l, i) => {
+    if (opts.ignoreEmptyLines && isBlank(l)) return;
+    if (!aSet.has(norm(l))) result.right.push({ line: i + 1, type: 'added' });
+  });
+  return result;
+}
+
+/**
+ * Heuristic move detection: a pure-removed line in A whose exact content appears
+ * as a pure-added line in B (one-to-one) is re-tagged 'moved' on both sides.
+ */
+function detectMoves(result: CompareResult, textA: string, textB: string): void {
+  const aLines = splitLines(textA);
+  const bLines = splitLines(textB);
+  const removed = result.left.filter((m) => m.type === 'removed');
+  const added = result.right.filter((m) => m.type === 'added');
+  const usedB = new Set();
+  for (const r of removed) {
+    const content = aLines[r.line - 1] ?? '';
+    if (content.trim() === '') continue;
+    const match = added.find((a) => !usedB.has(a.line) && (bLines[a.line - 1] ?? '') === content);
+    if (match) {
+      usedB.add(match.line);
+      r.type = 'moved';
+      match.type = 'moved';
+    }
+  }
+}
diff --git a/src/plugins/compare/index.ts b/src/plugins/compare/index.ts
new file mode 100644
index 0000000..790be48
--- /dev/null
+++ b/src/plugins/compare/index.ts
@@ -0,0 +1,228 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+/**
+ * ComparePlus plugin — side-by-side file comparison, ported (browser-feasible
+ * subset) from https://github.com/pnedev/comparePlus.
+ *
+ * Uses the app's split view for the two panes and CM6 decorations for line
+ * highlights (added / removed / changed / moved) plus inline character diffs.
+ * Compare modes: two open files, vs clipboard, vs last save, and find unique
+ * lines. Navigation jumps between diffs. Git/SVN diff is not possible in the
+ * browser sandbox and is intentionally omitted.
+ *
+ * Loaded only via dynamic import from the plugin registry (jsdiff lives in this
+ * chunk, never in editor.js).
+ */
+import { Decoration, type EditorView } from '@codemirror/view';
+import type { Plugin, PluginContext } from '../plugin-api';
+import type { DocId } from '../../services/document-store';
+import { computeCompare, computeUniqueLines, type CompareOptions } from './diff-engine';
+import { setOverlayDecos, buildCompareDecorations, diffLineNumbers } from './decorations';
+
+let ctx: PluginContext;
+let options: CompareOptions = {
+  ignoreWhitespace: false,
+  ignoreCase: false,
+  ignoreEmptyLines: false,
+  detectMoves: true,
+  charDiff: true,
+};
+/** Sorted changed-line numbers for diff navigation. */
+let navLines: number[] = [];
+let navIdx = -1;
+
+/** Append the compare field to a view once, then set/replace its decorations. */
+function applyDecos(
+  view: EditorView | null,
+  marks: Parameters[1],
+  chars: Parameters[2],
+  charClass: string,
+): void {
+  if (!view) return;
+  const decos = buildCompareDecorations(view.state, marks, chars, charClass);
+  view.dispatch({ effects: setOverlayDecos.of(decos) });
+}
+
+/** Set up the split, place both docs, compute the diff, and paint both panes. */
+function runCompare(leftId: DocId, rightId: DocId, unique = false): void {
+  ctx.split.ensure('v');
+  // Populate the secondary pane FIRST: a store emit while pane 1 is empty would
+  // trip the app's collapse-on-empty guard and tear the split down mid-setup.
+  ctx.split.showDoc(1, rightId);
+  ctx.split.showDoc(0, leftId);
+
+  const left = ctx.store.get(leftId);
+  const right = ctx.store.get(rightId);
+  if (!left || !right) return;
+
+  const result = unique
+    ? computeUniqueLines(left.content, right.content, options)
+    : computeCompare(left.content, right.content, options);
+
+  // A just-created split pane is sized by dockview asynchronously; decorating it
+  // before its viewport exists renders nothing. Defer to the next frame so both
+  // panes are laid out, then paint and jump to the first diff.
+  const paint = (): void => {
+    applyDecos(ctx.split.primaryView(), result.left, result.leftChars, 'cmp-char-del');
+    applyDecos(ctx.split.secondaryView(), result.right, result.rightChars, 'cmp-char-ins');
+    navLines = diffLineNumbers(result.right);
+    navIdx = -1;
+    if (navLines.length > 0) gotoDiff(0);
+  };
+  requestAnimationFrame(() => requestAnimationFrame(paint));
+}
+
+function gotoDiff(idx: number): void {
+  if (navLines.length === 0) return;
+  navIdx = ((idx % navLines.length) + navLines.length) % navLines.length;
+  const target = navLines[navIdx]!;
+  for (const v of [ctx.split.secondaryView(), ctx.split.primaryView()]) {
+    if (!v) continue;
+    const line = v.state.doc.line(Math.max(1, Math.min(target, v.state.doc.lines)));
+    v.dispatch({ selection: { anchor: line.from }, scrollIntoView: true });
+  }
+}
+
+function clearResults(): void {
+  for (const v of [ctx.split.primaryView(), ctx.split.secondaryView()]) {
+    if (v) v.dispatch({ effects: setOverlayDecos.of(Decoration.none) });
+  }
+  navLines = [];
+  navIdx = -1;
+}
+
+// ── Compare mode commands ─────────────────────────────────────────────────────
+
+/**
+ * Choose the two documents to compare: the active one (right) and its neighbour
+ * in tab order (left) — i.e. compare the current file with the previous tab.
+ */
+function pickCompareTargets(action: string): [DocId, DocId] | null {
+  const docs = ctx.store.list();
+  if (docs.length < 2) {
+    alert(`Open at least two files, then run ${action}.`);
+    return null;
+  }
+  const active = ctx.store.active() ?? docs[docs.length - 1]!;
+  const idx = docs.findIndex((d) => d.id === active.id);
+  const other = docs[idx - 1] ?? docs[idx + 1] ?? docs.find((d) => d.id !== active.id)!;
+  return [other.id, active.id];
+}
+
+function comparePair(): void {
+  const t = pickCompareTargets('Compare');
+  if (t) runCompare(t[0], t[1]);
+}
+
+async function compareClipboard(): Promise {
+  const active = ctx.store.active();
+  if (!active) return;
+  let text: string;
+  try {
+    text = await navigator.clipboard.readText();
+  } catch {
+    alert('Could not read the clipboard (permission denied).');
+    return;
+  }
+  const temp = ctx.store.create({ name: 'Clipboard', content: text, dirty: false });
+  runCompare(active.id, temp.id);
+}
+
+async function compareLastSave(): Promise {
+  const active = ctx.store.active();
+  if (!active?.handle) {
+    alert('This document has no file on disk to compare against.');
+    return;
+  }
+  let saved: string;
+  try {
+    const raw = await (await active.handle.getFile()).text();
+    saved = raw.startsWith('') ? raw.slice(1) : raw;
+  } catch {
+    alert('Could not read the file from disk.');
+    return;
+  }
+  const temp = ctx.store.create({ name: `${active.name} (saved)`, content: saved, dirty: false });
+  runCompare(active.id, temp.id);
+}
+
+function findUniqueLines(): void {
+  const t = pickCompareTargets('Find Unique Lines');
+  if (t) runCompare(t[0], t[1], true);
+}
+
+function openOptions(): void {
+  const overlay = document.createElement('div');
+  overlay.className = 'dialog-overlay';
+  const box = document.createElement('div');
+  box.className = 'dialog-box';
+  box.setAttribute('role', 'dialog');
+  overlay.appendChild(box);
+  const row = (id: string, label: string, checked: boolean): string =>
+    ``;
+  box.innerHTML = `
+    
ComparePlus Settings
+ ${row('cmp-ws', 'Ignore whitespace', !!options.ignoreWhitespace)} + ${row('cmp-case', 'Ignore letter case', !!options.ignoreCase)} + ${row('cmp-empty', 'Ignore empty lines', !!options.ignoreEmptyLines)} + ${row('cmp-moves', 'Detect moved lines', !!options.detectMoves)} + ${row('cmp-char', 'Show inline character differences', !!options.charDiff)} +
`; + const close = (): void => overlay.remove(); + const val = (id: string): boolean => (box.querySelector(`#${id}`) as HTMLInputElement).checked; + box.querySelector('#cmp-cancel')!.addEventListener('click', close); + box.querySelector('#cmp-save')!.addEventListener('click', () => { + options = { + ignoreWhitespace: val('cmp-ws'), + ignoreCase: val('cmp-case'), + ignoreEmptyLines: val('cmp-empty'), + detectMoves: val('cmp-moves'), + charDiff: val('cmp-char'), + }; + void ctx.storage.set('options', options); + close(); + }); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) close(); + }); + document.body.appendChild(overlay); +} + +function setMenu(): void { + ctx.setMenu({ + label: 'ComparePlus', + items: [ + { label: 'Compare', action: () => comparePair() }, + { label: 'Compare against Clipboard', action: () => void compareClipboard() }, + { label: 'Compare against Last Save', action: () => void compareLastSave() }, + { label: 'Find Unique Lines', action: () => findUniqueLines() }, + { label: 'Compare Selections', enabled: false }, // not yet implemented + { label: '', separator: true }, + { label: 'Next Diff', action: () => gotoDiff(navIdx + 1) }, + { label: 'Previous Diff', action: () => gotoDiff(navIdx - 1) }, + { label: 'First Diff', action: () => gotoDiff(0) }, + { label: 'Last Diff', action: () => gotoDiff(navLines.length - 1) }, + { label: '', separator: true }, + { label: 'Clear Results', action: () => clearResults() }, + { label: 'Diff against Git/SVN', enabled: false }, // not supported in-browser + { label: '', separator: true }, + { label: 'Settings…', action: () => openOptions() }, + ], + }); + ctx.refreshMenu(); +} + +const plugin: Plugin = { + async activate(c: PluginContext): Promise { + ctx = c; + const saved = await c.storage.get('options'); + if (saved) options = { ...options, ...saved }; + setMenu(); + }, + deactivate(): void { + clearResults(); + ctx.setMenu(null); + ctx.refreshMenu(); + }, +}; + +export default plugin; diff --git a/src/plugins/plugin-api.ts b/src/plugins/plugin-api.ts index 5b13a04..190264c 100644 --- a/src/plugins/plugin-api.ts +++ b/src/plugins/plugin-api.ts @@ -11,11 +11,24 @@ * A plugin therefore only depends on this file (types) and receives everything * it needs through PluginContext at activate() time. */ -import type { DocumentStore } from '../services/document-store'; +import type { DocumentStore, DocId } from '../services/document-store'; import type { EditorController } from '../editor/editor-controller'; import type { EditorView } from '@codemirror/view'; import type { DockManager } from '../app/dock-manager'; +/** Split-view control surface for plugins that need two panes (e.g. ComparePlus). */ +export interface SplitApi { + isActive(): boolean; + /** Create the secondary pane if not present ('h' = stacked, 'v' = side-by-side). */ + ensure(orientation: 'h' | 'v'): void; + primaryView(): EditorView; + secondaryView(): EditorView | null; + /** Assign a document to a pane (0 = primary, 1 = secondary) and show + activate it. */ + showDoc(view: 0 | 1, docId: DocId): void; + /** Collapse the split back to a single pane. */ + collapse(): void; +} + /** A single entry in a plugin's Plugins-menu submenu. */ export interface PluginMenuItem { label: string; @@ -51,6 +64,8 @@ export interface PluginContext { getFocusedController(): EditorController; /** The dock manager, for registering/toggling panels and driving the split. */ dockManager: DockManager; + /** Split-view control (two panes) for plugins like ComparePlus. */ + split: SplitApi; /** Per-plugin persistent settings (keys are namespaced to the plugin id). */ storage: PluginStorage; /** diff --git a/src/plugins/registry.test.ts b/src/plugins/registry.test.ts index 4bbadf3..c36c0c5 100644 --- a/src/plugins/registry.test.ts +++ b/src/plugins/registry.test.ts @@ -20,6 +20,7 @@ function makeDeps(kv: KeyValueStore, onMenusChanged = vi.fn()): PluginRegistryDe getFocusedController: () => ({}) as never, dockManager: {} as never, kv, + getSplitApi: () => ({}) as never, onMenusChanged, }; } diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 24c62f5..6937cd8 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -13,7 +13,7 @@ import type { DocumentStore } from '../services/document-store'; import type { EditorController } from '../editor/editor-controller'; import type { EditorView } from '@codemirror/view'; import type { DockManager } from '../app/dock-manager'; -import type { AvailablePlugin, Plugin, PluginContext, PluginMenu } from './plugin-api'; +import type { AvailablePlugin, Plugin, PluginContext, PluginMenu, SplitApi } from './plugin-api'; const ENABLED_KEY = 'plugins-enabled'; @@ -23,6 +23,8 @@ export interface PluginRegistryDeps { getFocusedController: () => EditorController; dockManager: DockManager; kv: KeyValueStore; + /** Resolve the split-view API (from the App, once it exists). */ + getSplitApi: () => SplitApi; /** Called whenever active plugins / their menus change (re-render the menu bar). */ onMenusChanged: () => void; } @@ -109,6 +111,7 @@ export class PluginRegistry { getFocusedView: this.deps.getFocusedView, getFocusedController: this.deps.getFocusedController, dockManager: this.deps.dockManager, + split: this.deps.getSplitApi(), storage: { get: (key) => this.deps.kv.get(`plugin:${id}:${key}`), set: (key, value) => this.deps.kv.set(`plugin:${id}:${key}`, value), diff --git a/src/styles.css b/src/styles.css index 387ea23..fc51ee0 100644 --- a/src/styles.css +++ b/src/styles.css @@ -889,6 +889,27 @@ body { opacity: 0.6; } +/* ── ComparePlus diff decorations ─────────────────────────────────────────────── */ +.cm-line.cmp-line-added { + background: rgba(70, 180, 80, 0.18); +} +.cm-line.cmp-line-removed { + background: rgba(220, 70, 70, 0.16); +} +.cm-line.cmp-line-changed { + background: rgba(230, 200, 60, 0.18); +} +.cm-line.cmp-line-moved { + background: rgba(70, 130, 220, 0.16); +} +/* Inline character-level differences within changed lines. */ +.cmp-char-ins { + background: rgba(70, 180, 80, 0.4); +} +.cmp-char-del { + background: rgba(220, 70, 70, 0.35); +} + /* ── Context menu ────────────────────────────────────────────────────────────── */ .context-menu { position: fixed; diff --git a/tests/e2e/compare-plus.spec.ts b/tests/e2e/compare-plus.spec.ts new file mode 100644 index 0000000..3ee53bb --- /dev/null +++ b/tests/e2e/compare-plus.spec.ts @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +/** + * E2E for the ComparePlus plugin: enable via the Plugin Manager, open two docs, + * run Compare, and assert the side-by-side split + diff decorations. Also covers + * Clear Results. + */ +import { test, expect } from '@playwright/test'; + +type Win = Window & { __appReady?: unknown }; + +async function ready(page: Parameters[1]>[0]['page']) { + page.on('dialog', (d) => void d.accept()); + await page.goto('/editor.html'); + await page.evaluate(async () => { + const dbs = await indexedDB.databases(); + await Promise.all( + dbs.map( + (db) => + new Promise((res) => { + const r = indexedDB.deleteDatabase(db.name!); + r.onsuccess = () => res(); + r.onerror = () => res(); + }), + ), + ); + }); + await page.reload(); + await page.waitForFunction(() => (window as unknown as Win).__appReady !== undefined); + await page.evaluate(() => (window as unknown as Win).__appReady); +} + +async function dropFile( + page: Parameters[1]>[0]['page'], + name: string, + content: string, +) { + await page.evaluate( + ({ name, content }) => { + const dt = new DataTransfer(); + dt.items.add(new File([content], name, { type: 'text/plain' })); + document.dispatchEvent( + new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true }), + ); + }, + { name, content }, + ); +} + +async function enableCompare(page: Parameters[1]>[0]['page']) { + await page.getByRole('menuitem', { name: 'Plugins' }).click(); + await page.getByRole('menuitem', { name: 'Plugins Manager…' }).click(); + await page.locator('.plugin-row', { hasText: 'ComparePlus' }).locator('.plugin-toggle').click(); + await page.locator('.dialog-actions button', { hasText: 'Close' }).click(); +} + +async function runCompareMenu( + page: Parameters[1]>[0]['page'], + item: string, +) { + await page.getByRole('menuitem', { name: 'Plugins' }).click(); + await page.getByRole('menuitem', { name: 'ComparePlus' }).hover(); + await page.getByRole('menuitem', { name: item, exact: true }).click(); +} + +test.describe('ComparePlus plugin', () => { + test('Compare shows a side-by-side split with diff highlights', async ({ page }) => { + await ready(page); + // Two docs: line 2 changed, line 4 added in the second. + await dropFile(page, 'a.txt', 'line1\nline2\nline3\n'); + await dropFile(page, 'b.txt', 'line1\nCHANGED\nline3\nline4\n'); + + await enableCompare(page); + await runCompareMenu(page, 'Compare'); + + // Secondary pane appears (side-by-side). + await expect(page.locator('#editor-2')).toHaveCount(1); + // Diff decorations rendered. + await expect(page.locator('.cmp-line-changed').first()).toBeVisible({ timeout: 5000 }); + expect(await page.locator('.cmp-line-added').count()).toBeGreaterThan(0); + }); + + test('Clear Results removes the diff highlights', async ({ page }) => { + await ready(page); + await dropFile(page, 'a.txt', 'x\ny\n'); + await dropFile(page, 'b.txt', 'x\nZ\n'); + await enableCompare(page); + await runCompareMenu(page, 'Compare'); + await expect(page.locator('.cmp-line-changed').first()).toBeVisible(); + + await runCompareMenu(page, 'Clear Results'); + await expect(page.locator('.cmp-line-changed')).toHaveCount(0); + }); +}); diff --git a/tests/e2e/markdown-viewer.spec.ts b/tests/e2e/markdown-viewer.spec.ts index 3ca0fff..5ad7c2d 100644 --- a/tests/e2e/markdown-viewer.spec.ts +++ b/tests/e2e/markdown-viewer.spec.ts @@ -77,9 +77,7 @@ test.describe('MarkdownViewer++ plugin', () => { page, }) => { await ready(page); - await page.evaluate(() => - (window as unknown as Win).__setActiveDocContent('# Persisted'), - ); + await page.evaluate(() => (window as unknown as Win).__setActiveDocContent('# Persisted')); await enableMarkdown(page); await expect(page.locator('.md-body h1')).toHaveText('Persisted'); From 0d37d92efd9b07a4b05e1a957c885b9cd63dfbfb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 23:42:46 +0700 Subject: [PATCH 5/7] fix(plugins): ComparePlus row alignment + synced scroll; markdown scroll rebind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ComparePlus: the two panes now align line-by-line. The diff engine emits filler "gaps" for lines present on only one side; those render as block-widget spacers so matching lines stay horizontally aligned, and the panes scroll in lockstep. MarkdownViewer++: the synced-scroll listener now rebinds to the focused editor pane on tab/pane changes (previously bound once at mount), so scrolling follows the active document. (Content already tracked the active tab — verified via switch/close/reload/split e2e.) Tests: engine gap tests; compare-plus alignment e2e (spacer present + equal pane heights). 662 unit + 193 e2e pass; typecheck, lint, build clean. CHANGELOG updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 9 ++-- src/plugins/compare/decorations.ts | 47 ++++++++++++++++++-- src/plugins/compare/diff-engine.test.ts | 15 +++++++ src/plugins/compare/diff-engine.ts | 43 +++++++++++++++++- src/plugins/compare/index.ts | 58 +++++++++++++++++++++++-- src/plugins/markdown/index.ts | 35 ++++++++++----- src/styles.css | 10 +++++ tests/e2e/compare-plus.spec.ts | 21 +++++++++ 8 files changed, 213 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 647dcc2..362e6ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,10 +32,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 sanitized, with synced scroll, auto-refresh, **Ctrl+Shift+M** toggle, custom CSS, and HTML export (PDF via the browser Print dialog). - **ComparePlus** — side-by-side file comparison using the split view, with - line + inline character diffs (added / removed / changed / moved), compare - against clipboard or last-saved, find unique lines, diff navigation, and - ignore-whitespace/case/empty-line + move-detection options. (Git/SVN diff is - not possible in a browser and is omitted.) + line + inline character diffs (added / removed / changed / moved), + **row-by-row alignment** (filler spacers) and synced scrolling between the + panes, compare against clipboard or last-saved, find unique lines, diff + navigation, and ignore-whitespace/case/empty-line + move-detection options. + (Git/SVN diff is not possible in a browser and is omitted.) ### Fixed diff --git a/src/plugins/compare/decorations.ts b/src/plugins/compare/decorations.ts index 4e1f4e7..ef73d75 100644 --- a/src/plugins/compare/decorations.ts +++ b/src/plugins/compare/decorations.ts @@ -6,12 +6,37 @@ * render reliably on a freshly-created split pane. */ import type { EditorState } from '@codemirror/state'; -import { Decoration } from '@codemirror/view'; +import { Decoration, WidgetType } from '@codemirror/view'; import type { DecorationSet } from '@codemirror/view'; -import type { LineMark, CharSeg } from './diff-engine'; +import type { LineMark, CharSeg, Gap } from './diff-engine'; export { setOverlayDecos } from '../../editor/plugin-overlay'; +/** A block spacer of `size` blank rows, used to align the two diff panes. */ +class SpacerWidget extends WidgetType { + constructor( + private readonly rows: number, + private readonly lineHeight: number, + ) { + super(); + } + eq(other: SpacerWidget): boolean { + return other.rows === this.rows && other.lineHeight === this.lineHeight; + } + toDOM(): HTMLElement { + const el = document.createElement('div'); + el.className = 'cmp-spacer'; + el.style.height = `${this.rows * this.lineHeight}px`; + return el; + } + get estimatedHeight(): number { + return this.rows * this.lineHeight; + } + ignoreEvent(): boolean { + return true; + } +} + /** * Build a DecorationSet for one pane from its line marks + inline char ranges. * `charClass` styles the inline ranges (deletion vs insertion colour). @@ -21,6 +46,8 @@ export function buildCompareDecorations( marks: LineMark[], charSegs: CharSeg[], charClass: string, + gaps: Gap[] = [], + lineHeight = 0, ): DecorationSet { const total = state.doc.lines; const clampLine = (n: number): number => Math.max(1, Math.min(n, total)); @@ -36,7 +63,21 @@ export function buildCompareDecorations( const to = line.from + Math.max(0, Math.min(c.to, line.length)); if (to > from) ranges.push(Decoration.mark({ class: charClass }).range(from, to)); } - // Decoration.set(sort=true) orders line + mark ranges correctly. + // Alignment spacers: a block widget of `size` blank rows before the gap line + // (or at the document end for a trailing gap). + if (lineHeight > 0) { + for (const g of gaps) { + if (g.size <= 0) continue; + const widget = new SpacerWidget(g.size, lineHeight); + if (g.line > total) { + ranges.push(Decoration.widget({ widget, block: true, side: 1 }).range(state.doc.length)); + } else { + const pos = state.doc.line(clampLine(g.line)).from; + ranges.push(Decoration.widget({ widget, block: true, side: -1 }).range(pos)); + } + } + } + // Decoration.set(sort=true) orders line + mark + widget ranges correctly. return Decoration.set(ranges, true); } diff --git a/src/plugins/compare/diff-engine.test.ts b/src/plugins/compare/diff-engine.test.ts index 6e3586d..e631235 100644 --- a/src/plugins/compare/diff-engine.test.ts +++ b/src/plugins/compare/diff-engine.test.ts @@ -53,5 +53,20 @@ describe('computeCompare', () => { const r = computeCompare('a\nb\nc\n', 'a\nb\nc\n'); expect(r.left).toHaveLength(0); expect(r.right).toHaveLength(0); + expect(r.leftGaps).toHaveLength(0); + expect(r.rightGaps).toHaveLength(0); + }); + + it('emits an alignment gap on the side missing lines (added in B)', () => { + // B inserts a line → the LEFT pane needs one filler row to stay aligned. + const r = computeCompare('a\nb\n', 'a\nx\nb\n'); + expect(r.leftGaps).toEqual([{ line: 2, size: 1 }]); + expect(r.rightGaps).toHaveLength(0); + }); + + it('emits an alignment gap on the right when lines are removed from A', () => { + const r = computeCompare('a\nb\nc\n', 'a\nc\n'); + expect(r.rightGaps).toEqual([{ line: 2, size: 1 }]); + expect(r.leftGaps).toHaveLength(0); }); }); diff --git a/src/plugins/compare/diff-engine.ts b/src/plugins/compare/diff-engine.ts index 6f1698a..da8d05a 100644 --- a/src/plugins/compare/diff-engine.ts +++ b/src/plugins/compare/diff-engine.ts @@ -23,11 +23,25 @@ export interface CharSeg { to: number; } +/** + * A filler gap: insert `size` blank rows immediately BEFORE 1-based line `line` + * (line === lastLine + 1 means a trailing gap at the end) so the two panes stay + * aligned row-by-row in a side-by-side view. + */ +export interface Gap { + line: number; + size: number; +} + export interface CompareResult { left: LineMark[]; right: LineMark[]; leftChars: CharSeg[]; rightChars: CharSeg[]; + /** Filler rows to insert in the LEFT pane to align with the right. */ + leftGaps: Gap[]; + /** Filler rows to insert in the RIGHT pane to align with the left. */ + rightGaps: Gap[]; } export interface CompareOptions { @@ -88,7 +102,14 @@ export function computeCompare( ...(opts.ignoreCase ? { ignoreCase: true } : {}), }); - const result: CompareResult = { left: [], right: [], leftChars: [], rightChars: [] }; + const result: CompareResult = { + left: [], + right: [], + leftChars: [], + rightChars: [], + leftGaps: [], + rightGaps: [], + }; let lineA = 1; let lineB = 1; @@ -130,6 +151,12 @@ export function computeCompare( markLeft(lineA + k, 'removed', removedLines[k]!); for (let k = paired; k < addedLines.length; k++) markRight(lineB + k, 'added', addedLines[k]!); + // Surplus lines on one side need filler rows on the other to stay aligned. + if (removedLines.length > addedLines.length) { + result.rightGaps.push({ line: lineB + paired, size: removedLines.length - paired }); + } else if (addedLines.length > removedLines.length) { + result.leftGaps.push({ line: lineA + paired, size: addedLines.length - paired }); + } lineA += removedLines.length; lineB += addedLines.length; i++; // consumed the paired added run @@ -137,10 +164,14 @@ export function computeCompare( } if (change.removed) { + // Lines only in A: the right pane needs the same number of filler rows. lines.forEach((l, k) => markLeft(lineA + k, 'removed', l)); + result.rightGaps.push({ line: lineB, size: lines.length }); lineA += lines.length; } else { + // Lines only in B: the left pane needs filler rows. lines.forEach((l, k) => markRight(lineB + k, 'added', l)); + result.leftGaps.push({ line: lineA, size: lines.length }); lineB += lines.length; } } @@ -168,7 +199,15 @@ export function computeUniqueLines( const bLines = splitLines(textB); const aSet = new Set(aLines.map(norm)); const bSet = new Set(bLines.map(norm)); - const result: CompareResult = { left: [], right: [], leftChars: [], rightChars: [] }; + // Unique-lines mode is a position-independent set difference; no alignment. + const result: CompareResult = { + left: [], + right: [], + leftChars: [], + rightChars: [], + leftGaps: [], + rightGaps: [], + }; aLines.forEach((l, i) => { if (opts.ignoreEmptyLines && isBlank(l)) return; if (!bSet.has(norm(l))) result.left.push({ line: i + 1, type: 'removed' }); diff --git a/src/plugins/compare/index.ts b/src/plugins/compare/index.ts index 790be48..e62d99a 100644 --- a/src/plugins/compare/index.ts +++ b/src/plugins/compare/index.ts @@ -29,16 +29,51 @@ let options: CompareOptions = { /** Sorted changed-line numbers for diff navigation. */ let navLines: number[] = []; let navIdx = -1; +/** Removes the two-pane scroll-sync listeners, if any. */ +let scrollCleanup: (() => void) | null = null; -/** Append the compare field to a view once, then set/replace its decorations. */ +/** Mirror scrolling between the two panes so aligned rows stay aligned. */ +function syncScroll(): void { + scrollCleanup?.(); + scrollCleanup = null; + const a = ctx.split.primaryView(); + const b = ctx.split.secondaryView(); + if (!a || !b) return; + let lock = false; + const mirror = (src: EditorView, dst: EditorView) => (): void => { + if (lock) return; + lock = true; + dst.scrollDOM.scrollTop = src.scrollDOM.scrollTop; + dst.scrollDOM.scrollLeft = src.scrollDOM.scrollLeft; + lock = false; + }; + const onA = mirror(a, b); + const onB = mirror(b, a); + a.scrollDOM.addEventListener('scroll', onA, { passive: true }); + b.scrollDOM.addEventListener('scroll', onB, { passive: true }); + scrollCleanup = (): void => { + a.scrollDOM.removeEventListener('scroll', onA); + b.scrollDOM.removeEventListener('scroll', onB); + }; +} + +/** Paint one pane's diff decorations, including alignment spacers. */ function applyDecos( view: EditorView | null, marks: Parameters[1], chars: Parameters[2], charClass: string, + gaps: Parameters[4], ): void { if (!view) return; - const decos = buildCompareDecorations(view.state, marks, chars, charClass); + const decos = buildCompareDecorations( + view.state, + marks, + chars, + charClass, + gaps, + view.defaultLineHeight, + ); view.dispatch({ effects: setOverlayDecos.of(decos) }); } @@ -62,11 +97,24 @@ function runCompare(leftId: DocId, rightId: DocId, unique = false): void { // before its viewport exists renders nothing. Defer to the next frame so both // panes are laid out, then paint and jump to the first diff. const paint = (): void => { - applyDecos(ctx.split.primaryView(), result.left, result.leftChars, 'cmp-char-del'); - applyDecos(ctx.split.secondaryView(), result.right, result.rightChars, 'cmp-char-ins'); + applyDecos( + ctx.split.primaryView(), + result.left, + result.leftChars, + 'cmp-char-del', + result.leftGaps, + ); + applyDecos( + ctx.split.secondaryView(), + result.right, + result.rightChars, + 'cmp-char-ins', + result.rightGaps, + ); navLines = diffLineNumbers(result.right); navIdx = -1; if (navLines.length > 0) gotoDiff(0); + syncScroll(); }; requestAnimationFrame(() => requestAnimationFrame(paint)); } @@ -86,6 +134,8 @@ function clearResults(): void { for (const v of [ctx.split.primaryView(), ctx.split.secondaryView()]) { if (v) v.dispatch({ effects: setOverlayDecos.of(Decoration.none) }); } + scrollCleanup?.(); + scrollCleanup = null; navLines = []; navIdx = -1; } diff --git a/src/plugins/markdown/index.ts b/src/plugins/markdown/index.ts index 4037a90..bcbe036 100644 --- a/src/plugins/markdown/index.ts +++ b/src/plugins/markdown/index.ts @@ -65,23 +65,34 @@ function mountPreview(host: HTMLElement): () => void { }; render(); - // Auto-refresh on any store change (edits, tab switch), debounced. + // Synced scroll: map the focused editor's scroll fraction onto the preview. + // The listener is (re)bound to whichever pane is focused, so after a tab or + // split-pane switch it follows the active editor. + let boundScroller: HTMLElement | null = null; + const onScroll = (): void => { + if (!boundScroller) return; + const sMax = boundScroller.scrollHeight - boundScroller.clientHeight; + if (sMax <= 0) return; + const pct = boundScroller.scrollTop / sMax; + host.scrollTop = pct * (host.scrollHeight - host.clientHeight); + }; + const rebindScroll = (): void => { + const s = ctx.getFocusedView().scrollDOM; + if (s === boundScroller) return; + boundScroller?.removeEventListener('scroll', onScroll); + boundScroller = s; + boundScroller.addEventListener('scroll', onScroll, { passive: true }); + }; + rebindScroll(); + + // Auto-refresh + re-bind scroll on any store change (edits, tab switch, focus). let timer: ReturnType | null = null; const unsub = ctx.store.subscribe(() => { + rebindScroll(); if (timer) clearTimeout(timer); timer = setTimeout(render, 150); }); - // Synced scroll: map the editor's scroll fraction onto the preview. - const scroller = ctx.getFocusedView().scrollDOM; - const onScroll = (): void => { - const sMax = scroller.scrollHeight - scroller.clientHeight; - if (sMax <= 0) return; - const pct = scroller.scrollTop / sMax; - host.scrollTop = pct * (host.scrollHeight - host.clientHeight); - }; - scroller.addEventListener('scroll', onScroll, { passive: true }); - // Open links in a new tab rather than navigating the panel. body.addEventListener('click', (e) => { const a = (e.target as HTMLElement).closest('a'); @@ -93,7 +104,7 @@ function mountPreview(host: HTMLElement): () => void { const cleanup = (): void => { unsub(); - scroller.removeEventListener('scroll', onScroll); + boundScroller?.removeEventListener('scroll', onScroll); if (timer) clearTimeout(timer); if (applyCssToMounted === applyCss) applyCssToMounted = null; }; diff --git a/src/styles.css b/src/styles.css index fc51ee0..0b799c9 100644 --- a/src/styles.css +++ b/src/styles.css @@ -909,6 +909,16 @@ body { .cmp-char-del { background: rgba(220, 70, 70, 0.35); } +/* Alignment filler rows inserted so both diff panes line up row-by-row. */ +.cmp-spacer { + background: repeating-linear-gradient( + 45deg, + rgba(0, 0, 0, 0.04), + rgba(0, 0, 0, 0.04) 6px, + transparent 6px, + transparent 12px + ); +} /* ── Context menu ────────────────────────────────────────────────────────────── */ .context-menu { diff --git a/tests/e2e/compare-plus.spec.ts b/tests/e2e/compare-plus.spec.ts index 3ee53bb..f949913 100644 --- a/tests/e2e/compare-plus.spec.ts +++ b/tests/e2e/compare-plus.spec.ts @@ -79,6 +79,27 @@ test.describe('ComparePlus plugin', () => { expect(await page.locator('.cmp-line-added').count()).toBeGreaterThan(0); }); + test('aligns the two panes row-by-row with filler spacers', async ({ page }) => { + await ready(page); + // Left has 3 lines; right adds a 4th → the left pane needs a filler row so + // matching lines stay horizontally aligned. + await dropFile(page, 'a.txt', 'line1\nline2\nline3\n'); + await dropFile(page, 'b.txt', 'line1\nline2\nline3\nline4\n'); + await enableCompare(page); + await runCompareMenu(page, 'Compare'); + + // A spacer widget was inserted... + await expect(page.locator('.cmp-spacer').first()).toBeVisible({ timeout: 5000 }); + // ...and the two panes now have (near-)equal total content height. + const heights = await page.evaluate(() => { + const h = (sel: string) => + (document.querySelector(sel + ' .cm-content') as HTMLElement)?.getBoundingClientRect() + .height ?? 0; + return { a: h('#editor'), b: h('#editor-2') }; + }); + expect(Math.abs(heights.a - heights.b)).toBeLessThan(4); + }); + test('Clear Results removes the diff highlights', async ({ page }) => { await ready(page); await dropFile(page, 'a.txt', 'x\ny\n'); From adee54527b38fa13c6840d82356ef24e9b72aafa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 01:37:24 +0700 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20split-view=20drag=20&=20drop=20?= =?UTF-8?q?=E2=80=94=20drop=20into=20a=20pane,=20move=20tabs=20between=20p?= =?UTF-8?q?anes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In split view: - Dropping a file lands in the pane the cursor is over (not just the focused pane); if that file is already open in the other pane, its tab is moved across rather than duplicated. - A tab can be dragged from one pane's strip onto the other to move it there. - FileActions.openDropped now delegates placement to a callback so the app can target a specific pane; App.openDocInView moves/creates + shows a doc in a given pane (falls back to primary when the pane doesn't exist). - The drop handler derives the target pane from the drop's element (guarded against non-Element targets like document, which previously threw). - TabBar tabs are draggable and each strip is a drop target (custom application/x-notepad-tab payload), wired to App.openDocInView. Tests: split-drag-drop.spec.ts e2e (drop into pane, move-across, tab drag). 662 unit + 196 e2e pass; typecheck, lint, build clean. CHANGELOG updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 +- src/app/app.ts | 27 +++++++- src/app/file-actions.ts | 22 ++++-- src/app/tabbar.ts | 30 ++++++++ tests/e2e/split-drag-drop.spec.ts | 110 ++++++++++++++++++++++++++++++ 5 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 tests/e2e/split-drag-drop.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 362e6ae..b61f671 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 if a tab already has that file open, it is activated instead of duplicated. Same-file detection uses the file-system handle identity when the browser provides one, falling back to the file name. Multiple files can be dropped at - once, and they open in the focused split pane. + once. In split view, a file opens in the **pane you drop it into**, and if it + is already open in the other pane it is **moved across**. You can also **drag + a tab from one pane to the other** to move it between views. - **Plugins system + first two plugins.** A Notepad++-style **Plugins** menu with a **Plugins Manager** modal. Plugins are lazy-loaded — their code and libraries diff --git a/src/app/app.ts b/src/app/app.ts index e428824..eeecc2a 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -249,6 +249,9 @@ export class App { onMoveToOtherView: this.deps.createSecondaryEditor ? (id) => this.moveToOtherView(id) : undefined, + onMoveToView: this.deps.createSecondaryEditor + ? (id, view) => this.openDocInView(id, view) + : undefined, }, viewId, ); @@ -324,6 +327,24 @@ export class App { this.secondaryTabBar?.render(); } + /** + * Show a document in a specific pane and focus it — creating the tab there, + * or moving it across if it was open in the other pane. Used by drag & drop + * (drop lands in the pane under the cursor) and tab-to-pane dragging. Falls + * back to the primary pane when the target pane doesn't exist. + */ + private openDocInView(docId: DocId, view: ViewId): void { + const target: ViewId = view === 1 && !this.secondary ? 0 : view; + const store = this.deps.store; + store.moveToView(docId, target); + store.setActiveForView(target, docId); + this.controllerFor(target)?.showDoc(docId); + this.applyFocus(target); + this.deps.dockManager?.focusEditorGroup(target); + this.primaryTabBar?.render(); + this.secondaryTabBar?.render(); + } + /** Tab context-menu "Move to Other View". */ private moveToOtherView(id: DocId): void { const store = this.deps.store; @@ -476,6 +497,9 @@ export class App { if (!isFileDrag(dt)) return; e.preventDefault(); e.stopPropagation(); + // Which pane did the drop land in? (secondary host contains #tabbar-2 + #editor-2) + const targetEl = e.target instanceof Element ? e.target : null; + const targetView: ViewId = targetEl?.closest('#dock-editor-host-2') ? 1 : 0; const items = Array.from(dt!.items).filter((i) => i.kind === 'file'); // getAsFile() / getAsFileSystemHandle() must be called synchronously — // DataTransferItems are invalidated once the event handler returns. @@ -501,7 +525,8 @@ export class App { }); } if (entries.length === 0) return; - await fileActions.openDropped(entries); + // Open (or move an already-open tab) into the pane the drop landed in. + await fileActions.openDropped(entries, (docId) => this.openDocInView(docId, targetView)); this.view.focus(); })(); }, diff --git a/src/app/file-actions.ts b/src/app/file-actions.ts index 259102b..e580c72 100644 --- a/src/app/file-actions.ts +++ b/src/app/file-actions.ts @@ -184,8 +184,13 @@ export class FileActions { /** * Open files dropped onto the page (drag & drop). For each dropped file: - * - if a tab already has that file open, activate it (no duplicate tab); - * - otherwise open it in a new tab in the focused pane. + * - if a tab already has that file open, reuse it (no duplicate tab); + * - otherwise open it in a new tab. + * + * Placement is delegated to `place(docId, wasAlreadyOpen)` so the caller can + * put the doc in a specific split pane (e.g. the pane the drop landed in, and + * move an already-open tab across from another pane). Defaults to activating + * the doc in the focused pane. * * "Already open" is decided by FileSystemFileHandle identity (isSameEntry) when * a handle is available (Chrome exposes one via getAsFileSystemHandle), and by @@ -194,12 +199,17 @@ export class FileActions { * @param entries dropped items; `handle` is present only when the browser * exposed a FileSystemFileHandle for the drop. */ - async openDropped(entries: Array<{ file: File; handle?: FileSystemFileHandle }>): Promise { + async openDropped( + entries: Array<{ file: File; handle?: FileSystemFileHandle }>, + place: (docId: DocId, wasAlreadyOpen: boolean) => void = (id) => { + this.store.setActive(id); + this.controller.showDoc(id); + }, + ): Promise { for (const { file, handle } of entries) { const existing = await this._findOpenDoc(file.name, handle); if (existing) { - this.store.setActive(existing.id); - this.controller.showDoc(existing.id); + place(existing.id, true); continue; } const verdict = classifySize(file.size); @@ -220,10 +230,10 @@ export class FileActions { bom, dirty: false, }); - this.controller.showDoc(doc.id); if (handle) { this.store.update(doc.id, { diskModified: await this._handleModified(handle) }); } + place(doc.id, false); } } diff --git a/src/app/tabbar.ts b/src/app/tabbar.ts index e2828a7..0578133 100644 --- a/src/app/tabbar.ts +++ b/src/app/tabbar.ts @@ -2,6 +2,9 @@ import type { DocumentStore, DocId, ViewId } from '../services/document-store'; import { showContextMenu } from './context-menu'; +/** DataTransfer type used when dragging a tab between split panes. */ +const TAB_MIME = 'application/x-notepad-tab'; + export interface TabBarContextCallbacks { onSave: (id: DocId) => void; onSaveAs: (id: DocId) => void; @@ -11,6 +14,8 @@ export interface TabBarContextCallbacks { onReload: () => void; /** Move the tab to the other split view (present only when split is available). */ onMoveToOtherView?: (id: DocId) => void; + /** Move a document to a specific pane (used when a tab is dragged onto this strip). */ + onMoveToView?: (id: DocId, view: ViewId) => void; } export class TabBar { @@ -50,6 +55,25 @@ export class TabBar { document.addEventListener('click', this._onDocClick); document.addEventListener('keydown', this._onDocKeydown); + // This strip is a drop target for tabs dragged from the other pane. Bound + // once on the persistent root (render() only replaces its children). + this.root.addEventListener('dragover', (e) => { + if (e.dataTransfer?.types.includes(TAB_MIME)) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + } + }); + this.root.addEventListener('drop', (e) => { + if (!e.dataTransfer?.types.includes(TAB_MIME)) return; + e.preventDefault(); + const id = e.dataTransfer.getData(TAB_MIME); + const doc = id ? this.store.get(id) : undefined; + // Only act when the tab comes from the OTHER pane. + if (doc && (doc.view ?? 0) !== this.viewId) { + this.contextCallbacks?.onMoveToView?.(id, this.viewId); + } + }); + this.store.subscribe(() => this.render()); } @@ -63,6 +87,12 @@ export class TabBar { tab.className = 'tab' + (doc.id === activeIdForView ? ' active' : '') + (doc.dirty ? ' dirty' : ''); tab.dataset.id = doc.id; + // Draggable so it can be dropped onto the other pane's tab strip. + tab.draggable = true; + tab.addEventListener('dragstart', (e) => { + e.dataTransfer?.setData(TAB_MIME, doc.id); + if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'; + }); // Disk-state indicator (faithful to NotepadNext tab disk icon): blue = in // sync with disk, red = unsaved edits, red+ring = changed externally. const disk = document.createElement('span'); diff --git a/tests/e2e/split-drag-drop.spec.ts b/tests/e2e/split-drag-drop.spec.ts new file mode 100644 index 0000000..44c4b4e --- /dev/null +++ b/tests/e2e/split-drag-drop.spec.ts @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +/** + * E2E for split-view drag & drop: + * - dropping a file into a pane opens/activates it in THAT pane; + * - dropping a file already open in the other pane moves it across; + * - dragging a tab from one pane onto the other moves it. + */ +import { test, expect } from '@playwright/test'; + +type Win = Window & { __appReady?: unknown }; + +async function ready(page: Parameters[1]>[0]['page']) { + page.on('dialog', (d) => void d.accept()); + await page.goto('/editor.html'); + await page.evaluate(async () => { + const dbs = await indexedDB.databases(); + await Promise.all( + dbs.map( + (db) => + new Promise((res) => { + const r = indexedDB.deleteDatabase(db.name!); + r.onsuccess = () => res(); + r.onerror = () => res(); + }), + ), + ); + }); + await page.reload(); + await page.waitForFunction(() => (window as unknown as Win).__appReady !== undefined); + await page.evaluate(() => (window as unknown as Win).__appReady); +} + +/** Dispatch a synthetic file drop on a specific element (so e.target is in that pane). */ +async function dropFileOn( + page: Parameters[1]>[0]['page'], + selector: string, + name: string, + content: string, +) { + await page.evaluate( + ({ selector, name, content }) => { + const el = document.querySelector(selector) ?? document; + const dt = new DataTransfer(); + dt.items.add(new File([content], name, { type: 'text/plain' })); + el.dispatchEvent( + new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true }), + ); + }, + { selector, name, content }, + ); +} + +async function splitWith( + page: Parameters[1]>[0]['page'], + files: [string, string][], +) { + for (const [n, c] of files) await dropFileOn(page, 'body', n, c); + await page.getByRole('menuitem', { name: 'View' }).click(); + await page.getByRole('menuitem', { name: 'Split Vertical' }).click(); + await expect(page.locator('#editor-2')).toHaveCount(1); +} + +test.describe('Split-view drag & drop', () => { + test('dropping a file into the secondary pane opens it there', async ({ page }) => { + await ready(page); + await splitWith(page, [ + ['a.md', 'A'], + ['b.md', 'B'], + ]); + await dropFileOn(page, '#editor-2', 'c.txt', 'C'); + await expect(page.locator('#tabbar-2 .tab', { hasText: 'c.txt' })).toHaveCount(1); + await expect(page.locator('#tabbar .tab', { hasText: 'c.txt' })).toHaveCount(0); + }); + + test('dropping a file already open in the other pane moves it across', async ({ page }) => { + await ready(page); + await splitWith(page, [ + ['a.md', 'A'], + ['x.md', 'X'], + ['b.md', 'B'], + ]); + // a.md is open in the primary pane; drop a same-named file onto the secondary. + await dropFileOn(page, '#editor-2', 'a.md', 'A'); + await expect(page.locator('#tabbar-2 .tab', { hasText: 'a.md' })).toHaveCount(1); + await expect(page.locator('#tabbar .tab', { hasText: 'a.md' })).toHaveCount(0); + }); + + test('dragging a tab onto the other pane moves it', async ({ page }) => { + await ready(page); + await splitWith(page, [ + ['a.md', 'A'], + ['x.md', 'X'], + ['b.md', 'B'], + ]); + // a.md is in the primary pane; drag its tab onto the secondary tab strip. + await page.evaluate(() => { + const src = [...document.querySelectorAll('#tabbar .tab')].find((t) => + t.textContent?.includes('a.md'), + )!; + const target = document.querySelector('#tabbar-2')!; + const dt = new DataTransfer(); + src.dispatchEvent(new DragEvent('dragstart', { dataTransfer: dt, bubbles: true })); + target.dispatchEvent( + new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true }), + ); + }); + await expect(page.locator('#tabbar-2 .tab', { hasText: 'a.md' })).toHaveCount(1); + await expect(page.locator('#tabbar .tab', { hasText: 'a.md' })).toHaveCount(0); + }); +}); From 8dea107cc07b5482d570d49c40386f558ddef30e Mon Sep 17 00:00:00 2001 From: codecancu Date: Sat, 25 Jul 2026 22:16:54 +0700 Subject: [PATCH 7/7] Fix tab overflow: keep active tab visible and prevent layout overflow Improve tab strip overflow handling by: - Updating the overflow algorithm to always keep the active tab visible (never hidden in overflow), using a greedy left-to-right approach instead of checking if tabs extend past the cutoff - Actually hiding overflowed tabs with display: none to prevent them from occupying layout space and pushing the >> button off-screen - Resetting display state before each measurement to account for previously hidden tabs - Fixing the shell's grid layout with minmax(0, 1fr) and min-width: 0 to clip content to viewport instead of expanding the grid Adds regression tests for narrow viewport behavior and tab activation from overflow menu. --- src/app/tabbar.ts | 28 ++++++++++++---- src/styles.css | 14 ++++++++ tests/e2e/markdown-viewer.spec.ts | 22 +++++++++++++ tests/e2e/tab-overflow.spec.ts | 55 +++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 6 deletions(-) diff --git a/src/app/tabbar.ts b/src/app/tabbar.ts index 0578133..8290fb0 100644 --- a/src/app/tabbar.ts +++ b/src/app/tabbar.ts @@ -241,8 +241,10 @@ export class TabBar { } /** - * Recompute which tabs overflow the tab strip and show/hide the `>>` button. - * Must be called after layout (offsets are available synchronously once appended). + * Recompute which tabs overflow the tab strip: hide them (so the strip's own + * `overflow: hidden` never clips the "»" button itself) and list them in the + * overflow dropdown. Must be called after layout (offsets/widths are + * available synchronously once appended). */ updateOverflow(): void { if (!this.overflowBtn) return; @@ -253,6 +255,10 @@ export class TabBar { return; } + // Reset from any previous pass first — a tab hidden last time round would + // otherwise be measured at its collapsed (zero) width. + for (const tab of allTabs) tab.style.display = ''; + // Available width = strip width minus the new-tab button and overflow button. const addBtn = this.root.querySelector('.tab-new-btn'); const addWidth = addBtn ? addBtn.offsetWidth : 0; @@ -261,15 +267,25 @@ export class TabBar { const chevronWidth = 28; const availableWidth = this.root.clientWidth - addWidth - chevronWidth; + // Greedily keep tabs left-to-right within the available width. The active + // tab is never dropped — Notepad++ always keeps the current tab visible + // even when the strip can't fit every open tab. + const activeId = this.store.activeForView(this.viewId)?.id; const overflowedIds = new Set(); + let shown = 0; for (const tab of allTabs) { - const rightEdge = tab.offsetLeft + tab.offsetWidth; - if (rightEdge > availableWidth) { - const id = tab.dataset.id; - if (id) overflowedIds.add(id); + const isActive = tab.dataset.id === activeId; + if (isActive || shown + tab.offsetWidth <= availableWidth) { + shown += tab.offsetWidth; + } else if (tab.dataset.id) { + overflowedIds.add(tab.dataset.id); } } + for (const tab of allTabs) { + tab.style.display = tab.dataset.id && overflowedIds.has(tab.dataset.id) ? 'none' : ''; + } + if (overflowedIds.size > 0) { this.overflowBtn.style.display = ''; this.overflowBtn.dataset.overflowIds = JSON.stringify([...overflowedIds]); diff --git a/src/styles.css b/src/styles.css index 0b799c9..3f27ea4 100644 --- a/src/styles.css +++ b/src/styles.css @@ -29,9 +29,23 @@ body { display: grid; /* menubar | toolbar | dock (fills remaining) | statusbar */ grid-template-rows: 24px 26px 1fr 24px; + /* minmax(0, 1fr): without the explicit 0 minimum, a grid track's automatic + minimum size is the max min-content of its items (e.g. the toolbar's row + of un-wrapped icon buttons, or a wide tab strip) — the whole shell would + balloon past a narrow viewport instead of clipping to it. */ + grid-template-columns: minmax(0, 1fr); background: #ffffff; overflow: hidden; } +/* Every direct grid child needs min-width: 0 too — grid items default to + min-width: auto (their content's min-content size), which independently + forces the shared column wide regardless of the track's own minmax(0, …). */ +#menubar, +#toolbar, +#dock, +#statusbar { + min-width: 0; +} /* #dock is the dockview container: fills the middle grid row. */ #dock { width: 100%; diff --git a/tests/e2e/markdown-viewer.spec.ts b/tests/e2e/markdown-viewer.spec.ts index 5ad7c2d..f7c384d 100644 --- a/tests/e2e/markdown-viewer.spec.ts +++ b/tests/e2e/markdown-viewer.spec.ts @@ -73,6 +73,28 @@ test.describe('MarkdownViewer++ plugin', () => { await expect(page.locator('.md-body h1')).toHaveText('Two', { timeout: 5000 }); }); + test('preview follows the active tab when switching between tabs', async ({ page }) => { + await ready(page); + // Tab A holds "# Alpha". + await page.evaluate(() => (window as unknown as Win).__setActiveDocContent('# Alpha')); + await enableMarkdown(page); + await expect(page.locator('.md-body h1')).toHaveText('Alpha'); + + // New tab B holds "# Beta". + await page.locator('#tab-new').click(); + await expect(page.locator('.tab')).toHaveCount(2); + await page.evaluate(() => (window as unknown as Win).__setActiveDocContent('# Beta')); + await expect(page.locator('.md-body h1')).toHaveText('Beta', { timeout: 5000 }); + + // Switch back to tab A: the preview must follow to "# Alpha". + await page.locator('.tab').first().click(); + await expect(page.locator('.md-body h1')).toHaveText('Alpha', { timeout: 5000 }); + + // And forward to tab B again. + await page.locator('.tab').last().click(); + await expect(page.locator('.md-body h1')).toHaveText('Beta', { timeout: 5000 }); + }); + test('preview still renders after a reload (persisted enable + restored layout)', async ({ page, }) => { diff --git a/tests/e2e/tab-overflow.spec.ts b/tests/e2e/tab-overflow.spec.ts index dda9ea4..27766ac 100644 --- a/tests/e2e/tab-overflow.spec.ts +++ b/tests/e2e/tab-overflow.spec.ts @@ -142,6 +142,61 @@ test.describe('tab overflow — >> chevron button', () => { await expect(menu).toBeHidden({ timeout: 2000 }); }); + test('>> button and hidden tabs stay within the viewport at narrow widths', async ({ page }) => { + // Regression test: the shell's outer grid column used to grow to fit + // un-wrapped content (the toolbar, or a wide tab strip) instead of + // clipping to the viewport, which pushed the >> button off-screen even + // though it reported display !== 'none'. toBeVisible() alone doesn't + // catch that — it doesn't check the element is inside the viewport. + await page.setViewportSize({ width: 480, height: 700 }); + page.on('dialog', (d) => void d.accept()); + await page.goto('/editor.html'); + await waitForApp(page); + + await openExtraTabs(page, 8); + + const overflowBtn = page.locator('#tab-overflow'); + await expect(overflowBtn).toBeVisible({ timeout: 3000 }); + const chevronBox = await overflowBtn.boundingBox(); + expect(chevronBox).not.toBeNull(); + expect(chevronBox!.x + chevronBox!.width).toBeLessThanOrEqual(480); + + // Hidden (overflowed) tabs must actually be removed from the layout, not + // just tracked for the dropdown — otherwise they (and the >> button + // itself) keep occupying flex space past the visible edge. + const hiddenCount = await page.locator('.tab[style*="display: none"]').count(); + expect(hiddenCount).toBeGreaterThan(0); + }); + + test('activating a tab from the overflow dropdown keeps the active tab visible', async ({ + page, + }) => { + // Regression test: naively hiding a fixed suffix of tabs would hide the + // newly-activated tab again on the next render (it's still geometrically + // past the cutoff), leaving no tab highlighted at all in the strip. + await page.setViewportSize({ width: 480, height: 700 }); + page.on('dialog', (d) => void d.accept()); + await page.goto('/editor.html'); + await waitForApp(page); + + await openExtraTabs(page, 8); + + const overflowBtn = page.locator('#tab-overflow'); + await expect(overflowBtn).toBeVisible({ timeout: 3000 }); + await overflowBtn.click(); + + const menu = page.locator('.tab-overflow-menu'); + await expect(menu).toBeVisible(); + // The first hidden tab is deepest in the strip's overflow — activate it. + await menu.locator('[role="menuitem"]').first().click(); + + const activeTab = page.locator('.tab.active'); + await expect(activeTab).toBeVisible(); + const activeBox = await activeTab.boundingBox(); + expect(activeBox).not.toBeNull(); + expect(activeBox!.x + activeBox!.width).toBeLessThanOrEqual(480); + }); + test('>> button is hidden when viewport widens enough to show all tabs', async ({ page }) => { // Start narrow await page.setViewportSize({ width: 400, height: 700 });
1