From df188208162414c6f6086601952067bbab1402fa Mon Sep 17 00:00:00 2001 From: LingX Date: Sat, 13 Jun 2026 16:36:35 -0400 Subject: [PATCH] Add search and LaTeX support --- package-lock.json | 167 ++++++++++++++++++- package.json | 3 + src/main/index.ts | 13 +- src/preload/index.ts | 9 + src/renderer/editor/editor.ts | 65 +++++++- src/renderer/editor/math-modal.ts | 169 +++++++++++++++++++ src/renderer/editor/search-panel.ts | 244 ++++++++++++++++++++++++++++ src/renderer/env.d.ts | 11 +- src/renderer/main.ts | 11 +- src/renderer/themes/base.css | 188 +++++++++++++++++++++ 10 files changed, 869 insertions(+), 11 deletions(-) create mode 100644 src/renderer/editor/math-modal.ts create mode 100644 src/renderer/editor/search-panel.ts diff --git a/package-lock.json b/package-lock.json index 5a0d72d..ffe4472 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,21 @@ { "name": "colamd", - "version": "1.3.1", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "colamd", - "version": "1.3.1", + "version": "1.5.0", "license": "MIT", "dependencies": { "@milkdown/kit": "^7.19.2", + "@milkdown/plugin-math": "^7.5.9", + "katex": "^0.16.47", "remark-breaks": "^4.0.0" }, "devDependencies": { + "@types/katex": "^0.16.8", "electron": "^34.0.0", "electron-builder": "^26.0.0", "electron-vite": "^3.0.0", @@ -1692,6 +1695,52 @@ "lodash-es": "^4.17.21" } }, + "node_modules/@milkdown/plugin-math": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/@milkdown/plugin-math/-/plugin-math-7.5.9.tgz", + "integrity": "sha512-LzFZKNc7zm3uOTs9mEN/12/X+UdGoyQRp5Gt9GE7lPJ96jOZaGJD0vMtX0mTauTOmUxDQoQS0I7NW1g9UhC6qQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "dependencies": { + "@milkdown/exception": "7.5.9", + "@milkdown/utils": "7.5.9", + "@types/katex": "^0.16.0", + "katex": "^0.16.0", + "remark-math": "^6.0.0", + "tslib": "^2.5.0" + }, + "peerDependencies": { + "@milkdown/core": "^7.2.0", + "@milkdown/ctx": "^7.2.0", + "@milkdown/prose": "^7.2.0" + } + }, + "node_modules/@milkdown/plugin-math/node_modules/@milkdown/exception": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/@milkdown/exception/-/exception-7.5.9.tgz", + "integrity": "sha512-9g+WpiRjgLsVlHt7DotlUmKK9oT6Lsr5TgxE0NdDvtr81CC43mgNtoekI6rg/PatEBBXifDK1GJJ4LKnRSseVQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.5.0" + } + }, + "node_modules/@milkdown/plugin-math/node_modules/@milkdown/utils": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/@milkdown/utils/-/utils-7.5.9.tgz", + "integrity": "sha512-udxaIdKm6pOiNACIKR7+NQ7Vj+QivhA5jcRqksEuQOfDFQBFcGaAhGEEiU1cKwE7fmP2ayigfD0IZ01rjLS2fA==", + "license": "MIT", + "dependencies": { + "@milkdown/exception": "7.5.9", + "nanoid": "^5.0.0", + "tslib": "^2.5.0" + }, + "peerDependencies": { + "@milkdown/core": "^7.2.0", + "@milkdown/ctx": "^7.2.0", + "@milkdown/prose": "^7.2.0", + "@milkdown/transformer": "^7.2.0" + } + }, "node_modules/@milkdown/plugin-slash": { "version": "7.19.2", "resolved": "https://registry.npmjs.org/@milkdown/plugin-slash/-/plugin-slash-7.19.2.tgz", @@ -2311,6 +2360,15 @@ "@types/node": "*" } }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -2318,6 +2376,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" + }, "node_modules/@types/keyv": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", @@ -5236,6 +5300,31 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5547,6 +5636,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-newline-to-break": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/mdast-util-newline-to-break/-/mdast-util-newline-to-break-2.0.0.tgz", @@ -5799,6 +5907,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-factory-destination": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", @@ -7214,6 +7341,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -7928,6 +8071,12 @@ "utf8-byte-length": "^1.0.1" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-fest": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", @@ -8021,6 +8170,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-stringify-position": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", diff --git a/package.json b/package.json index 32e498d..5b6d090 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,12 @@ }, "dependencies": { "@milkdown/kit": "^7.19.2", + "@milkdown/plugin-math": "^7.5.9", + "katex": "^0.16.47", "remark-breaks": "^4.0.0" }, "devDependencies": { + "@types/katex": "^0.16.8", "electron": "^34.0.0", "electron-builder": "^26.0.0", "electron-vite": "^3.0.0", diff --git a/src/main/index.ts b/src/main/index.ts index ea0523b..c59cb6e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -765,7 +765,18 @@ function buildMenu(): void { { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, - { role: 'selectAll' } + { role: 'selectAll' }, + { type: 'separator' }, + { + label: 'Find', + accelerator: 'CmdOrCtrl+F', + click: () => sendToFocused('editor:search') + }, + { + label: 'Insert Formula', + accelerator: 'CmdOrCtrl+Shift+E', + click: () => sendToFocused('editor:math') + } ] }, { diff --git a/src/preload/index.ts b/src/preload/index.ts index ca173bd..90a8f8c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -28,7 +28,10 @@ export interface ElectronAPI { onSetCustomCSS: (callback: (css: string) => void) => void exportSlides: (content: string) => Promise onMenuExportSlides: (callback: () => void) => void + onMenuImportTheme: (callback: () => void) => void onAgentActivity: (callback: (state: string) => void) => void + onSearch: (callback: () => void) => void + onMathModal: (callback: () => void) => void } contextBridge.exposeInMainWorld('electronAPI', { @@ -92,5 +95,11 @@ contextBridge.exposeInMainWorld('electronAPI', { }, onAgentActivity: (callback: (state: string) => void) => { ipcRenderer.on('agent-activity', (_event, state) => callback(state)) + }, + onSearch: (callback: () => void) => { + ipcRenderer.on('editor:search', () => callback()) + }, + onMathModal: (callback: () => void) => { + ipcRenderer.on('editor:math', () => callback()) } } satisfies ElectronAPI) diff --git a/src/renderer/editor/editor.ts b/src/renderer/editor/editor.ts index ff0f71b..c92aa46 100644 --- a/src/renderer/editor/editor.ts +++ b/src/renderer/editor/editor.ts @@ -1,16 +1,64 @@ import { Editor, rootCtx, defaultValueCtx, editorViewCtx, serializerCtx, remarkPluginsCtx } from '@milkdown/kit/core' import { DOMSerializer } from '@milkdown/kit/prose/model' +import { Plugin, PluginKey } from '@milkdown/kit/prose/state' +import { DecorationSet, type EditorView } from '@milkdown/kit/prose/view' import remarkBreaks from 'remark-breaks' import { commonmark } from '@milkdown/kit/preset/commonmark' import { gfm } from '@milkdown/kit/preset/gfm' import { history } from '@milkdown/kit/plugin/history' import { listener, listenerCtx } from '@milkdown/kit/plugin/listener' import { clipboard } from '@milkdown/kit/plugin/clipboard' -import { replaceAll } from '@milkdown/kit/utils' +import { replaceAll, $prose } from '@milkdown/kit/utils' +import { remarkMathPlugin, katexOptionsCtx, mathInlineSchema, mathBlockSchema } from '@milkdown/plugin-math' import { htmlView } from './html-view' +import { mathModal } from './math-modal' +import 'katex/dist/katex.min.css' import '@milkdown/kit/prose/view/style/prosemirror.css' +export const searchPluginKey = new PluginKey('search-highlight') + +const searchHighlight = $prose(() => { + return new Plugin({ + key: searchPluginKey, + state: { + init() { + return DecorationSet.empty + }, + apply(tr, old) { + const meta = tr.getMeta(searchPluginKey) + if (meta !== undefined) return meta + return old.map(tr.mapping, tr.doc) + } + }, + props: { + decorations(state) { + return searchPluginKey.getState(state) + } + } + }) +}) + +const mathEditorPlugin = $prose(() => { + return new Plugin({ + props: { + handleClickOn(_view, _pos, node, nodePos) { + if (node.type.name === 'math_inline' || node.type.name === 'math_block') { + const isBlock = node.type.name === 'math_block' + const currentValue = isBlock ? node.attrs.value : node.textContent + mathModal.show(currentValue, isBlock, nodePos) + return true + } + return false + } + } + }) +}) + +export function showMathModal(): void { + mathModal.show() +} + let editorInstance: Editor | null = null const inlineStyles: Record = { @@ -69,7 +117,8 @@ export async function createEditor( .config((ctx) => { ctx.set(rootCtx, root) ctx.set(defaultValueCtx, defaultContent) - ctx.set(remarkPluginsCtx, [{ plugin: remarkBreaks, options: undefined }]) + ctx.set(remarkPluginsCtx, [{ plugin: remarkBreaks, options: {} }]) + ctx.set(katexOptionsCtx.key, { throwOnError: false }) if (onChange) { ctx.get(listenerCtx).markdownUpdated((_ctx, markdown) => { onChange(markdown) @@ -82,6 +131,9 @@ export async function createEditor( .use(listener) .use(clipboard) .use(htmlView) + .use([remarkMathPlugin, katexOptionsCtx, mathInlineSchema, mathBlockSchema].flat()) + .use(mathEditorPlugin) + .use(searchHighlight) .create() // Enhance clipboard with inline styles for rich text paste (e.g. WeChat) @@ -131,3 +183,12 @@ export function setMarkdown(content: string): void { if (!editorInstance) return editorInstance.action(replaceAll(content)) } + +export function getEditorView(): EditorView | null { + if (!editorInstance) return null + let view: EditorView | null = null + editorInstance.action((ctx) => { + view = ctx.get(editorViewCtx) + }) + return view +} diff --git a/src/renderer/editor/math-modal.ts b/src/renderer/editor/math-modal.ts new file mode 100644 index 0000000..2a3eaa2 --- /dev/null +++ b/src/renderer/editor/math-modal.ts @@ -0,0 +1,169 @@ +import { getEditorView } from './editor' + +export class MathModal { + private container: HTMLDivElement + private input: HTMLTextAreaElement + private isBlockCheckbox: HTMLInputElement + private currentTarget: { pos: number; isBlock: boolean } | null = null + + constructor() { + this.container = document.createElement('div') + this.container.className = 'math-modal-overlay' + this.container.style.display = 'none' + + const modal = document.createElement('div') + modal.className = 'math-modal' + + const header = document.createElement('h3') + header.textContent = 'Edit LaTeX Formula' + + this.input = document.createElement('textarea') + this.input.className = 'math-modal-input' + this.input.placeholder = 'Enter LaTeX code here... (e.g., E=mc^2)' + this.input.rows = 4 + + const options = document.createElement('div') + options.className = 'math-modal-options' + + this.isBlockCheckbox = document.createElement('input') + this.isBlockCheckbox.type = 'checkbox' + this.isBlockCheckbox.id = 'math-is-block' + + const label = document.createElement('label') + label.htmlFor = 'math-is-block' + label.textContent = ' Display as block formula (centered)' + + options.append(this.isBlockCheckbox, label) + + const footer = document.createElement('div') + footer.className = 'math-modal-footer' + + const cancelBtn = document.createElement('button') + cancelBtn.textContent = 'Cancel' + cancelBtn.className = 'math-modal-btn cancel' + cancelBtn.addEventListener('click', () => this.hide()) + + const saveBtn = document.createElement('button') + saveBtn.textContent = 'Insert / Update' + saveBtn.className = 'math-modal-btn save' + saveBtn.addEventListener('click', () => this.save()) + + footer.append(cancelBtn, saveBtn) + modal.append(header, this.input, options, footer) + this.container.appendChild(modal) + + this.container.addEventListener('mousedown', (e) => { + if (e.target === this.container) this.hide() + }) + + this.container.addEventListener('keydown', (e) => { + e.stopPropagation() + if (e.key === 'Escape') { + e.preventDefault() + this.hide() + } + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + this.save() + } + }) + + document.body.appendChild(this.container) + } + + show(initialValue = '', isBlock = false, targetPos: number | null = null): void { + this.currentTarget = targetPos !== null ? { pos: targetPos, isBlock } : null + this.input.value = initialValue + this.isBlockCheckbox.checked = isBlock + + this.container.style.display = 'flex' + + setTimeout(() => { + this.input.focus() + if (initialValue) this.input.select() + }, 50) + } + + hide(): void { + this.container.style.display = 'none' + this.currentTarget = null + const sourceEditor = this.getSourceEditor() + if (sourceEditor) { + sourceEditor.focus() + return + } + const view = getEditorView() + if (view) view.focus() + } + + private save(): void { + const value = this.input.value.trim() + const isBlock = this.isBlockCheckbox.checked + const sourceEditor = this.getSourceEditor() + + if (sourceEditor && this.currentTarget === null) { + if (value) { + const formula = isBlock ? `\n$$\n${value}\n$$\n` : `$${value}$` + sourceEditor.setRangeText( + formula, + sourceEditor.selectionStart, + sourceEditor.selectionEnd, + 'end' + ) + } + this.hide() + return + } + + const view = getEditorView() + + if (!view) { + this.hide() + return + } + + const tr = view.state.tr + const schema = view.state.schema + const nodeType = isBlock ? schema.nodes.math_block : schema.nodes.math_inline + + if (!nodeType) { + console.error('Math schema nodes not found. Is the math plugin loaded?') + this.hide() + return + } + + if (this.currentTarget !== null) { + const { pos, isBlock: wasBlock } = this.currentTarget + const node = view.state.doc.nodeAt(pos) + + if (node && (node.type.name === 'math_inline' || node.type.name === 'math_block')) { + if (!value) { + tr.delete(pos, pos + node.nodeSize) + } else if (isBlock === wasBlock) { + if (isBlock) { + tr.setNodeMarkup(pos, undefined, { ...node.attrs, value }) + } else { + tr.replaceWith(pos, pos + node.nodeSize, nodeType.create(null, schema.text(value))) + } + } else if (isBlock) { + tr.replaceWith(pos, pos + node.nodeSize, nodeType.create({ value })) + } else { + tr.replaceWith(pos, pos + node.nodeSize, nodeType.create(null, schema.text(value))) + } + } + } else if (value) { + const insertNode = isBlock ? nodeType.create({ value }) : nodeType.create(null, schema.text(value)) + tr.replaceSelectionWith(insertNode) + } + + view.dispatch(tr) + this.hide() + } + + private getSourceEditor(): HTMLTextAreaElement | null { + const sourceEditor = document.getElementById('source-editor') as HTMLTextAreaElement | null + return sourceEditor?.classList.contains('visible') ? sourceEditor : null + } +} + +export const mathModal = new MathModal() diff --git a/src/renderer/editor/search-panel.ts b/src/renderer/editor/search-panel.ts new file mode 100644 index 0000000..593aeb9 --- /dev/null +++ b/src/renderer/editor/search-panel.ts @@ -0,0 +1,244 @@ +import { Decoration, DecorationSet } from '@milkdown/kit/prose/view' +import { getEditorView, searchPluginKey } from './editor' + +export class SearchPanel { + private container: HTMLDivElement + private input: HTMLInputElement + private countEl: HTMLSpanElement + private matches: { from: number; to: number }[] = [] + private currentIndex = -1 + private visible = false + + constructor() { + this.container = document.createElement('div') + this.container.className = 'search-panel' + this.container.style.display = 'none' + + this.input = document.createElement('input') + this.input.type = 'text' + this.input.placeholder = 'Search...' + this.input.className = 'search-input' + + this.countEl = document.createElement('span') + this.countEl.className = 'search-count' + + const prevBtn = this.btn('\u2039', 'search-btn', () => this.prev()) + const nextBtn = this.btn('\u203A', 'search-btn', () => this.next()) + const closeBtn = this.btn('\u00D7', 'search-btn search-close', () => this.hide()) + + this.container.append(this.input, this.countEl, prevBtn, nextBtn, closeBtn) + + this.input.addEventListener('input', () => this.search()) + this.input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault() + if (e.shiftKey) this.prev() + else this.next() + } + }) + + this.container.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + e.preventDefault() + this.hide() + } else { + e.stopPropagation() + } + }) + + document.addEventListener( + 'keydown', + (e) => { + if (this.visible && e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + this.hide() + } + }, + true + ) + + document.body.appendChild(this.container) + } + + show(): void { + this.container.style.display = 'flex' + this.visible = true + this.input.focus() + this.input.select() + if (this.input.value) this.search() + } + + hide(): void { + this.container.style.display = 'none' + this.visible = false + this.clearDecorations() + this.matches = [] + this.currentIndex = -1 + this.countEl.textContent = '' + const sourceEditor = this.getSourceEditor() + if (sourceEditor) { + sourceEditor.focus() + return + } + const view = getEditorView() + if (view) view.focus() + } + + private btn(text: string, className: string, onClick: () => void): HTMLButtonElement { + const button = document.createElement('button') + button.textContent = text + button.className = className + button.addEventListener('click', onClick) + return button + } + + private search(): void { + const query = this.input.value + const sourceEditor = this.getSourceEditor() + if (sourceEditor) { + this.searchSourceEditor(query, sourceEditor) + return + } + + const view = getEditorView() + if (!view) return + + this.matches = [] + this.currentIndex = -1 + + if (!query) { + this.clearDecorations() + this.updateCount() + return + } + + const lowerQuery = query.toLowerCase() + view.state.doc.descendants((node, pos) => { + if (!node.isText || !node.text) return + const text = node.text.toLowerCase() + let idx = 0 + while ((idx = text.indexOf(lowerQuery, idx)) !== -1) { + this.matches.push({ from: pos + idx, to: pos + idx + query.length }) + idx += 1 + } + }) + + if (this.matches.length > 0) { + this.currentIndex = 0 + this.highlight(view) + this.scrollToCurrent(view) + } else { + this.clearDecorations() + } + this.updateCount() + } + + private getSourceEditor(): HTMLTextAreaElement | null { + const sourceEditor = document.getElementById('source-editor') as HTMLTextAreaElement | null + return sourceEditor?.classList.contains('visible') ? sourceEditor : null + } + + private searchSourceEditor(query: string, sourceEditor: HTMLTextAreaElement): void { + this.clearDecorations() + this.matches = [] + this.currentIndex = -1 + + if (!query) { + this.updateCount() + return + } + + const lowerQuery = query.toLowerCase() + const text = sourceEditor.value.toLowerCase() + let idx = 0 + while ((idx = text.indexOf(lowerQuery, idx)) !== -1) { + this.matches.push({ from: idx, to: idx + query.length }) + idx += 1 + } + + if (this.matches.length > 0) { + this.currentIndex = 0 + this.selectSourceMatch(sourceEditor) + } + this.updateCount() + } + + private highlight(view: NonNullable>): void { + const decorations = this.matches.map((match, index) => { + const className = index === this.currentIndex ? 'search-match-current' : 'search-match' + return Decoration.inline(match.from, match.to, { class: className }) + }) + view.dispatch(view.state.tr.setMeta(searchPluginKey, DecorationSet.create(view.state.doc, decorations))) + } + + private clearDecorations(): void { + const view = getEditorView() + if (!view) return + view.dispatch(view.state.tr.setMeta(searchPluginKey, DecorationSet.empty)) + } + + private scrollToCurrent(view: NonNullable>): void { + if (this.currentIndex < 0 || this.currentIndex >= this.matches.length) return + const match = this.matches[this.currentIndex] + const coords = view.coordsAtPos(match.from) + const editorEl = document.getElementById('editor') + if (!editorEl) return + + const rect = editorEl.getBoundingClientRect() + const targetTop = editorEl.scrollTop + coords.top - rect.top - rect.height / 3 + editorEl.scrollTo({ top: targetTop, behavior: 'smooth' }) + } + + private next(): void { + if (this.matches.length === 0) return + this.currentIndex = (this.currentIndex + 1) % this.matches.length + const sourceEditor = this.getSourceEditor() + if (sourceEditor) { + this.selectSourceMatch(sourceEditor) + this.updateCount() + return + } + + const view = getEditorView() + if (view) { + this.highlight(view) + this.scrollToCurrent(view) + } + this.updateCount() + } + + private prev(): void { + if (this.matches.length === 0) return + this.currentIndex = (this.currentIndex - 1 + this.matches.length) % this.matches.length + const sourceEditor = this.getSourceEditor() + if (sourceEditor) { + this.selectSourceMatch(sourceEditor) + this.updateCount() + return + } + + const view = getEditorView() + if (view) { + this.highlight(view) + this.scrollToCurrent(view) + } + this.updateCount() + } + + private updateCount(): void { + if (this.matches.length === 0) { + this.countEl.textContent = this.input.value ? '0/0' : '' + } else { + this.countEl.textContent = `${this.currentIndex + 1}/${this.matches.length}` + } + } + + private selectSourceMatch(sourceEditor: HTMLTextAreaElement): void { + if (this.currentIndex < 0 || this.currentIndex >= this.matches.length) return + const match = this.matches[this.currentIndex] + sourceEditor.focus() + sourceEditor.setSelectionRange(match.from, match.to) + this.input.focus() + } +} diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index 9b25201..e0a09ba 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -1,7 +1,8 @@ -import type { ElectronAPI } from '../preload/index' +declare module 'katex/dist/katex.min.css?inline' { + const css: string + export default css +} -declare global { - interface Window { - electronAPI: ElectronAPI - } +interface Window { + electronAPI: import('../preload/index').ElectronAPI } diff --git a/src/renderer/main.ts b/src/renderer/main.ts index 487ae6c..e120e70 100644 --- a/src/renderer/main.ts +++ b/src/renderer/main.ts @@ -1,7 +1,11 @@ -import { createEditor, getMarkdown, getHTML, setMarkdown } from './editor/editor' +import { createEditor, getMarkdown, getHTML, setMarkdown, showMathModal } from './editor/editor' +import { SearchPanel } from './editor/search-panel' import { applyTheme, loadSavedTheme } from './themes/theme-manager' +import katexCSS from 'katex/dist/katex.min.css?inline' import './themes/base.css' +const katexExportCSS = katexCSS.replace(/@font-face\{[^}]+\}/g, '') + function isSlidesContent(content: string): boolean { return /^---\s*\n[\s\S]*?(kicker|chip):/m.test(content) } @@ -52,6 +56,10 @@ async function init(): Promise { if (css) applyTheme(savedTheme, css) } + const searchPanel = new SearchPanel() + api.onSearch(() => searchPanel.show()) + api.onMathModal(() => showMathModal()) + await createEditor('editor') // Slides button — open as slides @@ -110,6 +118,7 @@ th{background:${tableHeaderBg};font-weight:600} hr{border:none;border-top:2px solid ${borderColor};margin:2em 0} img{max-width:100%} ::selection{background:${selectionBg}} +${katexExportCSS} ${getHTML()}` api.exportHTML(html) diff --git a/src/renderer/themes/base.css b/src/renderer/themes/base.css index 604bb2b..ccad719 100644 --- a/src/renderer/themes/base.css +++ b/src/renderer/themes/base.css @@ -368,3 +368,191 @@ body.theme-newsprint #editor .ProseMirror { background: var(--border-color); border-radius: 3px; } + +/* ======= SEARCH PANEL ======= */ + +.search-panel { + position: fixed; + top: 10px; + right: 56px; + z-index: 1000; + display: flex; + align-items: center; + gap: 4px; + padding: 6px 8px; + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + -webkit-app-region: no-drag; +} + +.search-input { + width: 180px; + padding: 4px 8px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-color); + color: var(--text-color); + font-size: 13px; + outline: none; +} + +.search-input:focus { + border-color: var(--link-color); +} + +.search-count { + min-width: 32px; + color: var(--text-muted); + font-size: 12px; + text-align: center; +} + +.search-btn { + padding: 2px 8px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: none; + color: var(--text-color); + font-size: 16px; + line-height: 1; + cursor: pointer; +} + +.search-btn:hover { + background: var(--code-bg); +} + +.search-close { + font-size: 18px; +} + +.search-match { + background: rgba(255, 213, 79, 0.4); + border-radius: 2px; +} + +.search-match-current { + background: rgba(255, 152, 0, 0.6); + border-radius: 2px; +} + +/* ======= MATH ======= */ + +#editor .ProseMirror .math-inline { + padding: 0 2px; + border-radius: 3px; + cursor: pointer; + transition: background 0.15s; +} + +#editor .ProseMirror .math-inline:hover { + background: var(--code-bg); +} + +#editor .ProseMirror .math-block { + margin: 1em 0; + padding: 8px; + border-radius: 6px; + text-align: center; + cursor: pointer; + transition: background 0.15s; +} + +#editor .ProseMirror .math-block:hover { + background: var(--code-bg); +} + +.math-modal-overlay { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(2px); +} + +.math-modal { + display: flex; + width: 480px; + max-width: 90vw; + padding: 20px; + flex-direction: column; + gap: 16px; + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: 12px; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2); + -webkit-app-region: no-drag; +} + +.math-modal h3 { + margin: 0; + color: var(--text-color); + font-size: 16px; + font-weight: 600; +} + +.math-modal-input { + width: 100%; + padding: 12px; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-color); + color: var(--text-color); + font-family: 'SF Mono', 'Fira Code', Menlo, monospace; + font-size: 14px; + line-height: 1.5; + outline: none; + resize: vertical; +} + +.math-modal-input:focus { + border-color: var(--link-color); +} + +.math-modal-options { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-muted); + font-size: 13px; +} + +.math-modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 4px; +} + +.math-modal-btn { + padding: 6px 16px; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; +} + +.math-modal-btn.cancel { + border: 1px solid var(--border-color); + background: transparent; + color: var(--text-color); +} + +.math-modal-btn.cancel:hover { + background: var(--code-bg); +} + +.math-modal-btn.save { + background: var(--link-color); + color: #fff; +} + +.math-modal-btn.save:hover { + opacity: 0.9; +}