diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index d670af0..38bf050 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -8,10 +8,11 @@ body: label: App description: Which part of the suite is affected? options: - - HermesOffice Docs (docs) - - HermesOffice Sheets (sheets) - - HermesOffice Slides (slides) - - HermesOffice PDF (pdf) + - GenOffice Docs (docs) + - GenOffice Sheets (sheets) + - GenOffice Slides (slides) + - GenOffice PDF (pdf) + - GenOffice Markdown (markdown) - Shell / home screen - AI panel / providers - Build / packaging diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index e1ed1dd..8a52ec5 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -7,10 +7,11 @@ body: attributes: label: App options: - - HermesOffice Docs (docs) - - HermesOffice Sheets (sheets) - - HermesOffice Slides (slides) - - HermesOffice PDF (pdf) + - GenOffice Docs (docs) + - GenOffice Sheets (sheets) + - GenOffice Slides (slides) + - GenOffice PDF (pdf) + - GenOffice Markdown (markdown) - Shell / home screen - AI panel / providers - Whole suite diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfe5b30..bd5dbcb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,15 @@ jobs: fi npm run format:check + - name: Check theme colors + env: + FORMAT_BASE_REF: ${{ github.event.pull_request.base.sha || github.event.before }} + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + FORMAT_BASE_REF=$(git rev-parse HEAD^1) + fi + npm run check:theme-colors + - name: Lint run: npm run lint diff --git a/.gitignore b/.gitignore index 8ad06c3..26a194b 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,9 @@ packages/hermes-cloud/src/oauth-credentials.ts /*.zip /windows/ +# Playwright artifacts +/test-results/ + .cursor/rules/git-remote.mdc __pycache__/ /notes/ diff --git a/.nvmrc b/.nvmrc index 209e3ef..2bd5a0a 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20 +22 diff --git a/.prettierignore b/.prettierignore index 321cab5..a5333dc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -13,3 +13,6 @@ apps/shell/build/THIRD-PARTY-NOTICES.txt # Local-only acceptance drivers are intentionally outside CI. scripts/drivers/driver.*.mjs + +# Vendored third-party sources stay byte-identical to upstream. +packages/*/src/vendor/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4c12da4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,47 @@ +# CLAUDE.md + +Guidance for AI agents and human contributors working in this repo. + +## Theming rules (mandatory) + +The suite supports light / dark / system UI themes. The switching mechanism is a +`data-theme` attribute on `` plus CSS custom properties defined once in +`packages/ui/src/tokens.css` (light defaults in `:root`, overrides in +`[data-theme='dark']`, and a `prefers-color-scheme` media-query fallback for +system mode). + +1. **UI chrome colors must use semantic tokens.** Never write raw `#hex` / + `rgb()` in renderer CSS rules or chrome-related inline styles — reference + `var(--surface)`, `var(--text)`, `var(--hover)`, etc. from + `packages/ui/src/tokens.css`. Raw values are allowed only on custom-property + definition lines (`--x: #...;` — token, accent, or app-scoped variable + definitions). CI enforces this for new/changed renderer CSS lines + (`tools/check-theme-colors.mjs`). +2. **Every new token gets both values.** Adding a token means adding it to all + three blocks in `tokens.css` (light, dark, system-dark fallback). +3. **Accent colors stay per-app.** Each app defines `--accent` / + `--accent-dark` / `--accent-soft` (and its dark-adjusted values) in its own + `styles.css`. Shared rules reference `var(--accent)` and inherit the app's + brand color. +4. **Document content never follows the theme.** Page surfaces, cell fills, + slide content, PDF page bitmaps, export/print stylesheets, chart palettes, + highlight color maps, stamps, and WordArt presets are document data: they + stay hardcoded, must not reference chrome tokens, and must render/export + identically in both themes. (Word-style "dark chrome, white paper".) +5. **Canvas-drawn UI affordances go through a constants table.** Konva/canvas + editing chrome (selection frames, guides, handles) reads from the app's + canvas color table (e.g. `canvas-colors.ts`) keyed by the current theme — + no inline hex in draw calls. + +## Build gotchas + +- App main-process code (`apps/*/src/main`) is compiled into the **shell** + build. After changing it, rebuild the shell or the change silently does not + run. +- In dev mode, preload changes require a rebuild — a stale preload leaves the + renderer blank. +- Workspace packages listed in an app's `dependencies` must also be added to + the `externalizeDepsPlugin` `exclude` list, or the packaged app crashes on + launch. +- `useI18n()`'s `t` is not referentially stable; never put it in a hook + dependency array. Store the key and translate at render time. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e4bd15e..5dcbcd1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ and issues labeled `good first issue` are small, well-scoped entry points. ## Repository layout -- `apps/*` — the five Electron apps (docs, sheets, slides, pdf, shell). +- `apps/*` — the six Electron apps (docs, sheets, slides, pdf, markdown, shell). Each app is an npm workspace with its own `src/main` (Electron main process), `src/renderer` (React UI), and `tests/`. - `packages/*` — pure TypeScript engine and shared packages (no Electron @@ -34,7 +34,7 @@ and issues labeled `good first issue` are small, well-scoped entry points. ## Getting started -Prerequisites: Node 20+, npm 10+, and a Rust toolchain (`cargo` on PATH, +Prerequisites: Node 22+, npm 10+, and a Rust toolchain (`cargo` on PATH, needed only for the sheets xlsx sidecar). ```bash @@ -119,7 +119,7 @@ within hours — the gate lives on your machine first. ## Building installers Run these from the repository root — they regenerate the third-party -notices and build all five apps before packaging: +notices and build all six apps before packaging: ```bash npm run dist:mac # dmg + zip diff --git a/LICENSE-UNICODE.txt b/LICENSE-UNICODE.txt new file mode 100644 index 0000000..72d4460 --- /dev/null +++ b/LICENSE-UNICODE.txt @@ -0,0 +1,39 @@ +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 1991-2026 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in +the Data Files or Software without restriction, including without +limitation the rights to use, copy, modify, merge, publish, distribute, +and/or sell copies of the Data Files or Software, and to permit persons +to whom the Data Files or Software are furnished to do so, provided that +either (a) this copyright and permission notice appear with all copies of +the Data Files or Software, or (b) this copyright and permission notice +appear in associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL +DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR +PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS +ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THE DATA FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or +other dealings in these Data Files or Software without prior written +authorization of the copyright holder. diff --git a/NOTICE b/NOTICE index 20c34d4..4cf2018 100644 --- a/NOTICE +++ b/NOTICE @@ -13,6 +13,7 @@ checkout. Bundled fonts and their licenses are documented in apps/docs/src/renderer/fonts/README.md. +<<<<<<< HEAD --- HermesOffice @@ -25,3 +26,15 @@ in full; all attribution to Mainfunc, Inc. and to the GenOffice project remains intact. This fork adds its own branding and a native integration with the Hermes Agent (Nous Research) as the default AI provider; upstream code keeps its original copyright. +======= +Unicode Character Database + +apps/pdf/src/shared/radicals.ts contains a generated mapping derived from +Unicode Character Database 17.0.0, EquivalentUnifiedIdeograph.txt +(2025-08-01): +https://www.unicode.org/Public/17.0.0/ucd/EquivalentUnifiedIdeograph.txt + +Copyright © 1991-2026 Unicode, Inc. This data is distributed under Unicode +License v3. The complete copyright and permission notice is reproduced in +LICENSE-UNICODE.txt. +>>>>>>> upstream/main diff --git a/SECURITY.md b/SECURITY.md index 2c9028b..6229a39 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,7 +12,7 @@ acknowledge reports within 72 hours. All application windows run with the full Electron renderer lockdown: - `contextIsolation: true`, `nodeIntegration: false`, `sandbox: true` for every - document window and tab view (docs, sheets, slides, pdf, shell, updater). + document window and tab view (docs, sheets, slides, pdf, markdown, shell, updater). - Renderers reach the main process only through typed, validated IPC channels (payloads are schema-checked in the main process; sheets uses zod end to end). - Every `shell.openExternal` call goes through a single shared gate diff --git a/apps/docs/electron.vite.config.ts b/apps/docs/electron.vite.config.ts index ed837ee..63dae1c 100644 --- a/apps/docs/electron.vite.config.ts +++ b/apps/docs/electron.vite.config.ts @@ -1,6 +1,14 @@ +import { resolve } from 'node:path' import react from '@vitejs/plugin-react' import { defineConfig, externalizeDepsPlugin } from 'electron-vite' +// Resolve workspace packages from this checkout's sources: in a git worktree +// node_modules is a symlink into the main checkout, so bare specifiers would +// silently bundle the other checkout's (possibly stale) code. +const localAlias = { + '@hermesoffice/docx-engine': resolve(__dirname, '../../packages/docx-engine/src/index.ts'), +} + export default defineConfig({ // Main and preload use only electron + node builtins; bundle everything so // the packaged app doesn't rely on node_modules at runtime. @@ -9,10 +17,12 @@ export default defineConfig({ // (same setup as apps/slides). main: { plugins: [externalizeDepsPlugin({ exclude: ['@hermesoffice/electron-utils'] })], + resolve: { alias: localAlias }, }, preload: {}, renderer: { plugins: [react()], + resolve: { alias: localAlias }, server: { // Overridable so multiple hermesoffice dev instances can coexist (default 5173). port: Number(process.env.DOCS_DEV_PORT) || 5173, diff --git a/apps/docs/package.json b/apps/docs/package.json index f681a37..ed2a48b 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -38,6 +38,7 @@ "electron-builder": "^26.0.12", "electron-vite": "^5.0.0", "jsdom": "^28.0.0", + "marked": "^17.0.6", "opentype.js": "^2.0.0", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/apps/docs/src/main/docs-main.ts b/apps/docs/src/main/docs-main.ts index dfa8d3c..165ec97 100644 --- a/apps/docs/src/main/docs-main.ts +++ b/apps/docs/src/main/docs-main.ts @@ -16,6 +16,7 @@ import { basename, dirname, join } from 'node:path' import { BrowserWindow, Menu, WebContentsView, app, dialog, ipcMain, shell } from 'electron' import { appMenuLabels, + configuredDefaultSaveDir, contextMenuLabels, fetchRemoteImage, installContextMenu, @@ -23,6 +24,7 @@ import { safeExternalUrl, showOpenDialogWithMemory, showSaveDialogWithMemory, + toggleDevToolsItem, windowMenuTemplate, } from '@hermesoffice/electron-utils' import { createI18n, getUiLang, normalizeLang, setUiLang } from '@hermesoffice/i18n' @@ -1947,14 +1949,14 @@ async function openDialog(event: IpcMainInvokeEvent, options: OpenDialogOptions) } async function saveDialog(event: IpcMainInvokeEvent, options: SaveDialogOptions) { - return showSaveDialogWithMemory(dialog, dialogParent(event), options) + // before any pick is remembered, bare-name suggestions anchor in the + // configurable default save folder instead of Electron's Downloads pin + return showSaveDialogWithMemory(dialog, dialogParent(event), options, defaultSaveDir()) } -/** default folder where new files land on their first (silent) save; shared with the other editors via shell */ +/** default folder where new files land on their first (silent) save; shared with the other editors via shell. User-configurable (app-settings.json), falls back to /HermesOffice. */ export function defaultSaveDir(): string { - const dir = join(app.getPath('documents'), 'HermesOffice') - mkdirSync(dir, { recursive: true }) - return dir + return configuredDefaultSaveDir(app) } /** first free path for fileName inside dir: name.ext, name-2.ext, name-3.ext… */ @@ -2157,7 +2159,13 @@ function allowPdfWrite(wcId: number, filePath: string): void { pdfWritablePaths.set(wcId, set) } +// Fidelity-harness escape hatch: headless runs have no save dialog to authorize +// paths, so an explicitly configured directory (set only by our test scripts) +// is treated as pre-authorized for PDF export. +const testExportDir = process.env.GENOFFICE_TEST_EXPORT_DIR || null + function canPdfWrite(wcId: number, filePath: string): boolean { + if (testExportDir && filePath.startsWith(testExportDir + '/')) return true return pdfWritablePaths.get(wcId)?.has(filePath) === true } @@ -3337,6 +3345,26 @@ function sendCommand(command: MenuCommand, payload?: string): void { activeDocsWebContents()?.send('menu:command', command, payload) } +/** + * Per-tab View-menu toggle state (AI Sidebar / Dark Mode), reported by each + * renderer whenever it changes. The template can't hardcode `checked` — the + * state lives in the renderer and differs per tab — so builds read the active + * tab's last report, and reports from the active tab patch the built menu in + * place (buildDocsMenu also re-runs on every tab focus switch). + * Defaults mirror the renderer's initial state: sidebar shown, light canvas. + */ +const viewMenuStateByWebContents = new Map() + +function activeViewMenuState(): { aiSidebar: boolean; darkCanvas: boolean } { + const id = activeDocsWebContents()?.id + return ( + (id !== undefined ? viewMenuStateByWebContents.get(id) : undefined) ?? { + aiSidebar: true, + darkCanvas: false, + } + ) +} + /** shell-injected items appended to the File menu (e.g. Back to Home); persists * across the internal rebuilds pushRecent() triggers */ let extraFileMenuItems: MenuItemConstructorOptions[] = [] @@ -3477,11 +3505,23 @@ export function buildDocsMenu(): void { { label: tm('menuPageWidth'), click: () => sendCommand('zoom-page-width') }, { label: tm('menuWholePage'), click: () => sendCommand('zoom-whole-page') }, { type: 'separator' }, - { label: tm('menuAiSidebar'), click: () => sendCommand('toggle-ai') }, - { label: tm('menuDarkMode'), click: () => sendCommand('toggle-dark') }, + { + id: 'docs-menu-ai-sidebar', + type: 'checkbox', + checked: activeViewMenuState().aiSidebar, + label: tm('menuAiSidebar'), + click: () => sendCommand('toggle-ai'), + }, + { + id: 'docs-menu-dark-mode', + type: 'checkbox', + checked: activeViewMenuState().darkCanvas, + label: tm('menuDarkMode'), + click: () => sendCommand('toggle-dark'), + }, { type: 'separator' }, { role: 'togglefullscreen', label: tm('menuFullscreen') }, - ...(isDev ? [{ role: 'toggleDevTools' as const }] : []), + ...(isDev ? [toggleDevToolsItem(appMenuLabels(getUiLang()))] : []), ], }, { @@ -3644,6 +3684,24 @@ interface DocsCloseState { const closeCheckWaiters = new Map void>() const closeSaveWaiters = new Map void>() +ipcMain.on('docs:view-menu-state', (event, state: unknown) => { + const s = state as { aiSidebar?: unknown; darkCanvas?: unknown } | null + const next = { aiSidebar: s?.aiSidebar === true, darkCanvas: s?.darkCanvas === true } + if (!viewMenuStateByWebContents.has(event.sender.id)) { + const id = event.sender.id + event.sender.once('destroyed', () => viewMenuStateByWebContents.delete(id)) + } + viewMenuStateByWebContents.set(event.sender.id, next) + // patch the live menu only for the active tab; an inactive tab's state gets + // picked up by the buildDocsMenu run its next focus triggers + if (event.sender.id !== activeDocsWebContents()?.id) return + const menu = Menu.getApplicationMenu() + const ai = menu?.getMenuItemById('docs-menu-ai-sidebar') + if (ai) ai.checked = next.aiSidebar + const dark = menu?.getMenuItemById('docs-menu-dark-mode') + if (dark) dark.checked = next.darkCanvas +}) + ipcMain.on('docs:close-check-result', (event, state: unknown) => { const waiter = closeCheckWaiters.get(event.sender.id) if (!waiter) return diff --git a/apps/docs/src/preload/index.ts b/apps/docs/src/preload/index.ts index b95b160..ac73009 100644 --- a/apps/docs/src/preload/index.ts +++ b/apps/docs/src/preload/index.ts @@ -7,6 +7,7 @@ import type { AiStreamRequest, DesktopApi, MenuCommand, + UiTheme, } from '../shared/ipc' import type { ProjectApi } from '@hermesoffice/project-store' @@ -20,6 +21,12 @@ const api: DesktopApi = { ipcRenderer.on('app:language-changed', listener) return () => ipcRenderer.removeListener('app:language-changed', listener) }, + getTheme: () => ipcRenderer.invoke('app:get-theme'), + onThemeChanged: (handler) => { + const listener = (_event: IpcRendererEvent, theme: UiTheme) => handler(theme) + ipcRenderer.on('app:theme-changed', listener) + return () => ipcRenderer.removeListener('app:theme-changed', listener) + }, openDocx: () => ipcRenderer.invoke('docs:open'), openDocxPath: (path: string) => ipcRenderer.invoke('docs:open-path', path), consumePendingOpenDocx: () => ipcRenderer.invoke('docs:consume-pending-open'), @@ -111,6 +118,11 @@ const api: DesktopApi = { ipcRenderer.on('docs:close-check', listener) return () => ipcRenderer.removeListener('docs:close-check', listener) }, + reportViewMenuState: (state: { aiSidebar: boolean; darkCanvas: boolean }) => + ipcRenderer.send('docs:view-menu-state', { + aiSidebar: state?.aiSidebar === true, + darkCanvas: state?.darkCanvas === true, + }), reportCloseCheck: (state: { dirty: boolean; autoSave: boolean; filePath?: string | null }) => ipcRenderer.send('docs:close-check-result', { dirty: state?.dirty === true, diff --git a/apps/docs/src/renderer/App.tsx b/apps/docs/src/renderer/App.tsx index feb4d06..4d5ab47 100644 --- a/apps/docs/src/renderer/App.tsx +++ b/apps/docs/src/renderer/App.tsx @@ -3,6 +3,7 @@ import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react' import { EditorContent, useEditor } from '@tiptap/react' import type { Editor } from '@tiptap/core' import { DOMParser as PmDOMParser, type Mark as PmMark } from '@tiptap/pm/model' +import { markdownPasteHtml } from './editor/markdown-paste' import { BLANK_BULLET_NUM_ID, BLANK_ORDERED_NUM_ID, @@ -39,6 +40,7 @@ import { liveSections, nextLineAnchor, measureBlocks, + docGridPitchPt, type LineAnchor, pageNumbers, sliceWithLineSplit, @@ -47,6 +49,7 @@ import { type BlockMeta, type PageNoteItem, pageAt, + singleCutCell, sectionColGeom, sectionColumns, sectionFirstPages, @@ -55,6 +58,7 @@ import { effectiveTopPx, effectiveBottomPx, formatPageNumber, + visiblePageCount, type SectionGeom, type SectionHfHeights, type PageSlice, @@ -103,6 +107,7 @@ import { } from './editor/active-editor' import type { CompareEntry } from './editor/compare' import { collectHeadings } from './editor/headings' +import { applyTocPageDisplays } from './editor/toc-refresh' import { setSelectionAlign } from './editor/direction' import { @@ -115,11 +120,12 @@ import { InkOverlay } from './components/InkOverlay' import { collectRevisions, gotoRevision, type TrackChangesStorage } from './editor/revisions' import { NavPane } from './components/NavPane' import { Ruler } from './components/Ruler' -import { docLineFactor, docThemeCss } from './doc-style-css' +import { docBodyFont, docLineFactor, docThemeCss } from './doc-style-css' import { isDocDirty } from './doc-dirty' import { EMPTY_HF_VARIANTS, hfFromPart, + restingHfAreaVariant, type DocState, type HfVariantKey, type HfVariantsState, @@ -167,6 +173,13 @@ const _IS_MAC = navigator.platform.toLowerCase().includes('mac') const twipsToPx = (twips: number) => (twips / 1440) * 96 +/** tiny stable string hash for decoration keys */ +function hashStr(str: string): number { + let h = 0 + for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) | 0 + return h +} + const EMPTY_BLOCKS: Block[] = [] // O(doc) derivations cached by PM doc reference: caret moves and unrelated @@ -197,6 +210,24 @@ function cleanPastedHtml(html: string): string { .replace(/]*)>\s*]*>([\s\S]*?)<\/p>\s*<\/li>/g, '$2') } +/** All runs a footnote/endnote reference may live in: paragraph runs, plus table + * cell paragraphs (incl. nested tables) — refs inside cells still print their + * note at the page bottom like Word */ +function blockNoteScanRuns(b: Block): NonNullable { + const out: NonNullable = [] + if (b.runs) out.push(...b.runs) + const walkTable = (table: NonNullable | undefined): void => { + for (const row of table?.rows ?? []) { + for (const cell of row) { + for (const p of cell.richParas ?? []) out.push(...p.runs) + for (const nested of cell.nestedTables ?? []) walkTable(nested) + } + } + } + if (b.table) walkTable(b.table) + return out +} + /** Footnote area at the top of a page gap (previous page's bottom): absolutely positioned in the content area, double-click an entry to edit */ function makeGapNotesEl( items: PageNoteItem[], @@ -307,7 +338,17 @@ export function App() { const [titlePgDirty, setTitlePgDirty] = useState(false) const [evenOddHf, setEvenOddHf] = useState(false) const [evenOddHfDirty, setEvenOddHfDirty] = useState(false) - const [hfView, setHfView] = useState('default') + const [hfView, setHfViewState] = useState('default') + /** false until the user picks a variant (chip/toggle); resting areas then follow their page's variant instead */ + const [hfViewTouched, setHfViewTouched] = useState(false) + const setHfView = (v: HfView) => { + setHfViewState(v) + setHfViewTouched(true) + } + const [pageInfo, setPageInfo] = useState({ current: 1, total: 1 }) + // last page's number for the document-end footer: text for the page marker, num for + // even/odd parity (section restarts / pageNumberFmt make both differ from the physical count) + const [lastPageNo, setLastPageNo] = useState<{ text: string; num: number } | null>(null) /** Page-number-format dialog + pending final-section pgNumType write (non-final sections rewrite sectPr via pgNumDirtySections) */ const [pgNumModal, setPgNumModal] = useState<{ fmt: string; start: string } | null>(null) const [pgNumEdit, setPgNumEdit] = useState<{ fmt?: string; start?: number } | null>(null) @@ -345,18 +386,30 @@ export function App() { } return sectionHfEdits[`${sec.lastBlockIndex}:${kind}`] ?? fromPart() } + /** Variant an on-canvas area shows/edits: explicit chip choice wins; at rest the header area is page 1 and the footer area the last page */ + const areaView = (kind: 'header' | 'footer'): HfView => + hfViewTouched + ? effHfView + : restingHfAreaVariant(kind, { + titlePg, + evenOddHf, + pageCount: pageInfo.total, + ...(lastPageNo ? { lastPageNo: lastPageNo.num } : {}), + }) + const headerAreaView = areaView('header') + const footerAreaView = areaView('footer') const shownHeader = - effHfView === 'first' + headerAreaView === 'first' ? hfVariants.headerFirst - : effHfView === 'even' + : headerAreaView === 'even' ? hfVariants.headerEven : multiHf ? sectionHfValue('header') : header const shownFooter = - effHfView === 'first' + footerAreaView === 'first' ? hfVariants.footerFirst - : effHfView === 'even' + : footerAreaView === 'even' ? hfVariants.footerEven : multiHf ? sectionHfValue('footer') @@ -365,10 +418,11 @@ export function App() { const hfImagesOf = (kind: 'header' | 'footer') => { const parsed = doc?.parsed if (!parsed) return undefined - if (effHfView === 'first') { + const view = kind === 'header' ? headerAreaView : footerAreaView + if (view === 'first') { return (kind === 'header' ? parsed.headerFirst : parsed.footerFirst)?.images } - if (effHfView === 'even') { + if (view === 'even') { return (kind === 'header' ? parsed.headerEven : parsed.footerEven)?.images } if (multiHf) { @@ -380,13 +434,14 @@ export function App() { } const commitHf = (kind: 'header' | 'footer', next: HeaderFooter) => { + const view = kind === 'header' ? headerAreaView : footerAreaView // multi-section and not the final one: use the per-section edit channel (that section becomes its own part; earlier sections are unaffected) - if (effHfView === 'default' && multiHf && hfSectionClamped < sections.length - 1) { + if (view === 'default' && multiHf && hfSectionClamped < sections.length - 1) { const sec = sections[hfSectionClamped] setSectionHfEdits((m) => ({ ...m, [`${sec.lastBlockIndex}:${kind}`]: next })) return } - if (effHfView === 'default') { + if (view === 'default') { if (kind === 'header') { setHeader(next) setHeaderDirty(true) @@ -397,7 +452,7 @@ export function App() { return } const key: HfVariantKey = - effHfView === 'first' + view === 'first' ? kind === 'header' ? 'headerFirst' : 'footerFirst' @@ -500,10 +555,6 @@ export function App() { const [ctxMenu, setCtxMenu] = useState(null) const [showFontDialog, setShowFontDialog] = useState(false) const [showParaDialog, setShowParaDialog] = useState(false) - const [pageInfo, setPageInfo] = useState({ current: 1, total: 1 }) - // last page's displayed number for the document-end footer '#' marker - // (section restarts / pageNumberFmt make it differ from the physical count) - const [lastPageNo, setLastPageNo] = useState(null) const [, forceRender] = useReducer((x: number) => x + 1, 0) const dirtyRef = useRef(false) // serializes save(): overlapping saves (Cmd+S vs autosave timer vs blur) would @@ -568,6 +619,45 @@ export function App() { reader.readAsDataURL(imageFile) return true } + // text/plain-only Markdown (code blocks, terminals, .md files, LLM + // output): convert and insert as formatted content instead of literal + // "## Heading" / "**bold**" characters + if (!html && text) { + const markdownHtml = markdownPasteHtml(text) + if (markdownHtml !== null) { + try { + // cleanPastedHtml unwraps
  • from loose lists — + // docListItem only allows inline content and would shatter them. + const dom = new window.DOMParser().parseFromString( + cleanPastedHtml(markdownHtml), + 'text/html', + ) + const parser = PmDOMParser.fromSchema(view.state.schema) + if ( + selEmpty && + $from.parent.isTextblock && + $from.parent.content.size === 0 && + $from.depth === 1 + ) { + // same wholesale replace as block HTML onto an empty paragraph + const parsed = parser.parse(dom.body) + if (parsed.content.childCount > 0) { + view.dispatch( + view.state.tr.replaceWith($from.before(1), $from.after(1), parsed.content), + ) + return true + } + } else { + view.dispatch( + view.state.tr.replaceSelection(parser.parseSlice(dom.body)).scrollIntoView(), + ) + return true + } + } catch { + /* fall back to default literal paste on parse failure */ + } + } + } return false }, }, @@ -645,6 +735,11 @@ export function App() { useEffect(() => window.desktop.onTeardown?.(() => setTornDown(true)), []) + // keep the native View menu's checkmarks (AI Sidebar / Dark Mode) in sync + useEffect(() => { + window.desktop.reportViewMenuState?.({ aiSidebar: showAi, darkCanvas }) + }, [showAi, darkCanvas]) + // Crash-recovery copy: while the document is dirty, push a serialized // copy to the main process every 30s; a normal save (or discarding on close) removes // it, and reopening the file offers Restore/Discard when a newer copy exists. @@ -757,7 +852,11 @@ export function App() { setTitlePgDirty, setEvenOddHf, setEvenOddHfDirty, - setHfView, + // opening a document resets to the resting per-page variant selection + setHfView: (v: HfView) => { + setHfViewState(v) + setHfViewTouched(false) + }, pgNumEdit, pgNumDirtySections, setPgNumEdit, @@ -1329,9 +1428,10 @@ export function App() { // page-bottom height (px) reserved for footnote references inside a block: same estimation model as the parity runner const footnoteExtraOf = useCallback( (b: Block): number => { - if (!b.runs || footnotes.length === 0) return 0 + const runs = blockNoteScanRuns(b) + if (runs.length === 0 || footnotes.length === 0) return 0 let extra = 0 - for (const run of b.runs) { + for (const run of runs) { if (run.noteRef?.kind !== 'footnote') continue const sec = sections.find((s) => (b.docxIndex ?? 0) <= s.lastBlockIndex)?.settings ?? section @@ -1345,6 +1445,9 @@ export function App() { [footnotes, sections, section], ) + // typed w:docGrid line pitch (pt) when every section shares one; null = no snapping + const gridPitchPt = useMemo(() => docGridPitchPt(sections), [sections]) + // single-section header/footer push-down: body top = max(marginTop, headerDist + header height) const singleHfPx = useMemo(() => { if (!section) return { headerPx: 0, footerPx: 0 } @@ -1426,8 +1529,10 @@ export function App() { for (const b of blocks) { if (b.docxIndex === undefined) continue const pb = doc.parsed.blocks.find((bl) => bl.docxIndex === b.docxIndex) - if (!pb?.runs) continue - const ids = pb.runs.filter((r) => r.noteRef?.kind === 'footnote').map((r) => r.noteRef!.id) + if (!pb) continue + const ids = blockNoteScanRuns(pb) + .filter((r) => r.noteRef?.kind === 'footnote') + .map((r) => r.noteRef!.id) if (ids.length === 0) continue const page = pageAt(slices, b.top + 0.5) - 1 const sec = sections.find((s) => b.docxIndex! <= s.lastBlockIndex)?.settings ?? section @@ -1531,7 +1636,8 @@ export function App() { } return { mBlocks: blocks, slices: s, secs: live } }) - const nums = secs.length > 1 ? pageNumbers(slices, secs) : slices.map((_, i) => i + 1) + // same page-number algorithm as the footer path (w:pgNumType start offsets apply to single-section docs too) + const nums = secs.length > 0 ? pageNumbers(slices, secs) : slices.map((_, i) => i + 1) const byEl = new Map(mBlocks.filter((b) => b.el).map((b) => [b.el as HTMLElement, b.top])) const pages: number[] = [] for (const h of collectHeadings(editor.state.doc)) { @@ -1576,15 +1682,18 @@ export function App() { const scrollRect = scroller.getBoundingClientRect() const origin = pmRect.top + mTopPx * factor const midScreen = Math.min(scrollRect.top + scrollRect.height / 2, pmRect.bottom) - // slices use gapless virtual coordinates; subtract the page-gap height above the midpoint (including mid-paragraph inline gaps) + // slices use gapless virtual coordinates; subtract the page-gap height above the midpoint + // (including mid-paragraph inline gaps and repeated-header clone rows, which carry + // page-repeat-header but not page-gap) let gapAbove = 0 - for (const gap of pm.querySelectorAll('.page-gap')) { + for (const gap of pm.querySelectorAll('.page-gap, .page-repeat-header')) { const r = gap.getBoundingClientRect() if (r.top < midScreen) gapAbove += Math.min(r.height, midScreen - r.top) } const midY = (midScreen - origin - gapAbove) / factor - const current = pageAt(slices, midY) - const total = slices.length + // visible-page numbering so the status bar and F9 NUMPAGES agree with the gap widgets + const current = visiblePageCount(slices, pageAt(slices, midY)) + const total = visiblePageCount(slices) setPageInfo((prev) => prev.current === current && prev.total === total ? prev : { current, total }, ) @@ -1592,12 +1701,17 @@ export function App() { const remeasure = () => { const pm = pmEl() if (!pm) return + const tStart = performance.now() + let tMeasure = 0 + let tSlice = 0 // a columned canvas measures + slices in the single-flow measuring state (fillLineBoxes // also reads the DOM for line sampling, so it must share the state); display-state DOM // reads like gap positioning happen outside the measuring state const measured = measureSingleFlow(pm, () => { + const t0 = performance.now() const origin = pm.getBoundingClientRect().top + mTopPx * factor const { blocks, totalHeight } = measureBlocks(pm, origin, factor) + tMeasure = performance.now() - t0 // multi-section: assign blocks to sections by docxIndex; each section has its own content height / forced breaks. // liveSections: when a section-break block is deleted, that section merges into the next in real time (effective before saving) const secList = sections.length > 0 ? liveSections(sections, blocks) : null @@ -1611,6 +1725,7 @@ export function App() { ) const flowH = withEndnotes?.totalHeight ?? totalHeight const hfHs = secList ? hfHeightsOf(secList) : null + const t1 = performance.now() const s = secList ? sliceWithLineSplit( blocks, @@ -1626,6 +1741,7 @@ export function App() { factor, blockMetaOf, ) + tSlice = performance.now() - t1 return { blocks, secList, hfHs, s } }) const { blocks, secList, hfHs } = measured @@ -1633,18 +1749,47 @@ export function App() { // document-end footer shows the last page's displayed number, not the physical count if (slices.length > 0) { const lastIdx = slices.length - 1 - setLastPageNo( - secList + const num = secList ? pageNumbers(slices, secList)[lastIdx] : slices.length + setLastPageNo({ + num, + text: secList ? formatPageNumber( - pageNumbers(slices, secList)[lastIdx], + num, secList[Math.min(slices[lastIdx].section, secList.length - 1)]?.pageNumberFmt, ) - : slices.length, - ) + : String(num), + }) + } + // Auto-refresh TOC page numbers from the fresh slicing, like Word's field + // update. Gated on dirtyRef — our pagination approximates Word's, so a + // pristine open keeps the file's numbers. History-exempt: a field result + // change is not an edit to undo. + if (editor && dirtyRef.current && slices.length > 0) { + const nums = secList ? pageNumbers(slices, secList) : slices.map((_, n) => n + 1) + const byEl = new Map(blocks.filter((b) => b.el).map((b) => [b.el as HTMLElement, b.top])) + const headings = collectHeadings(editor.state.doc) + // formatted with the owning section's pgNumType, like the header/footer numbers + const displays = headings.map((h) => { + const dom = editor.view.nodeDOM(h.pos) as HTMLElement | null + const top = dom ? byEl.get(dom) : undefined + if (top === undefined) return undefined + const idx = Math.min(Math.max(pageAt(slices, top + 1), 1), nums.length) + const fmt = + secList?.[Math.min(slices[idx - 1].section, secList.length - 1)]?.pageNumberFmt + return formatPageNumber(nums[idx - 1] ?? idx, fmt) + }) + const tr = editor.state.tr + if (applyTocPageDisplays(editor.state.doc, tr, headings, displays)) { + tr.setMeta('addToHistory', false) + editor.view.dispatch(tr) + } } // M4 always-on pagination in the canvas: render page gaps before page-leading blocks // (print view only; gaps don't count as content — measureBlocks subtracts them, so // slice results are gap-independent and refresh is idempotent) + let tGapsBuild: number | undefined + let tSetGaps: number | undefined + const tGaps0 = performance.now() if (editor) { const gaps: PageGapSpec[] = [] const gapIds = new Set() @@ -1701,6 +1846,10 @@ export function App() { ) const hfSig = (v: HeaderFooter | null | undefined) => v ? `${v.text}·${v.pageNumber ? 1 : 0}·${v.paras?.length ?? 0}` : '' + const visiblePages = visiblePageCount(slices) + // stopgap until per-section canvas geometry: a landscape section's header/footer + // strip must not overflow the (portrait) canvas paper it is drawn on + const canvasPaperW = pmRect.width / factor slices.slice(1).forEach((slice, k) => { // an even/odd section's zero-height blank page shares its start with the following page: draw only one gap band on the canvas if (slice.start === slices[k].start) return @@ -1728,11 +1877,11 @@ export function App() { value: gapFooter.value ?? { text: '' }, images: gapFooter.images, pageNo: pageNoTextOf(k), - pageTotal: slices.length, + pageTotal: visiblePages, }) el.style.top = 'auto' el.style.bottom = `${GAP_BAND + metrics.marginTop + box.footerDist}px` - el.style.width = `${box.width - 120}px` + el.style.width = `${Math.min(box.width, canvasPaperW) - 120}px` hfEls.push(el) } if (hfHasVisibleContent(gapHeader.value, gapHeader.images)) { @@ -1742,11 +1891,11 @@ export function App() { value: gapHeader.value ?? { text: '' }, images: gapHeader.images, pageNo: pageNoTextOf(k + 1), - pageTotal: slices.length, + pageTotal: visiblePages, }) el.style.bottom = 'auto' el.style.top = `calc(100% - ${Math.max(0, metrics.marginTop - box.headerDist)}px)` - el.style.width = `${box.width - 120}px` + el.style.width = `${Math.min(box.width, canvasPaperW) - 120}px` hfEls.push(el) } const hfProps = @@ -1755,7 +1904,7 @@ export function App() { hfEls, // key must cover everything baked into the widgets (both pages' // formatted numbers + total count), or stale PAGE/NUMPAGES survive reuse - hfKey: `${pageNoTextOf(k)}·${pageNoTextOf(k + 1)}·${slices.length}·${hfSig(gapFooter.value)}·${hfSig(gapHeader.value)}`, + hfKey: `${pageNoTextOf(k)}·${pageNoTextOf(k + 1)}·${visiblePages}·${hfSig(gapFooter.value)}·${hfSig(gapHeader.value)}`, } : {} // previous page's footnotes: rendered into the top of the gap (page-bottom area), with the gap enlarged by the reserved height. @@ -1802,19 +1951,63 @@ export function App() { ) const elTop = b.el.getBoundingClientRect().top let matched = false + let cutRow: Element | null = null for (const tr of Array.from(b.el.querySelectorAll('tr')).filter( - (r) => !r.closest('.doc-nested-table'), + (r) => + !r.closest('.doc-nested-table') && !r.classList.contains('page-repeat-header'), )) { const trTop = tr.getBoundingClientRect().top const gapsAbove = gapRects.reduce((s, g) => (g.top <= trTop ? s + g.height : s), 0) const off = (trTop - elTop - gapsAbove) / factor + // last real row starting at/above the cut = the row the cut falls inside + if (!tr.classList.contains('page-gap') && off <= slice.start - b.top + 0.5) + cutRow = tr if (Math.abs(off - (slice.start - b.top)) < 1.5) { matched = true try { const $pos = editor.view.state.doc.resolve(editor.view.posAtDOM(tr, 0)) + // w:tblHeader repetition: the engine reserved slice.repeatHeader.height + // at the top of this page's column, so cloning the source header rows + // below the gap fills exactly that space. Clones are decorations — + // page-gap-inline keeps them out of the virtual coordinates. + let repeatHeaderEls: HTMLElement[] | undefined + if (slice.repeatHeader) { + const tableEl = tr.closest('table') + const srcRows = tableEl + ? (Array.from(tableEl.querySelectorAll(':scope > tbody > tr')).filter( + (r) => + !r.classList.contains('page-gap') && + !r.classList.contains('page-repeat-header'), + ) as HTMLElement[]) + : [] + const els: HTMLElement[] = [] + let acc = 0 + for (const row of srcRows) { + if (acc >= slice.repeatHeader.height - 1.5) break + const clone = row.cloneNode(true) as HTMLElement + clone.classList.add('page-gap-inline', 'page-repeat-header') + clone.setAttribute('contenteditable', 'false') + els.push(clone) + acc += row.getBoundingClientRect().height / factor + } + if (els.length > 0) repeatHeaderEls = els + } for (let d = $pos.depth; d > 0; d--) { if ($pos.node(d).type.name === 'docTableRow') { - gaps.push({ pos: $pos.before(d), kind: 'table', metrics }) + gaps.push({ + pos: $pos.before(d), + kind: 'table', + metrics, + ...hfProps, + ...(repeatHeaderEls + ? { + repeatHeaderEls, + // content signature: header edits with unchanged height + // must still rebuild the widgets (same rule as hfKey) + repeatHeaderKey: `${repeatHeaderEls.length}-${Math.round(slice.repeatHeader!.height)}-${hashStr(repeatHeaderEls.map((e) => e.innerHTML).join('§'))}`, + } + : {}), + }) break } } @@ -1825,10 +2018,31 @@ export function App() { } } if (!matched) { - // inline cut point (page break mid-line): the cut falls in a line's band gap; locate the first line after it and insert a zero-height dashed marker + // in-row cut point (page break between a cell's lines): anchor at the first line after it const anchor = nextLineAnchor(b.el, slice.start - b.top, factor) const pos = anchor ? posFromAnchor(editor.view, anchor) : undefined - if (pos != null) gaps.push({ pos, kind: 'cut', metrics }) + if (anchor == null || pos == null) return + const cutCell = singleCutCell(cutRow) + if (cutCell) { + // single-column row: insert a real inline gap band; bleed to the paper + // edges from the anchor's block (the widget's containing block) + const r = ( + anchor.node.parentElement?.closest('td > *, th > *') ?? cutCell + ).getBoundingClientRect() + gaps.push({ + pos, + kind: 'cell', + metrics: { + ...metrics, + marginLeft: metrics.marginLeft + (r.left - pmRect.left) / factor, + marginRight: metrics.marginRight + (pmRect.right - r.right) / factor, + }, + ...hfProps, + }) + } else { + // multi-cell row: keep the zero-height dashed marker (no hfProps — it can't host header/footer strips) + gaps.push({ pos, kind: 'cut', metrics }) + } } return } @@ -1849,8 +2063,36 @@ export function App() { }) markShown() }) + // TOC page numbers: the file's cached PAGEREF results are stale (generators + // write them against a layout that never matches; Word silently refreshes on + // open, we never write back). Backfill the display from the live layout — + // DOM-only, inside contenteditable=false subtrees the save path never reads. + const tocLines = pm.querySelectorAll('.doc-toc-line[data-toc-anchor]') + if (tocLines.length > 0) { + const anchorIdx = new Map() + for (const b of parsed.blocks) { + if (b.docxIndex == null) continue + for (const a of b.hiddenBookmarks ?? []) anchorIdx.set(a, b.docxIndex) + } + const topByIdx = new Map() + for (const b of blocks) { + if (b.docxIndex != null) topByIdx.set(b.docxIndex, b.top) + } + for (const el of tocLines) { + const idx = anchorIdx.get(el.getAttribute('data-toc-anchor') ?? '') + const top = idx === undefined ? undefined : topByIdx.get(idx) + if (top === undefined) continue + const pageEl = el.querySelector('.doc-toc-page') + // pageAt is 1-based; pageNoTextOf indexes nums/slices 0-based + const pageIdx = Math.max(0, Math.min(pageAt(slices, top + 1) - 1, slices.length - 1)) + if (pageEl && slices.length > 0) pageEl.textContent = pageNoTextOf(pageIdx) + } + } } + tGapsBuild = performance.now() - tGaps0 + const tSet0 = performance.now() setPageGaps(editor.view, gaps) + tSetGaps = performance.now() - tSet0 // the last page paints as a full sheet like the ones above it: // extend the canvas to that page's paper bottom, measured from the last gap const gapEls = pm.querySelectorAll('.page-gap') @@ -1870,7 +2112,19 @@ export function App() { ;(window as unknown as Record).__pageDebug = { slices, blocks: blocks.map((b) => ({ top: b.top, height: b.height, docxIndex: b.docxIndex })), + remeasureMs: performance.now() - tStart, + measureMs: tMeasure, + sliceMs: tSlice, + gapsBuildMs: tGapsBuild, + setGapsMs: tSetGaps, } + requestAnimationFrame(() => { + const tf = performance.now() + requestAnimationFrame(() => { + const dbg = (window as unknown as Record>).__pageDebug + if (dbg) dbg.frameMs = performance.now() - tf + }) + }) setGapNoteIds((prev) => { if (prev.size === gapIds.size && [...gapIds].every((id) => prev.has(id))) return prev return gapIds @@ -2516,9 +2770,17 @@ export function App() { {doc && liveDocCjk != null && ( )} + {doc && gridPitchPt != null && ( + // typed w:docGrid: line-height round(up) expressions snap to this pitch + + )} + {doc && section && ( + // over-wide tables may spill into the right margin (Word/LO), capped at the paper edge + + )} {/* Theme CSS comes from live state, so a Design ▸ Themes/Fonts/Colors pick shows on the page immediately instead of only in the saved file */} - {doc && } + {doc && } {colFlow && viewMode === 'print' && ( // columns (sectPr w:cols): column gap follows the document's w:space; measuring-columns // is the single-flow measuring state (columns removed, content-box width = column width, @@ -2650,7 +2912,7 @@ export function App() { {titlePg && ( )} + {entry.snapshot && ( + <> + {/* hairline between reply actions (icons) and the document action (icon+label); + CSS shows it only when an icon button actually precedes it */} + + rollback(i, entry.snapshot!)} + /> + + )} )} {entry.turnLimit && isLast && !busy && ( @@ -1055,26 +1159,6 @@ export function AiPanel({ })} - {snapshots.length > 0 && ( -
    -
    - - {t('aiSnapshotsTitle')} -
    - {snapshots.map((s, i) => ( -
    - - {s.time} - {s.label} - - -
    - ))} -
    - )} -
    {attachNotice &&
    {attachNotice}
    } void }) { + const { t: tr } = useI18n() + return ( + + ) +} + /** Tool activity group: a single quiet summary row * that auto-opens while tools run, auto-collapses into "Worked · N steps" when they finish, * and a manual toggle that always wins. Rows inside are step rows with 1px connectors. */ diff --git a/apps/docs/src/renderer/ai/commands.ts b/apps/docs/src/renderer/ai/commands.ts index f6a2531..e715d52 100644 --- a/apps/docs/src/renderer/ai/commands.ts +++ b/apps/docs/src/renderer/ai/commands.ts @@ -598,6 +598,10 @@ function runSetHeadingLevel( } function runReplaceAllText(tr: Transaction, schema: Schema, cmd: ReplaceAllText): CommandResult { + // an empty needle would match at every offset without ever advancing the scan below + if (!cmd.containsText) { + return { command: 'replaceAllText', matched: 0, changed: 0, skippedProtected: 0 } + } const matchCase = cmd.matchCase !== false const needle = matchCase ? cmd.containsText : cmd.containsText.toLowerCase() const blocks = topLevelBlocks(tr.doc) diff --git a/apps/docs/src/renderer/components/ContextMenu.tsx b/apps/docs/src/renderer/components/ContextMenu.tsx index ca48d7a..04d59ad 100644 --- a/apps/docs/src/renderer/components/ContextMenu.tsx +++ b/apps/docs/src/renderer/components/ContextMenu.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react' import type { Editor } from '@tiptap/core' +import { platformShortcuts } from '@hermesoffice/i18n' import { useI18n, type StringKey } from '../i18n/locale' import { fontFamiliesFor, isEastAsianFontName } from '../font-list' import { cssFontFamily } from '../line-metrics' @@ -187,7 +188,7 @@ export function EditorContextMenu({ )} - {opts.key && {opts.key}} + {opts.key && {platformShortcuts(opts.key)}} {opts.submenuKey && } ) diff --git a/apps/docs/src/renderer/components/HeaderFooterArea.tsx b/apps/docs/src/renderer/components/HeaderFooterArea.tsx index c8fb204..9fe6e8b 100644 --- a/apps/docs/src/renderer/components/HeaderFooterArea.tsx +++ b/apps/docs/src/renderer/components/HeaderFooterArea.tsx @@ -1,6 +1,13 @@ import { useEffect, useRef, useState } from 'react' -import { TOTAL_PAGES_MARK, type HfImage, type HfParagraph, type Run } from '@hermesoffice/docx-engine' +import { + PAGE_MARK, + TOTAL_PAGES_MARK, + type HfImage, + type HfParagraph, + type Run, +} from '@hermesoffice/docx-engine' import { useI18n } from '../i18n/locale' +import { hfUsesLegacyHash } from '../editor/hf-dom' import { cssDualFontFamily, cssFontFamily } from '../line-metrics' export interface HfValue { @@ -9,6 +16,10 @@ export interface HfValue { paras?: HfParagraph[] } +/** visible edit-surface stand-ins for the invisible private-use field sentinels */ +const PAGE_TOKEN = '{PAGE}' +const TOTAL_TOKEN = '{NUMPAGES}' + function runStyle(run: Run): React.CSSProperties { const style: React.CSSProperties = {} if (run.bold) style.fontWeight = 600 @@ -26,8 +37,8 @@ function runStyle(run: Run): React.CSSProperties { function parasOf(value: HfValue): HfParagraph[] { if (value.paras?.length) return value.paras const runs: Run[] = value.text ? [{ text: value.text }] : [] - if (value.pageNumber && !value.text.includes('#')) { - runs.push({ text: runs.length > 0 ? ' #' : '#' }) + if (value.pageNumber && !value.text.includes('#') && !value.text.includes(PAGE_MARK)) { + runs.push({ text: runs.length > 0 ? ` ${PAGE_MARK}` : PAGE_MARK }) } return [{ align: 'center', runs }] } @@ -35,8 +46,8 @@ function parasOf(value: HfValue): HfParagraph[] { /** * Header / footer zone on the page: renders the rich paragraphs, * double-click enters in-place editing (plain text per paragraph; each line - * keeps its paragraph format and first-run styling), blur commits. The '#' - * marker stands for the automatic page number. + * keeps its paragraph format and first-run styling), blur commits. PAGE / + * NUMPAGES sentinels edit as visible {PAGE} / {NUMPAGES} tokens. */ export function HeaderFooterArea({ kind, @@ -72,7 +83,13 @@ export function HeaderFooterArea({ if (!editing) return const el = editRef.current if (!el) return - el.innerText = paras.map((p) => p.runs.map((r) => r.text).join('')).join('\n') + // table-row (cells) paragraphs stay out of the text editing flow + el.innerText = paras + .filter((p) => !p.cells) + .map((p) => p.runs.map((r) => r.text).join('')) + .join('\n') + .replaceAll(PAGE_MARK, PAGE_TOKEN) + .replaceAll(TOTAL_PAGES_MARK, TOTAL_TOKEN) cancelRef.current = false initialTextRef.current = el.innerText el.focus() @@ -93,19 +110,36 @@ export function HeaderFooterArea({ return } if (el.innerText === initialTextRef.current) return - const lines = el.innerText.replace(/\n+$/, '').split('\n') - const nextParas: HfParagraph[] = lines.map((line, i) => { - const template = paras[Math.min(i, paras.length - 1)] + const lines = el.innerText + .replace(/\n+$/, '') + .replaceAll(PAGE_TOKEN, PAGE_MARK) + .replaceAll(TOTAL_TOKEN, TOTAL_PAGES_MARK) + .split('\n') + const textParas = paras.filter((p) => !p.cells) + const templates: HfParagraph[] = + textParas.length > 0 ? textParas : [{ align: 'center', runs: [] }] + const edited: HfParagraph[] = lines.map((line, i) => { + const template = templates[Math.min(i, templates.length - 1)] const style = template.runs[0] ?? {} return { ...template, runs: line === '' ? [] : [{ ...style, text: line }] } }) - const text = nextParas.map((p) => p.runs.map((r) => r.text).join('')).join('') + // splice cells rows back at their original positions among the text paragraphs + const nextParas: HfParagraph[] = [] + let ei = 0 + for (const p of paras) { + if (p.cells) nextParas.push(p) + else if (ei < edited.length) nextParas.push(edited[ei++]) + } + nextParas.push(...edited.slice(ei)) + const text = edited.map((p) => p.runs.map((r) => r.text).join('')).join('') onCommit({ ...value, text, paras: nextParas }) } const display = (text: string) => { - const t = text.replaceAll(TOTAL_PAGES_MARK, String(pageTotal ?? 1)) - return value.pageNumber ? t.replace('#', String(pageNo ?? 1)) : t + const t = text + .replaceAll(TOTAL_PAGES_MARK, String(pageTotal ?? 1)) + .replaceAll(PAGE_MARK, String(pageNo ?? 1)) + return hfUsesLegacyHash(value) ? t.replace('#', String(pageNo ?? 1)) : t } return ( @@ -115,7 +149,11 @@ export function HeaderFooterArea({ readOnly ? undefined : t(kind === 'header' ? 'appDblclickEditHeader' : 'appDblclickEditFooter') + - (value.pageNumber ? t('appHfPageNumHint') : '') + (value.pageNumber + ? hfUsesLegacyHash(value) + ? t('appHfPageNumHint') + : t('appHfPageNumHint').replace('#', PAGE_TOKEN) + : '') } onDoubleClick={() => { if (!readOnly && !editing) setEditing(true) @@ -169,30 +207,59 @@ function HfContent({ }) { return ( <> - {paras.map((para, i) => ( -
    - {para.runs.length === 0 ? ' ' : null} - {para.runs.map((run, j) => ( - - {display(run.text)} - - ))} -
    - ))} + {paras.map((para, i) => + para.cells ? ( + // layout-table row: read-only flex columns (excluded from text editing) +
    + {para.cells.map((cell, j) => ( +
    + {cell.runs.map((run, k) => ( + + {display(run.text)} + + ))} +
    + ))} +
    + ) : ( +
    + {para.runs.length === 0 ? ' ' : null} + {para.runs.map((run, j) => ( + + {display(run.text)} + + ))} +
    + ), + )} ) } diff --git a/apps/docs/src/renderer/components/PaginationPreview.tsx b/apps/docs/src/renderer/components/PaginationPreview.tsx index fc79d4f..de97a2a 100644 --- a/apps/docs/src/renderer/components/PaginationPreview.tsx +++ b/apps/docs/src/renderer/components/PaginationPreview.tsx @@ -34,6 +34,80 @@ import { HeaderFooterArea } from './HeaderFooterArea' const twipsToPx = (twips: number) => (twips / 1440) * 96 +/** Snapshot of one top-level canvas block for pruned per-page clones (virtual gapless coordinates, layout px) */ +export interface CloneChild { + html: string + vTop: number + vBottom: number + /** CSS margins (layout px): spacer heights must exclude them to keep flow positions exact */ + mt: number + mb: number + /** zero-height marker (hidden bookmarks etc.): always kept, never worth pruning */ + zero: boolean +} + +/** + * Per-page full-document clones cost pages × doc DOM; past this budget (top-level + * blocks × pages) a 300+-page document OOMs the renderer during preview/export + * ("Promise was collected"), so pages switch to pruned clones: blocks outside the + * page window collapse into fixed-height spacers. + */ +const CLONE_PRUNE_BUDGET = 150_000 +/** window slack around a page (px): keeps neighbours whose floats/overflow bleed into the page */ +const CLONE_PRUNE_PAD = 2000 + +/** + * Canvas block → clone HTML. Phantom table rows (page-gap / repeated-header + * widgets) are removed and rowspans restored to their source values + * (data-base-rowspan): the canvas grows rowspans to bridge the phantom rows, + * but the clone hides/drops them, so the grown spans would swallow real rows. + */ +function cloneBlockHtml(el: HTMLElement): string { + if (!el.querySelector('tr.page-gap, tr.page-repeat-header, [data-base-rowspan]')) { + return el.outerHTML + } + const tmp = el.cloneNode(true) as HTMLElement + for (const tr of Array.from(tmp.querySelectorAll('tr.page-gap, tr.page-repeat-header'))) { + tr.remove() + } + for (const td of Array.from(tmp.querySelectorAll('[data-base-rowspan]'))) { + td.setAttribute('rowspan', td.getAttribute('data-base-rowspan')!) + } + return tmp.outerHTML +} + +/** pruned clone for one page window: blocks intersecting [from-pad, to+pad] verbatim, pruned runs as spacers */ +export function prunedCloneHtml(kids: CloneChild[], from: number, to: number): string { + const lo = from - CLONE_PRUNE_PAD + const hi = to + CLONE_PRUNE_PAD + const parts: string[] = [] + let lastKept: CloneChild | null = null + let pruned = false + for (const c of kids) { + if (!c.zero && (c.vBottom <= lo || c.vTop >= hi)) { + pruned = true + continue + } + if (pruned) { + // spacer replaces the pruned run; its height re-derives the next block's + // border-box top from the previous kept block's margin edge (spacers + // suppress margin collapse, so both adjacent margins apply in full) + const base = lastKept ? lastKept.vBottom + lastKept.mb : 0 + const h = Math.max(0, c.vTop - c.mt - base) + parts.push( + `
    `, + ) + pruned = false + } + parts.push(c.html) + // zero-height markers anchor positions too: skipping them here made every + // following marker's spacer re-span the full distance from the last real + // block, inflating the clone flow (blank pages past the drift) + lastKept = c + } + return parts.join('') +} + export interface HfSet { header: HeaderFooter | null footer: HeaderFooter | null @@ -73,6 +147,7 @@ export function PaginationPreview({ pageFootnotesOf, endnoteItems, sectionHfOverride, + clearPageGaps, onExportPdf, onExportMarkdown, onClose, @@ -97,6 +172,14 @@ export function PaginationPreview({ endnoteItems?: PageNoteItem[] /** Multi-section: unsaved per-section header/footer edit overrides (default variant) */ sectionHfOverride?: (sectionIndex: number, kind: 'header' | 'footer') => HeaderFooter | null + /** + * Clears the canvas page-gap decorations before the snapshot measure. In-table + * gap/repeated-header widgets are extra s that consume rowspan slots, so a + * vMerge-heavy table measures with collapsed columns (exploding row heights) + * while they are present; the canvas rebuilds them on its next debounced + * remeasure after the snapshot. + */ + clearPageGaps?: () => void onExportPdf: () => void /** fork: convert the open document to Markdown (anydoc, local) */ onExportMarkdown: () => void @@ -108,6 +191,8 @@ export function PaginationPreview({ /** Top Y of the endnote area (virtual coordinates); null = no endnotes */ const [endnotesTop, setEndnotesTop] = useState(null) const [html, setHtml] = useState('') + /** non-null = pruned-clone mode (large documents): per-page windows instead of full clones */ + const [cloneKids, setCloneKids] = useState(null) /** Live section list: a section whose break block was deleted (unsaved) merges into the next, matching the canvas */ const [secs, setSecs] = useState(sections) @@ -126,6 +211,7 @@ export function PaginationPreview({ useEffect(() => { const pm = document.querySelector('.editor-scroll .ProseMirror') as HTMLElement | null if (!pm) return + clearPageGaps?.() const factor = zoom / 100 // switch the columned canvas to the single-flow measuring state (CSS columns off, width = column width), matching engine column-flow coordinates if (colFlow) pm.classList.add('measuring-columns') @@ -196,7 +282,41 @@ export function PaginationPreview({ } setSlices(computed) setPageNotes(pageFootnotesOf ? pageFootnotesOf(blocks, computed) : []) - setHtml(pm.innerHTML) + // Per-page full clones explode on large documents (pages × doc DOM → + // renderer OOM / "Promise was collected" during printToPDF). Past the + // budget, snapshot per-block geometry and render pruned windows instead. + const kidEls = Array.from(pm.children) as HTMLElement[] + if (computed.length * kidEls.length >= CLONE_PRUNE_BUDGET) { + const metas: CloneChild[] = [] + let gapAccum = 0 + for (const el of kidEls) { + const rect = el.getBoundingClientRect() + if (el.classList.contains('page-gap')) { + gapAccum += rect.height + continue + } + let innerGap = 0 + for (const g of el.querySelectorAll('.page-gap-inline')) + innerGap += g.getBoundingClientRect().height + const cs = window.getComputedStyle(el) + const vTop = (rect.top - origin - gapAccum) / factor + const h = (rect.height - innerGap) / factor + gapAccum += innerGap + metas.push({ + html: cloneBlockHtml(el), + vTop, + vBottom: vTop + h, + mt: parseFloat(cs.marginTop) || 0, + mb: parseFloat(cs.marginBottom) || 0, + zero: rect.height <= 0, + }) + } + setCloneKids(metas) + setHtml('') + } else { + setCloneKids(null) + setHtml(Array.from(pm.children, (c) => cloneBlockHtml(c as HTMLElement)).join('')) + } } finally { if (colFlow) pm.classList.remove('measuring-columns') } @@ -349,6 +469,7 @@ export function PaginationPreview({ '--header-dist': `${pageBox.headerDist}px`, '--footer-dist': `${pageBox.footerDist}px`, '--pv-mr': `${twipsToPx(s.marginRight)}px`, + '--pv-ml': `${twipsToPx(s.marginLeft)}px`, padding: `${mTop}px ${twipsToPx(s.marginRight)}px ${mBottom}px ${twipsToPx(s.marginLeft)}px`, background: pageColor ? `#${pageColor}` : undefined, } as React.CSSProperties @@ -359,11 +480,39 @@ export function PaginationPreview({ {watermark}
    )} + {(parts.headerImages ?? []) + .filter((img) => img.floating) + .map((img, k) => ( + // picture watermark (absolute VML shape in the header): drawn once + // per page behind the body (negative z-index; .pv-page isolates) + + ))} {parts.header && ( !img.floating)} readOnly onCommit={() => {}} pageNo={pageNoText} @@ -380,7 +529,15 @@ export function PaginationPreview({ >
    @@ -417,7 +574,15 @@ export function PaginationPreview({ >
    @@ -437,7 +602,11 @@ export function PaginationPreview({ >
    @@ -469,7 +638,16 @@ export function PaginationPreview({
    @@ -574,7 +752,7 @@ export function PaginationPreview({ !img.floating)} readOnly onCommit={() => {}} pageNo={pageNoText} diff --git a/apps/docs/src/renderer/components/PictureDialogs.tsx b/apps/docs/src/renderer/components/PictureDialogs.tsx index 20d783a..cad55c2 100644 --- a/apps/docs/src/renderer/components/PictureDialogs.tsx +++ b/apps/docs/src/renderer/components/PictureDialogs.tsx @@ -185,7 +185,12 @@ export function CutoutDialog({ dataUrl, onApply, onCancel }: CutoutProps) { > {error ? ( {t(error)} @@ -199,7 +204,9 @@ export function CutoutDialog({ dataUrl, onApply, onCancel }: CutoutProps) { }} /> )} - {!loaded && !error && {t('ribbonLoading')}} + {!loaded && !error && ( + {t('ribbonLoading')} + )} -
    +
    {t('ribbonCutoutHint', { pct: removedPct })}
    @@ -458,7 +465,12 @@ export function CropDialog({ dataUrl, onApply, onCancel }: CropProps) { > {error ? ( {t(error)} @@ -536,10 +548,12 @@ export function CropDialog({ dataUrl, onApply, onCancel }: CropProps) {
    ) : ( - {t('ribbonLoading')} + {t('ribbonLoading')} )} -
    {t('ribbonCropHint')}
    +
    + {t('ribbonCropHint')} +
    +
    {t('ribbonThemeColorsSection')}
    +
    + {THEME_COLORS.map((c) => ( +
    +
    + {THEME_COLOR_SHADES.flatMap((row, rowIndex) => + row.map((hex, columnIndex) => ( +
    +
    {t('ribbonStandardColors')}
    +
    + {COLORS.map((c) => ( +
    + +
    + ) +} + /** Word text highlight colors (OOXML named values) */ const HIGHLIGHTS = [ 'yellow', @@ -591,6 +675,15 @@ function RibbonInner({ const [penHighlight, setPenHighlight] = useState('yellow') const [painter, setPainter] = useState(null) const ribbonRef = useRef(null) + const fontStepRef = useRef<{ + pending: number | null + applied: number | null + timer: number | null + // editor snapshot the deferred apply validates against (stale-apply guard) + anchor: number + head: number + doc: PMNode | null + }>({ pending: null, applied: null, timer: null, anchor: -1, head: -1, doc: null }) const lastRegularTab = useRef<(typeof TABS)[number]>('home') const wasInTable = useRef(false) const wasInImage = useRef(false) @@ -664,6 +757,45 @@ function RibbonInner({ } }, [inImage]) + // ---- Shape Format (contextual tab when a floating box is selected, same mechanism) ---- + const inShape = !sub && fs.textboxSelected + const shapeIsLine = !!fs.shapePrst?.startsWith('line') + const wasInShape = useRef(false) + + useEffect(() => { + if (inShape && !wasInShape.current) { + wasInShape.current = true + setDropdown(null) + setTab('shapeFormat') + } else if (!inShape && wasInShape.current) { + wasInShape.current = false + setDropdown(null) + setTab((current) => (current === 'shapeFormat' ? lastRegularTab.current : current)) + } + }, [inShape]) + + /** apply fill/outline to the selected floating box (first box of the node) */ + const setShapeStyle = (patch: { fill?: string | null; borderColor?: string | null }) => { + if (!canEdit) return + const attrs = editor.getAttributes('docProtected') + const boxes = attrs?.textboxes as TextboxDisplay[] | null + if (!Array.isArray(boxes) || boxes.length === 0) return + const box = { ...boxes[0] } + if ('fill' in patch) { + if (patch.fill) box.fill = patch.fill + else delete box.fill + } + if ('borderColor' in patch) { + if (patch.borderColor) box.borderColor = patch.borderColor + else delete box.borderColor + } + editor + .chain() + .focus() + .updateAttributes('docProtected', { textboxes: [box, ...boxes.slice(1)] }) + .run() + } + /** * Replace the selected image's bytes (shared by Replace Picture / remove background / crop). * The original image's patch-save only supports size/alignment/wrap; swapping bytes must go @@ -1099,18 +1231,59 @@ function RibbonInner({ } const stepFontSize = (dir: 1 | -1) => { - const idx = FONT_SIZES.findIndex((s) => s >= currentSize) - let next: number - if (dir === 1) - next = - FONT_SIZES[ + const step = (base: number): number => { + const idx = FONT_SIZES.findIndex((s) => s >= base) + if (dir === 1) + return FONT_SIZES[ Math.min( - idx === -1 ? FONT_SIZES.length : idx + (FONT_SIZES[idx] === currentSize ? 1 : 0), + idx === -1 ? FONT_SIZES.length : idx + (FONT_SIZES[idx] === base ? 1 : 0), FONT_SIZES.length - 1, ) ] - else next = FONT_SIZES[Math.max(idx === -1 ? FONT_SIZES.length - 1 : idx - 1, 0)] - setTextStyle({ sizeHalfPoints: Math.round(next * 2) }) + return FONT_SIZES[Math.max(idx === -1 ? FONT_SIZES.length - 1 : idx - 1, 0)] + } + // Every applied size change re-paginates the whole document synchronously — + // ~700ms per click on table-heavy documents — so clicking A+/A- in a burst + // froze the UI for seconds. Apply the first click immediately (a single + // click keeps instant feedback); clicks landing inside the coalesce window + // only advance the pending size, and one trailing apply lays out the final + // size. `pending` also covers fs.fontSizePt lagging the last apply within + // the window. + const st = fontStepRef.current + const next = step(st.pending ?? currentSize) + st.pending = next + if (st.timer === null) { + st.applied = next + setTextStyle({ sizeHalfPoints: Math.round(next * 2) }) + } else { + window.clearTimeout(st.timer) + } + // Snapshot after the (possible) leading apply: the deferred apply is only + // valid while nothing else has touched the editor. A selection move, an + // undo, or a size set another way each shows up as a selection or document + // change and must invalidate the pending step instead of being overwritten. + const target = ed + st.anchor = target.state.selection.anchor + st.head = target.state.selection.head + st.doc = target.state.doc + st.timer = window.setTimeout(() => { + st.timer = null + const pending = st.pending + st.pending = null + if (pending === null || pending === st.applied || !canEdit) return + if ( + target.state.selection.anchor !== st.anchor || + target.state.selection.head !== st.head || + target.state.doc !== st.doc + ) + return + st.applied = pending + // deliberately no focus(): a deferred apply must never pull focus back + target + .chain() + .setMark('docTextStyle', { sizeHalfPoints: Math.round(pending * 2) }) + .run() + }, FONT_STEP_COALESCE_MS) } const toggleVertAlign = (kind: 'superscript' | 'subscript') => { @@ -1322,12 +1495,95 @@ function RibbonInner({ {t(TAB_LABEL_KEYS[imageTab])} ))} + {inShape && + SHAPE_TABS.map((shapeTab) => ( + + ))} {trailingActions}
    - {tab === 'pictureFormat' && inImage ? ( + {tab === 'shapeFormat' && inShape ? ( +
    +
    +
    + {!shapeIsLine && ( +
    + + {dropdown === 'shapeFill' && ( + { + setShapeStyle({ fill: hex }) + setDropdown(null) + }} + /> + )} +
    + )} +
    + + {dropdown === 'shapeOutline' && ( + { + setShapeStyle({ borderColor: hex }) + setDropdown(null) + }} + /> + )} +
    +
    +
    {t('ribbonGroupShapeStyles')}
    +
    +
    + ) : tab === 'pictureFormat' && inImage ? (
    {/* ---- Adjust: remove background / crop / replace picture ---- */}
    diff --git a/apps/docs/src/renderer/components/ribbon-format-state.ts b/apps/docs/src/renderer/components/ribbon-format-state.ts index cddae6f..9930cb0 100644 --- a/apps/docs/src/renderer/components/ribbon-format-state.ts +++ b/apps/docs/src/renderer/components/ribbon-format-state.ts @@ -31,6 +31,9 @@ export interface RibbonFormatState { imageHeightPx: number | null imageHasDocxIndex: boolean textboxSelected: boolean + shapeFill: string | null + shapeBorderColor: string | null + shapePrst: string | null cellKey: number | null cellHeightCm: number | null cellWidthCm: number | null @@ -76,6 +79,9 @@ export const EMPTY_FORMAT_STATE: RibbonFormatState = { imageHeightPx: null, imageHasDocxIndex: false, textboxSelected: false, + shapeFill: null, + shapeBorderColor: null, + shapePrst: null, cellKey: null, cellHeightCm: null, cellWidthCm: null, @@ -189,6 +195,15 @@ export function computeFormatState( imageHeightPx: num(protAttrs.imageHeightPx), imageHasDocxIndex: protAttrs.docxIndex != null, textboxSelected: Array.isArray(protAttrs.textboxes) && protAttrs.textboxes.length > 0, + shapeFill: Array.isArray(protAttrs.textboxes) + ? str((protAttrs.textboxes[0] as { fill?: string } | undefined)?.fill) + : null, + shapeBorderColor: Array.isArray(protAttrs.textboxes) + ? str((protAttrs.textboxes[0] as { borderColor?: string } | undefined)?.borderColor) + : null, + shapePrst: Array.isArray(protAttrs.textboxes) + ? str((protAttrs.textboxes[0] as { prst?: string } | undefined)?.prst) + : null, cellKey, cellHeightCm, cellWidthCm, diff --git a/apps/docs/src/renderer/components/ribbon-insert-tab.tsx b/apps/docs/src/renderer/components/ribbon-insert-tab.tsx index 85ec3ae..a7b79e6 100644 --- a/apps/docs/src/renderer/components/ribbon-insert-tab.tsx +++ b/apps/docs/src/renderer/components/ribbon-insert-tab.tsx @@ -3,6 +3,7 @@ import type { CSSProperties } from 'react' import type { Editor } from '@tiptap/core' import { ShapePreview, WORDART_PRESETS, wordArtStrokePx } from '@hermesoffice/ui' import type { ChartDisplay, HeaderFooter, NewChart } from '@hermesoffice/docx-engine' +import { hfHasPageField, hfWithoutPageMarks } from '../editor/hf-dom' import { EquationGallery, EquationModal } from './EquationModal' import { COVER_PRESETS, insertCoverPage, type CoverPreset } from '../editor/cover-pages' import { startShapeDrawMode } from '../editor/shape-draw' @@ -1066,12 +1067,8 @@ export function InsertTab({ + )} + {/* mounted only after the file is loaded so chat history resolves against the real path */} + {status === 'ready' && ( + setAiOpen(false)} + /> + )} +
    +
    +
    +
    + {fmOpen && } + +
    +
    +
    +
    + {fileName && {fileName}} +
    +
    + {statusText && ( + {statusText} + )} +
    +
    +
    +
    + setSlashState(null)} /> + +
    + ) +} diff --git a/apps/markdown/src/renderer/ai/AiPanel.tsx b/apps/markdown/src/renderer/ai/AiPanel.tsx new file mode 100644 index 0000000..69a8ef0 --- /dev/null +++ b/apps/markdown/src/renderer/ai/AiPanel.tsx @@ -0,0 +1,846 @@ +import { useEffect, useRef, useState } from 'react' +import type { PointerEvent as ReactPointerEvent, ReactElement, ReactNode } from 'react' +import { AgentLoop, composeSkills } from '@hermesoffice/agent-core' +import type { AiSettings } from '@hermesoffice/ai-provider' +import { AiComposer, AiTypingIndicator, Markdown } from '@hermesoffice/ui' +import type { Editor } from '@tiptap/core' +import { aiLangDirective, t as tGlobal, useI18n } from '../i18n/locale' +import sendEnterOn from '../assets/send-enter-on.png' +import sendEnterOff from '../assets/send-enter-off.png' +import sendStop from '../assets/send-stop.png' +import { clearAiHighlights } from '../editor/aiHighlight' +import { createMarkdownSkill } from './markdown-skill' +import { createSearchSkill } from './search-skill' +import { createElectronTransport } from './transport' + +const PANEL_WIDTH_KEY = 'markdown-ai-panel-width' +const PANEL_WIDTH_DEFAULT = 360 +const PANEL_WIDTH_MIN = 280 +const MAX_TURNS = 50 +const MAX_SNAPSHOTS = 20 +const TOOL_OUTPUT_MAX_CHARS = 2000 + +function clampPanelWidth(w: number): number { + return Math.min(Math.max(w, PANEL_WIDTH_MIN), Math.min(720, Math.round(window.innerWidth * 0.6))) +} + +function loadPanelWidth(): number { + const saved = Number(localStorage.getItem(PANEL_WIDTH_KEY)) + return Number.isFinite(saved) && saved > 0 ? clampPanelWidth(saved) : PANEL_WIDTH_DEFAULT +} + +interface ToolActivity { + name: string + summary: string + /** still executing: rendered as a spinner chip, replaced in place when the tool finishes */ + running?: boolean + isError?: boolean + output?: string +} + +interface ChatEntry { + role: 'user' | 'assistant' + text: string + streaming?: boolean + isError?: boolean + /** the run failed and this user message was rolled back out of the model context */ + undelivered?: boolean + tools?: ToolActivity[] +} + +interface Snapshot { + label: string + time: string + markdown: string +} + +/** Ribbon preset instruction; a new nonce triggers one auto-send */ +export interface AiPreset { + text: string + nonce: number +} + +export interface MarkdownAiDeps { + getEditor(): Editor | null + /** full document body as markdown, for pre-mutation snapshots */ + getSnapshot(): string + /** rollback: replace the document with a snapshot */ + restoreSnapshot(markdown: string): void + /** fired when a run with at least one mutation finishes (auto-save hook) */ + onRunDone(mutated: boolean): void +} + +export function AiPanel({ + deps, + filePath, + preset, + onCollapse, +}: { + deps: MarkdownAiDeps + filePath: string | null + preset?: AiPreset | null + onCollapse: () => void +}): ReactElement { + const { lang, t } = useI18n() + const [chat, setChat] = useState([]) + const [prompt, setPrompt] = useState('') + const [busy, setBusy] = useState(false) + const [copiedIdx, setCopiedIdx] = useState(null) + const [snapshots, setSnapshots] = useState([]) + const chatRef = useRef(null) + const inputRef = useRef(null) + const stickToBottomRef = useRef(true) + const [panelWidth, setPanelWidth] = useState(loadPanelWidth) + const [resizing, setResizing] = useState(false) + const asideRef = useRef(null) + + useEffect(() => { + const dock = asideRef.current?.closest('.ai-dock') as HTMLElement | null + dock?.style.setProperty('--ai-panel-width', `${panelWidth}px`) + }, [panelWidth]) + + const settingsRef = useRef(null) + const langRef = useRef(lang) + langRef.current = lang + const depsRef = useRef(deps) + depsRef.current = deps + const filePathRef = useRef(filePath) + /** instruction of the in-flight run, labels its rollback snapshot */ + const runInstructionRef = useRef('') + const runMutatedRef = useRef(false) + /** tool activity of the whole run, for transcript persistence */ + const runToolsRef = useRef([]) + const chatIdsRef = useRef<{ projectId: string; chatId: string } | null>(null) + /** messages sent before resolveChat returned, flushed once the chat id is known */ + const pendingPersistRef = useRef< + Array<{ role: 'user' | 'assistant'; text: string; tools?: ToolActivity[] }> + >([]) + + const patchLast = (patch: Partial | ((last: ChatEntry) => Partial)) => { + setChat((prev) => { + const next = [...prev] + const last = next[next.length - 1] + if (!last || last.role !== 'assistant') return prev + next[next.length - 1] = { ...last, ...(typeof patch === 'function' ? patch(last) : patch) } + return next + }) + } + + const persistMessage = (role: 'user' | 'assistant', text: string, tools?: ToolActivity[]) => { + const ids = chatIdsRef.current + if (!window.projectApi) return + if (!ids) { + pendingPersistRef.current.push({ role, text, tools }) + return + } + void window.projectApi + .appendChat({ + projectId: ids.projectId, + chatId: ids.chatId, + role, + text, + ...(tools && tools.length > 0 ? { tools } : {}), + }) + .catch(() => { + /* persistence failures are silent */ + }) + } + + // The loop is built once; every mutable value goes through a ref getter + const loopRef = useRef | null>(null) + if (!loopRef.current) { + loopRef.current = new AgentLoop({ + transport: createElectronTransport(() => settingsRef.current!), + maxTurns: MAX_TURNS, + skill: composeSkills('markdown+search', '', [ + createMarkdownSkill(() => depsRef.current.getEditor()), + createSearchSkill(), + ]), + captureSnapshot: () => depsRef.current.getSnapshot(), + systemSuffix: () => aiLangDirective(langRef.current), + events: { + onText: (text) => patchLast({ text }), + onToolStart: (call) => { + // Live "running" chip: replaced in place by onToolExecuted + patchLast((last) => ({ + tools: [ + ...(last.tools ?? []), + { name: call.name, summary: call.name.replace(/[_-]+/g, ' '), running: true }, + ], + })) + }, + onToolExecuted: ({ call, execution, snapshotBefore }) => { + if (execution.mutated) runMutatedRef.current = true + if (snapshotBefore !== undefined) { + const label = runInstructionRef.current.slice(0, 40) + const time = new Date().toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }) + setSnapshots((prev) => + [...prev, { label, time, markdown: snapshotBefore }].slice(-MAX_SNAPSHOTS), + ) + } + const activity: ToolActivity = { + name: call.name, + summary: execution.summary, + isError: execution.isError, + output: execution.output?.slice(0, TOOL_OUTPUT_MAX_CHARS), + } + runToolsRef.current.push(activity) + patchLast((last) => { + // Swap out the running placeholder pushed by onToolStart (parse-fail calls have none) + const tools = [...(last.tools ?? [])] + if (tools.at(-1)?.running) tools.pop() + return { tools: [...tools, activity] } + }) + }, + onTurnEnd: () => { + patchLast({ streaming: false }) + setChat((prev) => [...prev, { role: 'assistant', text: '', streaming: true }]) + }, + onDone: ({ text, cancelled, turnLimit }) => { + const final = turnLimit + ? [text, tGlobal('aiTurnLimit')].filter(Boolean).join('\n\n') + : text || (cancelled ? tGlobal('aiStopped') : '') + patchLast((last) => ({ + streaming: false, + text: final || (last.tools?.length ? last.text : tGlobal('aiNoReply')), + // A stop mid-tool can leave a running placeholder behind — drop it + tools: last.tools?.filter((tl) => !tl.running), + })) + persistMessage('assistant', final, runToolsRef.current) + const editor = depsRef.current.getEditor() + if (editor) clearAiHighlights(editor) + depsRef.current.onRunDone(runMutatedRef.current) + setBusy(false) + }, + onError: (error) => { + setChat((prev) => { + const next = [...prev] + for (let i = next.length - 1; i >= 0; i--) { + const entry = next[i]! + if (entry.role === 'user') { + next[i] = { ...entry, undelivered: true } + break + } + } + const last = next.at(-1) + if (last?.role === 'assistant') { + next[next.length - 1] = { + ...last, + streaming: false, + text: error, + isError: true, + tools: last.tools?.filter((tl) => !tl.running), + } + } + return next + }) + setBusy(false) + }, + }, + }) + } + + // ── chat-history persistence: bind to the file, restore prior transcript ── + useEffect(() => { + const api = window.projectApi + if (!api) return + const tempChatId = `unsaved-${Date.now()}` + void api + .resolveChat({ filePath: filePathRef.current ?? null, tempChatId }) + .then((ids) => { + chatIdsRef.current = ids + for (const msg of pendingPersistRef.current.splice(0)) { + persistMessage(msg.role, msg.text, msg.tools) + } + return api.loadChat({ projectId: ids.projectId, chatId: ids.chatId, limit: 200 }) + }) + .then((msgs) => { + if (msgs.length === 0) return + // the user may have sent a message while history was loading — never + // replace a live transcript (and don't clobber the loop context) + let applied = false + setChat((prev) => { + if (prev.length > 0) return prev + applied = true + return msgs.map((m) => ({ + role: m.role, + text: m.text, + tools: m.tools?.map((tool) => ({ + name: tool.name, + summary: tool.summary, + isError: tool.isError, + output: tool.output ? tool.output.slice(0, TOOL_OUTPUT_MAX_CHARS) : undefined, + })), + })) + }) + if (applied && !loopRef.current?.busy) { + loopRef.current?.restore(msgs.map((m) => ({ role: m.role, text: m.text }))) + } + }) + .catch(() => { + /* history load failures are silent */ + }) + }, []) + + /** after an untitled document's first save, bind the unsaved-* history to the real path */ + useEffect(() => { + filePathRef.current = filePath + const ids = chatIdsRef.current + if (!window.projectApi || !ids || !filePath || !ids.chatId.startsWith('unsaved-')) return + void window.projectApi + .rebindChat({ projectId: ids.projectId, tempChatId: ids.chatId, newFilePath: filePath }) + .then((r) => { + if (r?.chatId) chatIdsRef.current = r + }) + .catch(() => { + /* silent */ + }) + }, [filePath]) + + useEffect(() => { + if (stickToBottomRef.current) { + chatRef.current?.scrollTo({ top: chatRef.current.scrollHeight }) + } + }, [chat, busy]) + + const onChatScroll = (): void => { + const el = chatRef.current + if (!el) return + stickToBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48 + } + + const send = (text: string): void => { + const instruction = text.trim() + const loop = loopRef.current + if (!instruction || !loop || loop.busy) return + stickToBottomRef.current = true + runInstructionRef.current = instruction + runMutatedRef.current = false + runToolsRef.current = [] + setChat((prev) => [ + ...prev, + { role: 'user', text: instruction }, + { role: 'assistant', text: '', streaming: true }, + ]) + setPrompt('') + setBusy(true) + persistMessage('user', instruction) + void (async () => { + try { + settingsRef.current = await window.markdownApi.getAiSettings() + await loop.run(instruction) + } catch (err) { + patchLast({ + streaming: false, + text: err instanceof Error ? err.message : String(err), + isError: true, + }) + setBusy(false) + } + })() + } + + const stop = (): void => loopRef.current?.cancel() + + const retry = (): void => send(runInstructionRef.current) + + // ribbon presets auto-send; while a run is active they land in the composer instead + const presetNonceRef = useRef(0) + useEffect(() => { + if (!preset || preset.nonce === presetNonceRef.current) return + presetNonceRef.current = preset.nonce + if (loopRef.current?.busy) setPrompt(preset.text) + else send(preset.text) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [preset]) + + const copyMessage = (text: string, idx: number): void => { + void navigator.clipboard.writeText(text) + setCopiedIdx(idx) + window.setTimeout(() => setCopiedIdx((cur) => (cur === idx ? null : cur)), 1200) + } + + const rollback = (snapshot: Snapshot): void => { + if (busy) return + depsRef.current.restoreSnapshot(snapshot.markdown) + setSnapshots((prev) => prev.filter((s) => s !== snapshot)) + } + + useEffect(() => { + const onResize = (): void => setPanelWidth((w) => clampPanelWidth(w)) + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + }, []) + + const resizeCleanupRef = useRef<(() => void) | null>(null) + useEffect(() => () => resizeCleanupRef.current?.(), []) + + /** Drag the right edge to resize: the panel is flush with the window's left edge, so width = clientX */ + const startResize = (e: ReactPointerEvent): void => { + e.preventDefault() + const resizer = e.currentTarget + setResizing(true) + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + const onMove = (ev: PointerEvent): void => { + setPanelWidth(clampPanelWidth(ev.clientX)) + } + let done = false + const cleanup = (): void => { + if (done) return + done = true + resizeCleanupRef.current = null + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', cleanup) + window.removeEventListener('pointercancel', cleanup) + resizer.removeEventListener('lostpointercapture', cleanup) + document.body.style.cursor = '' + document.body.style.userSelect = '' + setResizing(false) + setPanelWidth((w) => { + localStorage.setItem(PANEL_WIDTH_KEY, String(Math.round(w))) + return w + }) + } + resizeCleanupRef.current = cleanup + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', cleanup) + window.addEventListener('pointercancel', cleanup) + resizer.addEventListener('lostpointercapture', cleanup) + resizer.setPointerCapture(e.pointerId) + } + + return ( + + ) +} + +/** Step-row status icons (timeline glyphs, unified with the other apps) */ +function StepIcon({ status }: { status: 'running' | 'done' | 'error' }) { + if (status === 'running') { + return ( + + + + ) + } + if (status === 'error') { + return ( + + + + + ) + } + return ( + + + + + ) +} + +/** Tool activity group (docs parity): auto-opens while tools run, auto-collapses into + * "Worked · N steps" when they finish; a manual toggle always wins */ +function ToolChipList({ tools }: { tools: ToolActivity[] }) { + const { t: tr } = useI18n() + const [expanded, setExpanded] = useState>(new Set()) + const [userOpen, setUserOpen] = useState(null) + + const toggle = (j: number) => { + setExpanded((prev) => { + const next = new Set(prev) + if (next.has(j)) next.delete(j) + else next.add(j) + return next + }) + } + + const anyRunning = tools.some((tool) => tool.running) + const open = userOpen ?? anyRunning + const label = anyRunning ? tr('aiGroupWorking') : tr('aiWorkedSteps', { n: tools.length }) + + return ( +
    + +
    +
    + {tools.map((tool, j) => { + const hasOutput = !tool.running && !!tool.output + const isOpen = expanded.has(j) + const stepStatus = tool.running ? 'running' : tool.isError ? 'error' : 'done' + return ( +
    + + + +
    + {hasOutput ? ( + + ) : ( + + {tool.summary} + + )} + {hasOutput && isOpen && ( +
    +
    +
    {tool.output}
    +
    +
    + )} +
    +
    + ) + })} +
    +
    +
    + ) +} + +function Svg({ children }: { children: ReactNode }): ReactElement { + return ( + + {children} + + ) +} + +function IconNewChat(): ReactElement { + return ( + + + + + ) +} + +function IconCollapse(): ReactElement { + return ( + + + + + + ) +} + +function IconClock(): ReactElement { + return ( + + + + + ) +} + +/** Genspark brand mark, inline for crisp device-resolution rendering */ +export function GensparkMark({ size = 18 }: { size?: number }): React.JSX.Element { + return ( + + + + ) +} diff --git a/apps/markdown/src/renderer/ai/markdown-skill.ts b/apps/markdown/src/renderer/ai/markdown-skill.ts new file mode 100644 index 0000000..09dff10 --- /dev/null +++ b/apps/markdown/src/renderer/ai/markdown-skill.ts @@ -0,0 +1,56 @@ +import type { AgentSkill } from '@hermesoffice/agent-core' +import type { Editor } from '@tiptap/core' +import { AGENT_TOOLS, buildDocContext, executeTool, markDocSeen } from './tools' + +const MARKDOWN_RULES = [ + 'All markdown passed to tools must be pure GFM. Rules:', + '- Allowed syntax, and nothing else: `#`–`######` headings, paragraphs, `**bold**`, `*italic*`, `~~strikethrough~~`, `` `inline code` ``, `[links](url)`, `![images](path)`, `-` / `1.` lists, `- [ ]` task lists, `>` blockquotes, ``` fenced code blocks, `|` pipe tables, `---` horizontal rules, hard line breaks (two trailing spaces).', + '- Never emit raw HTML — no tag of any kind (``, `
    `, `

    `, ``, `
    `, ``, ``, …) and no style attributes. The editor forces everything through its GFM-only schema: semantic tags degrade to plain GFM and all other tags and styling are silently dropped.', + '- Never emit non-GFM extensions: `==highlight==`, `++underline++`, `:::` fenced divs, footnotes, math, or emoji shortcodes. They are not parsed and end up as literal text in the document.', + '- This editor has no colored text, fonts, font sizes, underline, highlight, alignment, or line spacing. If the user asks for such styling, explain that pure markdown cannot express it — never fake it with HTML.', + '- Express emphasis through structure instead: headings for hierarchy, bold for key phrases, blockquotes for callout-style notes, tables for comparisons.', +].join('\n') + +const AGENT_SYSTEM_PROMPT = [ + 'You are the writing assistant inside GenOffice Markdown, a markdown document editor.', + 'You read and edit the open document through tools that address top-level blocks by 0-based index.', + '', + '## Markdown syntax rules', + MARKDOWN_RULES, + '', + '## Editing rules', + '- The per-message document state lists every block as `index | type | preview`. Previews are truncated — use read_blocks when you need full text.', + '- Prefer replace_blocks for rewrites and formatting changes; insert_content for additions. Batch related edits into as few calls as possible.', + '- After a mutating call, block indexes change — refresh with get_document_context before more index-based edits.', + '- If a tool reports the document changed under you, refresh the context and re-plan instead of retrying blindly.', + '', + '## Writing a new document', + '- When the document is blank and the user asks for content, write the full document in one insert_content call: start with a single `#` title, use `##` sections, keep paragraphs short.', + '- Use tables for comparisons, task lists for actionable items, blockquotes for important notes.', + '- Never invent facts or numbers; use web_search when the topic needs current information and attribute sources.', + '', + '## Conversation', + '- Answer questions about the document directly, without editing it.', + '- Keep replies short; the edits themselves are the deliverable. Summarize what you changed in one or two sentences.', +].join('\n') + +export function createMarkdownSkill(getEditor: () => Editor | null): AgentSkill { + return { + id: 'markdown', + systemPrompt: AGENT_SYSTEM_PROMPT, + tools: AGENT_TOOLS, + buildContext: () => { + const editor = getEditor() + if (!editor) return '' + markDocSeen(editor) + return buildDocContext(editor) + }, + executeTool: (call) => { + const editor = getEditor() + if (!editor) { + return { output: 'editor not ready', isError: true, summary: call.name } + } + return executeTool(editor, call) + }, + } +} diff --git a/apps/markdown/src/renderer/ai/search-skill.ts b/apps/markdown/src/renderer/ai/search-skill.ts new file mode 100644 index 0000000..45b2da0 --- /dev/null +++ b/apps/markdown/src/renderer/ai/search-skill.ts @@ -0,0 +1,49 @@ +import type { AgentSkill } from '@hermesoffice/agent-core' +import { t } from '../i18n/locale' + +const SEARCH_SYSTEM_PROMPT = `## Web search +- When you need up-to-date information, data, or facts beyond the document, use web_search; never fabricate numbers from memory. +- When writing search results into the document, attribute the data source (a link or a source name).` + +/** Web-search AgentSkill (same main-process source as docs/sheets/slides web_search). */ +export function createSearchSkill(): AgentSkill { + return { + id: 'search', + systemPrompt: SEARCH_SYSTEM_PROMPT, + tools: [ + { + name: 'web_search', + description: + 'Search the web for textual information (references/data/facts). Use when you need up-to-date information or are unsure about a fact. Returns titles/links/snippets.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search keywords' }, + maxResults: { type: 'integer', description: 'Maximum number of results, default 6' }, + }, + required: ['query'], + }, + }, + ], + executeTool: async (call) => { + if (call.name !== 'web_search') { + return { output: `Unknown tool: ${call.name}`, isError: true, summary: call.name } + } + const query = String(call.input.query ?? '').trim() + if (!query) { + return { output: 'query must not be empty', isError: true, summary: t('aiToolWebSearch') } + } + const r = await window.markdownApi.webSearch(query, Number(call.input.maxResults) || 6) + const lines: string[] = [] + if (r.answer) lines.push(`Direct answer: ${r.answer}\n`) + r.results.forEach((it, i) => + lines.push(`${i + 1}. ${it.title}\n ${it.url}\n ${it.snippet}`), + ) + return { + output: lines.join('\n') || '(no results)', + mutated: false, + summary: t('aiToolWebSearchDone', { query, count: r.results.length }), + } + }, + } +} diff --git a/apps/markdown/src/renderer/ai/tools.ts b/apps/markdown/src/renderer/ai/tools.ts new file mode 100644 index 0000000..9f54e31 --- /dev/null +++ b/apps/markdown/src/renderer/ai/tools.ts @@ -0,0 +1,308 @@ +import type { Editor, JSONContent } from '@tiptap/core' +import type { Node as PmNode } from '@tiptap/pm/model' +import type { AgentToolCall, AgentToolDef, ToolExecution } from '@hermesoffice/agent-core' +import { markAiRange } from '../editor/aiHighlight' +import { stripLegacyFencedDivs } from '../markdown/docText' +import { t } from '../i18n/locale' + +const CONTEXT_MAX_CHARS = 8000 +const PREVIEW_CHARS = 60 +const READ_PAGE_CHARS = 24000 +const SELECTION_MAX_CHARS = 4000 + +const INDEX_CHANGE_NOTICE = + 'Block indexes may have changed; call get_document_context before further index-based edits.' +const STALE_DOC_ERROR = + 'The document changed since you last saw it (the user edited it). Call get_document_context to refresh before editing.' + +// ── staleness guard: index-addressed writes are refused after user edits ── + +const docBaseline = new WeakMap() + +export function markDocSeen(editor: Editor): void { + docBaseline.set(editor, editor.state.doc) +} + +function editedExternally(editor: Editor): boolean { + const seen = docBaseline.get(editor) + return seen !== undefined && seen !== editor.state.doc +} + +// ── document skeleton / serialization helpers ── + +function blockPreview(node: PmNode): string { + const text = node.textContent.replace(/\s+/g, ' ').trim() + return text.length > PREVIEW_CHARS ? `${text.slice(0, PREVIEW_CHARS)}…` : text +} + +function blockLabel(node: PmNode): string { + if (node.type.name === 'heading') return `h${node.attrs.level}` + return node.type.name +} + +/** Serialize a range of top-level blocks back to markdown */ +function serializeBlocks(editor: Editor, from: number, to: number): string { + const content: JSONContent[] = [] + editor.state.doc.forEach((node, _offset, index) => { + if (index >= from && index <= to) content.push(node.toJSON() as JSONContent) + }) + return editor.markdown?.serialize({ type: 'doc', content }) ?? '' +} + +function selectionMarkdown(editor: Editor): string { + const { from, to } = editor.state.selection + if (from === to) return '' + const text = editor.state.doc.textBetween(from, to, '\n') + return text.length > SELECTION_MAX_CHARS ? `${text.slice(0, SELECTION_MAX_CHARS)}…` : text +} + +/** Per-turn context: numbered block skeleton + selection, same shape as the docs agent */ +export function buildDocContext(editor: Editor): string { + const doc = editor.state.doc + const blockCount = doc.childCount + const isBlank = + blockCount === 0 || (blockCount === 1 && doc.firstChild!.textContent.trim() === '') + if (isBlank) { + return ['## Document state', 'The document is currently blank.'].join('\n') + } + const lines: string[] = ['## Document state', `${blockCount} top-level blocks:`, ''] + let used = 0 + for (let i = 0; i < blockCount; i++) { + const node = doc.child(i) + const line = `${i} | ${blockLabel(node)} | ${blockPreview(node)}` + used += line.length + 1 + if (used > CONTEXT_MAX_CHARS) { + lines.push(`… (${blockCount - i} more blocks; use read_blocks to view them)`) + break + } + lines.push(line) + } + const selection = selectionMarkdown(editor) + if (selection) lines.push('', '## User selection', selection) + return lines.join('\n') +} + +// ── tool definitions ── + +export const AGENT_TOOLS: AgentToolDef[] = [ + { + name: 'get_document_context', + description: + 'Refresh the document overview: a numbered list of top-level blocks (index | type | preview) plus the current selection. Call this before index-based edits when in doubt.', + inputSchema: { type: 'object', properties: {}, required: [] }, + }, + { + name: 'read_blocks', + description: + 'Read a range of top-level blocks as markdown. Long output is paged; a notice tells you the offset to continue from.', + inputSchema: { + type: 'object', + properties: { + startIndex: { type: 'integer', description: '0-based index of the first block' }, + endIndex: { type: 'integer', description: '0-based index of the last block (inclusive)' }, + offset: { + type: 'integer', + description: 'Character offset to continue a previously truncated read', + }, + }, + required: ['startIndex', 'endIndex'], + }, + }, + { + name: 'insert_content', + description: + 'Insert new markdown content after a top-level block. Use afterIndex -1 to insert at the very beginning of the document. On a blank document this replaces the empty paragraph.', + inputSchema: { + type: 'object', + properties: { + afterIndex: { + type: 'integer', + description: '0-based block index to insert after; -1 = document start', + }, + markdown: { type: 'string', description: 'Markdown content to insert' }, + }, + required: ['afterIndex', 'markdown'], + }, + }, + { + name: 'replace_blocks', + description: + 'Replace a range of top-level blocks (inclusive) with new markdown content. Use this for rewrites, formatting changes and deletions (empty markdown deletes the range).', + inputSchema: { + type: 'object', + properties: { + startIndex: { type: 'integer', description: '0-based index of the first block' }, + endIndex: { type: 'integer', description: '0-based index of the last block (inclusive)' }, + markdown: { type: 'string', description: 'Replacement markdown; empty string deletes' }, + }, + required: ['startIndex', 'endIndex', 'markdown'], + }, + }, +] + +// ── executor ── + +function fail(output: string, summary: string): ToolExecution { + return { output, isError: true, summary } +} + +function clampIndex(value: unknown, max: number): number | null { + const n = Number(value) + if (!Number.isInteger(n) || n < 0 || n > max) return null + return n +} + +/** Byte offsets of each top-level block: [startPos, endPos] in the current doc */ +function blockRange(doc: PmNode, from: number, to: number): { from: number; to: number } { + let pos = 0 + let start = 0 + let end = 0 + for (let i = 0; i <= to; i++) { + const child = doc.child(i) + if (i === from) start = pos + pos += child.nodeSize + if (i === to) end = pos + } + return { from: start, to: end } +} + +function parseMarkdownToNodes(editor: Editor, markdown: string): PmNode[] { + // model output guard: `:::` fenced divs are not GFM and would land as + // literal text — strip the fences and keep the body (same as file open). + // Raw HTML needs no guard: parse runs it through the schema, so semantic + // tags degrade to their GFM equivalents (→bold, →image) and + // anything the schema cannot represent loses its styling, keeping text. + const json = editor.markdown?.parse(stripLegacyFencedDivs(markdown)) + const content = json?.content ?? [] + return content.map((c) => editor.schema.nodeFromJSON(c)) +} + +function isBlankDoc(doc: PmNode): boolean { + return doc.childCount === 1 && doc.firstChild!.textContent.trim() === '' +} + +export function executeTool(editor: Editor, call: AgentToolCall): ToolExecution { + const doc = editor.state.doc + const maxIndex = doc.childCount - 1 + + switch (call.name) { + case 'get_document_context': { + markDocSeen(editor) + return { + output: buildDocContext(editor), + mutated: false, + summary: t('aiToolReadDoc'), + } + } + + case 'read_blocks': { + const start = clampIndex(call.input.startIndex, maxIndex) + const end = clampIndex(call.input.endIndex, maxIndex) + if (start === null || end === null || start > end) { + return fail( + `Invalid block range; the document has ${doc.childCount} blocks.`, + t('aiToolReadBlocks'), + ) + } + const full = serializeBlocks(editor, start, end) + const offset = Math.max(0, Number(call.input.offset) || 0) + const page = full.slice(offset, offset + READ_PAGE_CHARS) + const truncated = offset + READ_PAGE_CHARS < full.length + const notice = truncated + ? `\n\n[truncated — continue with offset=${offset + READ_PAGE_CHARS}]` + : '' + return { + output: page + notice, + mutated: false, + summary: t('aiToolReadBlocks'), + } + } + + case 'insert_content': { + if (editedExternally(editor)) return fail(STALE_DOC_ERROR, t('aiToolInsert')) + const markdown = String(call.input.markdown ?? '') + if (!markdown.trim()) return fail('markdown must not be empty', t('aiToolInsert')) + const after = Number(call.input.afterIndex) + if (!Number.isInteger(after) || after < -1 || after > maxIndex) { + return fail( + `afterIndex out of range; the document has ${doc.childCount} blocks.`, + t('aiToolInsert'), + ) + } + let nodes: PmNode[] + try { + nodes = parseMarkdownToNodes(editor, markdown) + } catch (err) { + return fail( + `markdown parse failed: ${err instanceof Error ? err.message : String(err)}`, + t('aiToolInsert'), + ) + } + if (nodes.length === 0) return fail('markdown parsed to no content', t('aiToolInsert')) + + let tr = editor.state.tr + if (isBlankDoc(doc)) { + tr = tr.replaceWith(0, doc.content.size, nodes) + tr = markAiRange(tr, 0, tr.doc.content.size) + } else { + const pos = after === -1 ? 0 : blockRange(doc, after, after).to + const insertedSize = nodes.reduce((s, n) => s + n.nodeSize, 0) + tr = tr.insert(pos, nodes) + tr = markAiRange(tr, pos, pos + insertedSize) + } + editor.view.dispatch(tr) + markDocSeen(editor) + return { + output: `Inserted ${nodes.length} block(s). ${INDEX_CHANGE_NOTICE}`, + mutated: true, + summary: t('aiToolInsertDone', { n: nodes.length }), + } + } + + case 'replace_blocks': { + if (editedExternally(editor)) return fail(STALE_DOC_ERROR, t('aiToolReplace')) + const start = clampIndex(call.input.startIndex, maxIndex) + const end = clampIndex(call.input.endIndex, maxIndex) + if (start === null || end === null || start > end) { + return fail( + `Invalid block range; the document has ${doc.childCount} blocks.`, + t('aiToolReplace'), + ) + } + const markdown = String(call.input.markdown ?? '') + let nodes: PmNode[] + try { + nodes = parseMarkdownToNodes(editor, markdown) + } catch (err) { + return fail( + `markdown parse failed: ${err instanceof Error ? err.message : String(err)}`, + t('aiToolReplace'), + ) + } + const { from, to } = blockRange(doc, start, end) + let tr = editor.state.tr + if (nodes.length === 0) { + // deleting every block is not allowed by the schema — leave one empty paragraph + if (start === 0 && end === maxIndex) { + tr = tr.replaceWith(from, to, editor.schema.nodes.paragraph!.create()) + } else { + tr = tr.delete(from, to) + } + } else { + const insertedSize = nodes.reduce((s, n) => s + n.nodeSize, 0) + tr = tr.replaceWith(from, to, nodes) + tr = markAiRange(tr, from, from + insertedSize) + } + editor.view.dispatch(tr) + markDocSeen(editor) + return { + output: `Replaced blocks ${start}-${end} with ${nodes.length} block(s). ${INDEX_CHANGE_NOTICE}`, + mutated: true, + summary: t('aiToolReplaceDone', { n: end - start + 1 }), + } + } + + default: + return fail(`Unknown tool: ${call.name}`, call.name) + } +} diff --git a/apps/markdown/src/renderer/ai/transport.ts b/apps/markdown/src/renderer/ai/transport.ts new file mode 100644 index 0000000..b080ed8 --- /dev/null +++ b/apps/markdown/src/renderer/ai/transport.ts @@ -0,0 +1,16 @@ +import { createIpcTransport, type AgentTransport } from '@hermesoffice/agent-core' +import type { AiSettings } from '@hermesoffice/ai-provider' +import { t } from '../i18n/locale' + +/** The shared IPC transport wired to the markdown preload bridge (window.markdownApi). */ +export function createElectronTransport(getSettings: () => AiSettings): AgentTransport { + return createIpcTransport({ + onStream: (listener) => window.markdownApi.onAiStream(listener), + start: (request) => window.markdownApi.aiStream(request), + cancel: (requestId) => void window.markdownApi.aiStreamCancel(requestId), + getSettings, + unknownErrorText: () => t('aiUnknownError'), + timeoutErrorText: () => t('aiTimeoutError'), + creditsErrorText: () => t('aiCreditsExhausted'), + }) +} diff --git a/apps/markdown/src/renderer/assets/send-enter-off.png b/apps/markdown/src/renderer/assets/send-enter-off.png new file mode 100644 index 0000000..8009f0b Binary files /dev/null and b/apps/markdown/src/renderer/assets/send-enter-off.png differ diff --git a/apps/markdown/src/renderer/assets/send-enter-on.png b/apps/markdown/src/renderer/assets/send-enter-on.png new file mode 100644 index 0000000..83de4da Binary files /dev/null and b/apps/markdown/src/renderer/assets/send-enter-on.png differ diff --git a/apps/markdown/src/renderer/assets/send-stop.png b/apps/markdown/src/renderer/assets/send-stop.png new file mode 100644 index 0000000..a2e625d Binary files /dev/null and b/apps/markdown/src/renderer/assets/send-stop.png differ diff --git a/apps/markdown/src/renderer/components/FrontmatterPanel.tsx b/apps/markdown/src/renderer/components/FrontmatterPanel.tsx new file mode 100644 index 0000000..c319018 --- /dev/null +++ b/apps/markdown/src/renderer/components/FrontmatterPanel.tsx @@ -0,0 +1,32 @@ +import { useI18n } from '../i18n/locale' + +interface Props { + /** inner YAML text, without the --- fences */ + value: string + onChange: (value: string) => void + readOnly?: boolean +} + +/** + * Raw-text properties panel: edits the YAML between the frontmatter fences + * verbatim. No YAML parsing — whatever the user types is what lands in the + * file, so exotic YAML survives untouched. + */ +export function FrontmatterPanel({ value, onChange, readOnly }: Props) { + const { t } = useI18n() + const rows = Math.min(12, Math.max(2, value.split('\n').length)) + return ( +

    +
    {t('fmProperties')}
    +