diff --git a/CHANGELOG.md b/CHANGELOG.md index 17ea971..b61f671 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,27 @@ 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. 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 + 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), + **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/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/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/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..54d5dc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,10 @@ "@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", "wasmoon": "^1.16.0" }, "devDependencies": { @@ -1711,6 +1714,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", @@ -2920,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", @@ -2990,6 +3009,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 +4344,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..1fc6271 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,10 @@ "@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", "wasmoon": "^1.16.0" } } diff --git a/src/app/app.ts b/src/app/app.ts index e59131f..eeecc2a 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 }; @@ -243,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, ); @@ -318,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; @@ -446,6 +473,66 @@ 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(); + // 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. + 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; + // 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(); + })(); + }, + 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. @@ -1090,6 +1177,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(); @@ -1214,6 +1303,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), @@ -1267,6 +1358,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(); @@ -1562,4 +1657,34 @@ 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?.(); + } + + /** 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/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..e580c72 100644 --- a/src/app/file-actions.ts +++ b/src/app/file-actions.ts @@ -182,6 +182,100 @@ 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, 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 + * 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 }>, + 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) { + place(existing.id, true); + 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, + }); + if (handle) { + this.store.update(doc.id, { diskModified: await this._handleModified(handle) }); + } + place(doc.id, false); + } + } + + /** + * 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/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/tabbar.ts b/src/app/tabbar.ts index e2828a7..8290fb0 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'); @@ -211,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; @@ -223,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; @@ -231,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/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..a0eca2c 100644 --- a/src/editor-page.ts +++ b/src/editor-page.ts @@ -18,9 +18,12 @@ 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'; +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'; @@ -214,6 +217,8 @@ function buildSharedExtensions( viewId: ViewId, ): Extension[] { return [ + // Generic overlay-decoration slot plugins fill (e.g. ComparePlus diffs). + overlayDecorationsField, lineNumbers(), highlightActiveLine(), history(), @@ -483,6 +488,21 @@ 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 + 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(), +}); + const app = new App({ view, controller, @@ -502,8 +522,13 @@ const app = new App({ controllerRef: focusedControllerRef, dockManager, createSecondaryEditor, + pluginRegistry, }); +// 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 ────── window.__dockManager = dockManager; window.__debugLogToggle = () => dockManager.togglePanel('debug-log'); @@ -692,6 +717,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/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 new file mode 100644 index 0000000..7e465cf --- /dev/null +++ b/src/plugins/available.ts @@ -0,0 +1,24 @@ +// 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'), + }, + { + 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..ef73d75 --- /dev/null +++ b/src/plugins/compare/decorations.ts @@ -0,0 +1,87 @@ +// 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, WidgetType } from '@codemirror/view'; +import type { DecorationSet } from '@codemirror/view'; +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). + */ +export function buildCompareDecorations( + state: EditorState, + 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)); + 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)); + } + // 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); +} + +/** 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..e631235 --- /dev/null +++ b/src/plugins/compare/diff-engine.test.ts @@ -0,0 +1,72 @@ +// 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); + 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 new file mode 100644 index 0000000..da8d05a --- /dev/null +++ b/src/plugins/compare/diff-engine.ts @@ -0,0 +1,242 @@ +// 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; +} + +/** + * 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 { + 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: [], + leftGaps: [], + rightGaps: [], + }; + 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]!); + // 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 + continue; + } + + 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; + } + } + + 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)); + // 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' }); + }); + 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..e62d99a --- /dev/null +++ b/src/plugins/compare/index.ts @@ -0,0 +1,278 @@ +// 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; +/** Removes the two-pane scroll-sync listeners, if any. */ +let scrollCleanup: (() => void) | null = null; + +/** 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, + gaps, + view.defaultLineHeight, + ); + 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', + 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)); +} + +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) }); + } + scrollCleanup?.(); + scrollCleanup = null; + 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/markdown/index.ts b/src/plugins/markdown/index.ts new file mode 100644 index 0000000..bcbe036 --- /dev/null +++ b/src/plugins/markdown/index.ts @@ -0,0 +1,227 @@ +// 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(); + + // 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); + }); + + // 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(); + boundScroller?.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), + }); + + // 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 => { + 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);
   });
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);
+  });
+});
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