diff --git a/.gitattributes b/.gitattributes index bd3280767..35dbbce3e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,6 +7,7 @@ *.docx binary *.xlsx binary *.pptx binary +*.ods binary *.pdf binary *.png binary *.ico binary diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index e3f55c346..bd57a5557 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: true contact_links: - name: Security vulnerability - url: https://github.com/genspark-ai/hermesoffice/security/advisories/new + url: https://github.com/criptogus/HermesOffice/security/advisories/new about: Please report security issues privately, never as a public issue. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f229c1d9..93df5cbbd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,17 @@ jobs: with: workspaces: apps/sheets/native/xlsx-engine + # electron's postinstall downloads its binary from GitHub Releases on every + # fresh install; a cache hit skips the download (and its 5xx flakes) + - name: Cache electron binaries + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/electron + # own prefix: the build workflows' electron-* entries also hold + # ~/.cache/electron-builder, and a saved key is never updated + key: electron-bin-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + restore-keys: electron-bin-${{ runner.os }}- + - name: Install dependencies run: npm ci @@ -88,6 +99,78 @@ jobs: fi npm run check:theme-colors + # Inline text-layout decisions (script tests, face predicates, CJK ranges, + # compat reads) stay in apps/docs/src/renderer/text-layout/, not in the emitters. + - name: Check inline text-layout rules + 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:inline-rules + + # Line-metric numbers (fonts.css size-adjust/overrides, knownLineFactor) + # must cite the Word measurement they came from. + - name: Check font-metric provenance + 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:font-metrics-provenance + + # fonts.generated.css / line-factors.generated.ts must match the measurement + # manifest; metric values are never hand-written outside it. + - name: Check generated font metrics + 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:generated-fonts + + # docs/word-truths-index.json and the coverage doc must match the fixtures; + # a recently dated truths section needs a fixture or a prose-only marker. + - name: Check Word truths coverage + run: npm run check:word-truths + + # skills/*/SKILL.md ship to users' agent directories; a body change + # without a metadata.version bump would never be offered as an update. + - name: Check skill versions + 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:skill-version + + # Whole-tree, not changed-files: every one of the manual's 21 languages + # has to match the English article's structure. A language that quietly + # falls behind is exactly what no reviewer sees, and it is invisible in a + # diff — the change that causes it looks like an ordinary translation. + - name: Check manual language coverage + run: npm run check:help-langs + + # Added lines only: Han text outside locale tables, review-tool tags, + # feedback-ledger ids, sample-corpus ids, internal PR numbers, personal + # paths/e-mails, credentials, NUL bytes (tools/check-public-hygiene.mjs). + - name: Check public-repo hygiene + 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 + if [ -f tools/check-public-hygiene.mjs ]; then + node tools/check-public-hygiene.mjs + else + echo "public-hygiene gate not present in this checkout; skipped" + fi + # Keep tracked code comments and contributor-facing documentation # English-only; functional CJK strings and AI prompt examples are allowed. - name: Check comments/docs are English-only @@ -108,10 +191,22 @@ jobs: - name: Check committed fixtures match the generator run: git diff --exit-code -- fixtures/generated - # CLI XLSX tests run before the Sheets workspace tests in npm test. - - name: Prepare Sheets engine and fixtures for CLI tests + # pptx-engine's OOXML schema gate validates saved decks with xmllint + # (tools/ooxml-validate); the runner image is not guaranteed to have it. + - name: Install xmllint + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libxml2-utils + + # `sheet apply`'s dependent-cache audit lives in the Rust xlsx-sidecar, and + # the CLI suite skips its sidecar-backed tests (`it.skipIf(!sidecar)`) when + # the binary is missing. Only the sheets package's test script builds it, + # and the root `test` script runs @hermesoffice/cli before @hermesoffice/sheets — + # so build the sidecar up front or CI's pass proves nothing about the + # audit. The cargo cache above holds the target dir, so this is cheap. + - name: Build xlsx-sidecar (the CLI audit tests run against it) run: | npm run native:build -w @hermesoffice/sheets + # the sidecar-gated CLI tests read the generated compatibility fixture + # (gitignored, normally produced by the sheets gate below) npm run fixtures -w @hermesoffice/sheets - name: Test @@ -150,6 +245,17 @@ jobs: with: workspaces: apps/sheets/native/xlsx-engine + # electron's postinstall downloads its binary from GitHub Releases on every + # fresh install; a cache hit skips the download (and its 5xx flakes) + - name: Cache electron binaries + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/electron + # own prefix: the build workflows' electron-* entries also hold + # ~/.cache/electron-builder, and a saved key is never updated + key: electron-bin-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + restore-keys: electron-bin-${{ runner.os }}- + - name: Install dependencies run: npm ci @@ -197,7 +303,7 @@ jobs: - name: Upload E2E artifacts on failure if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: e2e-artifacts path: | diff --git a/.prettierignore b/.prettierignore index 69c9216c7..63b20fe7f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -10,6 +10,11 @@ coverage/ package-lock.json apps/*/build/ apps/shell/build/THIRD-PARTY-NOTICES.txt +packages/font-metrics/word-faces.json +apps/docs/src/renderer/fonts/stand-in-boxes.json +apps/docs/tests/fixtures/font-metrics/ +apps/docs/src/renderer/fonts/fonts.generated.css +apps/docs/src/renderer/line-factors.generated.ts # Local-only acceptance drivers are intentionally outside CI. scripts/drivers/driver.*.mjs diff --git a/NOTICE b/NOTICE index 9f140d9df..34d8590f9 100644 --- a/NOTICE +++ b/NOTICE @@ -13,6 +13,27 @@ checkout. Bundled fonts and their licenses are documented in apps/docs/src/renderer/fonts/README.md. +Downloadable fonts + +Any app can offer additional font families, at the reader's request, from the +build-injected font mirror. Those families are not in the bundle and so are not +in that README; the catalogue is +packages/electron-utils/src/font-catalog.ts, and every family there carries its +SPDX licence id in a `license` field, checked against a permissive allowlist by +packages/electron-utils/tests/font-store.test.ts. + +All of them are licensed under the SIL Open Font License, Version 1.1 +(https://openfontlicense.org), which permits the bundling and redistribution +this application performs. The families are: Open Sans, Roboto, Lato, +Montserrat, Poppins, Inter, Source Sans 3, Oswald, Raleway, Nunito, +Merriweather, Playfair Display, Work Sans, Rubik, Noto Sans JP, Noto Sans KR, +Noto Sans SC, Noto Sans TC, Nanum Gothic, Noto Serif SC, Noto Serif TC, +Noto Serif JP, Noto Serif KR, Nanum Myeongjo. + +Each downloaded file is verified against the sha256 recorded in the catalogue +before it is installed, and the catalogue states each family's size so an app +can ask before fetching tens of megabytes. + Unicode Character Database apps/pdf/src/shared/radicals.ts contains a generated mapping derived from diff --git a/PRIVACY.md b/PRIVACY.md index f5c6e1ef8..38efff064 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,9 +1,9 @@ -# GenOffice Privacy +# HermesOffice Privacy Last updated: August 26, 2026 -GenOffice opens, edits, and saves documents locally. Document editing does not -upload files to GenOffice. AI features require a network connection and send +HermesOffice opens, edits, and saves documents locally. Document editing does not +upload files to HermesOffice. AI features require a network connection and send requests only when you use them. ## Usage analytics @@ -49,12 +49,12 @@ email address. Events are sent to Google Analytics 4 using the Measurement Protocol over HTTPS. As the HTTPS recipient, Google necessarily sees the connection's public IP address and transport metadata, and may use them for coarse geolocation and -security or spam-abuse processing. GenOffice does not add an IP address to the +security or spam-abuse processing. HermesOffice does not add an IP address to the event payload. ## Data not collected by analytics -GenOffice analytics never sends: +HermesOffice analytics never sends: - document content - file names diff --git a/SECURITY.md b/SECURITY.md index 75c91bc11..48a223745 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -67,13 +67,22 @@ If you find a way for a layout script to reach anything beyond the injected primitives (network, storage, IPC channels not reachable by design, or the main process), that is a vulnerability — please report it. -## Threat Model: Rendering AI-Generated HTML (slides export) +## Threat Model: Rendering AI-Generated HTML -The HTML-to-pptx export pipeline renders AI-generated HTML in a hidden -`BrowserWindow`. That window is treated as hostile content: full renderer +Three pipelines render AI-generated or untrusted HTML in a hidden +`BrowserWindow`. Every window is treated as hostile content: full renderer lockdown (`sandbox: true`, `contextIsolation: true`, `nodeIntegration: false`), -no preload script, no IPC surface — the main process drives it exclusively -through `executeJavaScript` and destroys it under a watchdog timeout. +no preload script, no IPC surface. The slides hidden windows only load the +app's own renderer and print HTML (PDF export, print, headless export); the +AI-HTML-to-pptx conversion runs inside the slides renderer, never in a hidden +window. The HTML app's DOCX export and the docs altChunk conversion drive their +windows exclusively through `executeJavaScript` under a watchdog timeout +(`Promise.race` against a fixed timeout, then `destroy()`), so a page whose +scripts never yield cannot strand the hidden window. HTML→DOCX +conversion renders the page with scripts enabled and does not sanitize the +markup before conversion — the output is a document, not a sandbox escape; +report it as a vulnerability if you find a way from the converted page into +the main process. ## Out of Scope diff --git a/apps/docs/electron.vite.config.ts b/apps/docs/electron.vite.config.ts index 8f02bc809..25e32fa52 100644 --- a/apps/docs/electron.vite.config.ts +++ b/apps/docs/electron.vite.config.ts @@ -30,6 +30,7 @@ export default defineConfig({ '@hermesoffice/docx-engine', '@hermesoffice/electron-utils', '@hermesoffice/font-metrics', + '@hermesoffice/xlsx-gateway', ], }), ], @@ -44,7 +45,7 @@ export default defineConfig({ plugins: [react()], resolve: { alias: localAlias }, server: { - // Overridable so multiple genoffice dev instances can coexist (default 5173). + // Overridable so multiple hermesoffice dev instances can coexist (default 5173). port: Number(process.env.DOCS_DEV_PORT) || 5173, strictPort: Boolean(process.env.DOCS_DEV_PORT), }, diff --git a/apps/docs/package.json b/apps/docs/package.json index 521e66af4..a253c6a31 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -34,22 +34,21 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.0.4", - "electron": "^43.3.0", + "electron": "^43.8.3", "electron-builder": "^26.0.12", "electron-vite": "^5.0.0", "jsdom": "^28.0.0", "marked": "^17.0.6", - "officecrypto-tool": "^0.0.19", "opentype.js": "^2.0.0", "react": "^19.2.4", "react-dom": "^19.2.4", "typescript": "^5.9.3", - "vite": "^7.3.1", - "vitest": "^4.1.11" + "vite": "^7.3.7", + "vitest": "^4.1.12" }, - "author": "GenOffice", + "author": "HermesOffice", "build": { - "appId": "com.genoffice.docs", + "appId": "com.hermesoffice.docs", "productName": "HermesOffice Docs", "electronVersion": "43.3.0", "artifactName": "AIDocx-${version}-${arch}.${ext}", @@ -96,6 +95,8 @@ "electron-updater": "^6.8.9", "pdf-lib": "^1.17.1", "pdfjs-dist": "^6.2.108", - "@hermesoffice/pptx-render": "*" + "@hermesoffice/pptx-render": "*", + "@hermesoffice/xlsx-gateway": "*", + "@hermesoffice/agent-core": "*" } } diff --git a/apps/docs/src/main/atomic-write.ts b/apps/docs/src/main/atomic-write.ts index b3c62812e..b2fdf1258 100644 --- a/apps/docs/src/main/atomic-write.ts +++ b/apps/docs/src/main/atomic-write.ts @@ -1,50 +1,7 @@ -import { randomBytes } from 'node:crypto' -import { rename, unlink, writeFile } from 'node:fs/promises' -import { basename, dirname, join } from 'node:path' - -/** Transient Windows codes: antivirus/indexer briefly locks the rename target. */ -const RETRYABLE_RENAME_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']) -const RENAME_RETRIES = 4 - -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) - -/** - * Same-dir temp file + rename, so a crash mid-write can't truncate the target. - * Rename-over-existing fails transiently on Windows under Defender/indexer - * locks: retry with backoff, then fall back to an in-place write — losing - * atomicity for that one save beats failing a save the previous plain - * writeFileSync would have completed. - */ -export async function atomicWriteFile(filePath: string, data: Buffer): Promise { - const tmp = join( - dirname(filePath), - `.${basename(filePath)}.${randomBytes(6).toString('hex')}.tmp`, - ) - try { - await writeFile(tmp, data) - for (let attempt = 0; ; attempt++) { - try { - await rename(tmp, filePath) - return - } catch (err) { - const code = (err as NodeJS.ErrnoException).code ?? '' - if (!RETRYABLE_RENAME_CODES.has(code) || attempt >= RENAME_RETRIES) throw err - await sleep(50 * 2 ** attempt) - } - } - } catch (err) { - try { - await unlink(tmp) - } catch { - /* tmp never created */ - } - if (RETRYABLE_RENAME_CODES.has((err as NodeJS.ErrnoException).code ?? '')) { - await writeFile(filePath, data) - return - } - throw err - } -} +/// Imported from its own electron-utils subpath rather than the package +/// barrel: the barrel drags Electron-only modules into headless bundles, and +/// tests that mock the barrel would otherwise have to mock this helper too. +export { atomicWriteFile } from '@hermesoffice/electron-utils/atomic-write' /** 'PK\x03\x04' local-file-header check — cheap docx/zip sanity test. */ export function looksLikeZip(bytes: Buffer): boolean { diff --git a/apps/docs/src/main/byte-handoff.ts b/apps/docs/src/main/byte-handoff.ts new file mode 100644 index 000000000..197f82a8a --- /dev/null +++ b/apps/docs/src/main/byte-handoff.ts @@ -0,0 +1,67 @@ +import { randomUUID } from 'node:crypto' +import { LAZY_MEDIA_SCHEME } from '@hermesoffice/docx-engine/lazy-media' + +/** + * Document bytes cross to the renderer over the media protocol instead of an + * IPC reply: the reply serializer copies the whole buffer (and doubles its + * scratch space while growing), which is what took the main process down on a + * 1 GB file. A one-shot token URL hands the buffer to a single fetch. + */ + +const HANDOFF_HOST = 'handoff' +/** an unclaimed handoff (renderer gone before its fetch) is dropped after this */ +const HANDOFF_TTL_MS = 60_000 + +/** a burst past this many unclaimed handoffs drops the oldest one */ +const MAX_PENDING_HANDOFFS = 8 +/** a burst past this many buffered bytes drops the oldest handoffs */ +const MAX_PENDING_HANDOFF_BYTES = 1024 * 1024 * 1024 + +const pending = new Map }>() +let pendingBytes = 0 + +/** release one handoff's timer, entry and bytes; the timer must go with the entry */ +function dropPending(token: string): void { + const entry = pending.get(token) + if (!entry) return + clearTimeout(entry.timer) + pending.delete(token) + pendingBytes -= entry.bytes.length +} + +/** oldest-first eviction until `bytes` fits under both caps (Map is insertion-ordered) */ +function evictFor(bytes: Buffer): void { + while ( + pending.size + 1 > MAX_PENDING_HANDOFFS || + pendingBytes + bytes.length > MAX_PENDING_HANDOFF_BYTES + ) { + const oldest = pending.keys().next() + if (oldest.done) return + dropPending(oldest.value) + } +} + +export function handOffBytes(bytes: Buffer): string { + evictFor(bytes) + const token = randomUUID() + const timer = setTimeout(() => dropPending(token), HANDOFF_TTL_MS) + timer.unref?.() + pending.set(token, { bytes, timer }) + pendingBytes += bytes.length + return `${LAZY_MEDIA_SCHEME}://${HANDOFF_HOST}/${token}` +} + +/** the bytes behind a handoff URL, released on first take; null for any other URL */ +export function takeHandoff(url: string): Buffer | null { + const prefix = `${LAZY_MEDIA_SCHEME}://${HANDOFF_HOST}/` + if (!url.startsWith(prefix)) return null + const token = url.slice(prefix.length) + const entry = pending.get(token) + if (!entry) return null + dropPending(token) + return entry.bytes +} + +export function pendingHandoffCount(): number { + return pending.size +} diff --git a/apps/docs/src/main/copy-image-guard.ts b/apps/docs/src/main/copy-image-guard.ts new file mode 100644 index 000000000..e67f9fbdc --- /dev/null +++ b/apps/docs/src/main/copy-image-guard.ts @@ -0,0 +1,45 @@ +/** + * Pure guards for docs:copy-image-to-clipboard. The data URL is base64-decoded + * and rasterized in the main process, so the payload is capped before decode + * and only raster subtypes are accepted. Electron-free for direct unit tests. + */ + +/** Renderer re-encodes via canvas.toDataURL, so photos routinely exceed 15 MB. */ +export const MAX_COPY_IMAGE_DATA_URL = 64 * 1024 * 1024 + +const DATA_URL_RE = /^data:image\/(png|jpe?g|gif|webp|bmp);/i + +export function validCopyImageDataUrl(dataUrl: unknown): dataUrl is string { + if (typeof dataUrl !== 'string') return false + if (dataUrl.length > MAX_COPY_IMAGE_DATA_URL) return false + // Non-data URLs are lazy-media ids resolved server-side; data URLs must be + // raster images (never data:text/html or svg scripts). + if (dataUrl.startsWith('data:') && !DATA_URL_RE.test(dataUrl)) return false + return true +} + +/** Display-size overrides for the pasted : finite, clamped to 100000px. */ +export function copyImageDisplaySize(meta: unknown): { width?: number; height?: number } { + if (typeof meta !== 'string' || meta.length > 2048) return {} + try { + const parsed = JSON.parse(meta) as Record + const out: { width?: number; height?: number } = {} + if ( + typeof parsed.imageWidthPx === 'number' && + Number.isFinite(parsed.imageWidthPx) && + parsed.imageWidthPx > 0 + ) { + out.width = Math.min(Math.round(parsed.imageWidthPx), 100000) + } + if ( + typeof parsed.imageHeightPx === 'number' && + Number.isFinite(parsed.imageHeightPx) && + parsed.imageHeightPx > 0 + ) { + out.height = Math.min(Math.round(parsed.imageHeightPx), 100000) + } + return out + } catch { + return {} + } +} diff --git a/apps/docs/src/main/docs-main.ts b/apps/docs/src/main/docs-main.ts index 77402c96e..a9a10007a 100644 --- a/apps/docs/src/main/docs-main.ts +++ b/apps/docs/src/main/docs-main.ts @@ -1,10 +1,12 @@ import { createHash, randomUUID } from 'node:crypto' +import { handOffBytes } from './byte-handoff' import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, + renameSync, statSync, unlinkSync, writeFileSync, @@ -33,16 +35,20 @@ import { ipcMain, nativeImage, net, + session, shell, webContents, } from 'electron' import { + abortOnDestroyed, appMenuLabels, + appMenuTemplate, buildPrintableHtml, configuredDefaultSaveDir, contextMenuLabels, fetchRemoteImage, installContextMenu, + setContextMenuInterceptor, installNavigationGuard, isHeadlessMode, printHtmlToPdf, @@ -54,13 +60,19 @@ import { aboutMenuItem, checkUpdatesMenuItem, toggleDevToolsItem, + settingsMenuItem, windowMenuTemplate, type HeadlessExportFormat, type HeadlessExportTarget, installRendererProtocol, registerRendererScheme, rendererUrl, - uniquePathIn, + MAX_REMOTE_IMAGE_BYTES, + readBodyCapped, + writeJsonAtomic, + recoveryCopyDiskNewer, + readLegacyImportSkip, + writeLegacyImportSkip, saveAttachmentBytes, } from '@hermesoffice/electron-utils' import { configureMetricsCache, familyVerticalMetrics } from '@hermesoffice/font-metrics' @@ -74,24 +86,31 @@ import type { WebContents, } from 'electron' import { parseFileToText } from '@hermesoffice/file-parse' +import { convertLegacyDocToDocx } from '../../../../packages/doc2docx/src' import { convertHtmlToDocx } from '../../../../packages/html2docx/src' import { ElectronBrowserDriver } from '../../../../packages/html2docx/src/drivers/electron' +import { convertOdtToDocx } from '../../../../packages/odf2ooxml/src' +import { convertHwpToDocx } from '../../../../packages/hwp2docx/src' import { AiCreditsError, AiTimeoutError, isAiNetworkError, isAiOverloadedError, + isAiRegionBlockedError, chatForProvider, - activeProvider, defaultAiSettings, + activeProvider, testMediaProvider, type AiMediaProviderConfig, type AiMediaProviderId, type AiSearchProviderId, resolveAiSettings, maxOutputTokensOf, + sanitizeAiSettings, + sanitizeCliPath, setAiUserAgent, setRescueFetch, + resolveProfileCredential, streamForProvider, type AiChatRequest, type AiSettings, @@ -104,8 +123,12 @@ import { listCodexModels, shutdownCodexAppServers, } from '@hermesoffice/ai-provider/codex-app-server' +import { startAiTrace } from '@hermesoffice/ai-provider/trace' +import { listCustomModelsForIpc } from '@hermesoffice/ai-provider/custom-models' import { ensureGenofficeLogin, + noteGskCallFailure, + setGenofficeLoginOpener, gskApiKey, generateImageTool, testSearchProvider, @@ -114,6 +137,7 @@ import { webSearchTool, imageSearchTool, analyzeMediaTool, + documentMediaRoots, } from '@hermesoffice/ai-search' import type { AiDocContent, @@ -121,25 +145,38 @@ import type { AttachmentImageResult, AttachmentMeta, AttachmentReadResult, + ContextMenuRequest, CreateDocumentRequest, CreateDocumentResult, DecryptOpenResult, DocsTabInfo, MenuCommand, + MergeOutputRequest, OpenDocxResult, + SpellLanguages, } from '../shared/ipc' -import { ATTACHMENT_IMAGE_EXTS } from '../shared/ipc' -import { findDocxPath } from '../shared/open-file' -import { atomicWriteFile, looksLikeZip } from './atomic-write' +import { ATTACHMENT_IMAGE_EXTS, MARKDOWN_MEDIA_DIR_TOKEN, type MarkdownImage } from '../shared/ipc' +import { MERGE_DATA_SOURCE_EXTENSIONS, readMergeDataSource } from './merge-data-source' +import { mergeOutputFileName, mergeOutputPath } from './merge-output' +import { ClickClaims } from '../shared/context-menu-claims' +import { + OPENABLE_DOC_EXTENSIONS, + docImportKindForPath, + findOpenableDocPath, + isOpenableDocPath, +} from '../shared/open-file' +import { atomicWriteFile } from './atomic-write' import { adoptLazyMediaHashes, forgetLazyMediaOwner, materializeLazyDocx, + moveLazyMediaSource, openLazyDocx, pointLazyMediaAt, readLazyMedia, registerLazyMediaProtocol, } from './lazy-media' +import { inlineLazyMediaInHtml } from './lazy-media-inline' import { commitDocPasswordSave, currentDocPasswordIntentRevision, @@ -159,6 +196,26 @@ import { snapshotDocPassword, } from './docx-encryption' import { isExternallyModified, type DiskFileState } from './external-change' +import { copyImageDisplaySize, validCopyImageDataUrl } from './copy-image-guard' +import { + printJobOptions, + printScaleOption, + validPrintGeometry, + validPrintPaper, +} from './print-args' +import { printPdfViaCups } from '@hermesoffice/electron-utils/print-cups' +import { printOutcome } from '@hermesoffice/electron-utils/print-job' +import { addPdfInternalLinks } from './pdf-links' +import { + assignStarredGroup, + dropStarredItems, + readStarredItems, + renameStarredItem, + starredGroupMap, + starredGroupNames, + toggleStarredItem, + writeStarredItems, +} from './starred-files' import { initDocsAutoUpdater } from './updater' import { registerZoteroIpc, teardownZoteroIpc } from './zotero-ipc' @@ -175,6 +232,17 @@ const tMain = createI18n({ zh: { dlgOpenDoc: '打开文档', filterWord: 'Word 文档', + odtImportMsg: '转换 .{ext} 文档?', + odtImportDetail: + 'HermesOffice 只编辑 .docx,不编辑 ODF。正文、标题、列表、表格和图片会转换过来;脚注、批注和文本框不会完整保留。原文件不会被修改,转换出的 .docx 是一份独立副本,之后的保存都写入它。', // public-hygiene: allow + hwpImportDetail: + 'HermesOffice 只编辑 .docx,不编辑 HWP。正文、表格、图片、页眉和页脚会转换过来;公式、脚注和嵌入对象不会转换,自动编号会变成普通文本。原文件不会被修改,转换出的 .docx 是一份独立副本,之后的保存都写入它。', // public-hygiene: allow + odtImportConvertBtn: '转换并打开', + importSkipConfirm: '下次直接转换,不再提示', + docImportMsg: '转换 .{ext} 文档?', + docImportDetail: + 'HermesOffice 只编辑 .docx,不编辑 Word 97-2003 旧版 .doc。正文、标题、列表、表格和图片会转换过来;脚注、批注和文本框不会完整保留。原文件不会被修改,转换出的 .docx 是一份独立副本,之后的保存都写入它。', // public-hygiene: allow + docImportConvertBtn: '转换并打开', dlgSaveAs: '另存为', closeUnsavedMsg: '此文档有未保存的更改。', closeUnsavedDetail: '关闭前是否保存?', @@ -182,6 +250,7 @@ const tMain = createI18n({ closeNoReplyDetail: '仍要关闭吗?未保存的更改将丢失。', btnCloseAnyway: '仍要关闭', autosaveFoundTitle: '发现自动恢复版本', + autosaveDiskNewer: '注意:此恢复副本保存后,磁盘上的文件又被修改过。恢复将替换这些较新的更改。', autosaveFoundBody: '上次会话有未保存的更改。要恢复自动保存的版本吗?', autosaveRestore: '恢复', autosaveDiscard: '放弃', @@ -192,11 +261,14 @@ const tMain = createI18n({ btnOverwrite: '覆盖', dlgInsertImage: '插入图片', filterImages: '图片', + dlgMergeDataSource: '选择收件人列表', + filterMergeDataSource: 'Excel 或 CSV', dlgAddAttachment: '添加附件', filterSupported: '支持的文件', filterAll: '所有文件', dlgExportPdf: '导出为 PDF', dlgExportHtml: '导出为 HTML', + dlgExportMarkdown: '导出为 Markdown', dlgPickExportDir: '选择导出目录', errUnsupportedExt: '暂不支持 .{ext} 类型', errNotFile: '不是文件', @@ -210,6 +282,7 @@ const tMain = createI18n({ errAttachmentNotGranted: '该文件未作为附件添加,无法读取', errGskNotLoggedIn: '未登录 Genspark:请点击下方「登录 Genspark」完成登录后重试', errNoApiKey: '未配置 {provider} 的 API Key', + errAiRegion: '该模型的厂商不向当前地区提供服务,请换一个模型或切换网络', errAiBusy: 'AI 服务当前繁忙,请稍后重试', errNoModel: '未配置模型名称', menuFile: '文件', @@ -224,6 +297,7 @@ const tMain = createI18n({ menuPageSetup: '页面设置…', menuExportPdf: '导出为 PDF…', menuExportHtml: '导出为 HTML…', + menuExportMarkdown: '导出为 Markdown…', menuExportImages: '导出为图片…', menuPrint: '打印…', menuEdit: '编辑', @@ -234,18 +308,21 @@ const tMain = createI18n({ menuPaste: '粘贴', menuPasteMatch: '粘贴并匹配格式', menuFindReplace: '查找和替换…', + menuGoTo: '定位…', menuSelectAll: '全选', menuView: '视图', + menuZoom: '缩放', menuZoomIn: '放大', menuZoomOut: '缩小', menuZoom100: '实际大小 (100%)', menuPageWidth: '页宽', menuWholePage: '整页', + menuMultiPage: '多页', menuAiSidebar: 'AI 侧栏', menuDarkMode: '深色模式', menuFullscreen: '进入全屏', menuInsert: '插入', - menuInsertTable: '表格(3×3)', + menuInsertTable: '表格…', menuInsertImage: '图片…', menuInsertPageBreak: '分页符', menuInsertLink: '超链接…', @@ -263,16 +340,57 @@ const tMain = createI18n({ menuFont: '字体…', menuParagraph: '段落…', menuTools: '工具', + menuTable: '表格', + menuTableInsert: '插入', + menuTableInsertTable: '表格…', + menuTableColsLeft: '在左侧插入列', + menuTableColsRight: '在右侧插入列', + menuTableRowsAbove: '在上方插入行', + menuTableRowsBelow: '在下方插入行', + menuTableCells: '单元格…', + menuTableDelete: '删除', + menuTableDeleteTable: '表格', + menuTableDeleteColumns: '列', + menuTableDeleteRows: '行', + menuTableSelect: '选择', + menuTableSelectCell: '单元格', + menuTableSelectColumn: '列', + menuTableSelectRow: '行', + menuTableSelectTable: '表格', + menuTableMergeCells: '合并单元格', + menuTableSplitCells: '拆分单元格…', + menuTableSplitTable: '拆分表格', + menuTableAutoFit: '自动调整', + menuTableAutoFitContents: '根据内容自动调整表格', + menuTableAutoFitWindow: '根据窗口自动调整表格', + menuTableFixedWidth: '固定列宽', + menuTableDistributeRows: '平均分布各行', + menuTableDistributeColumns: '平均分布各列', + menuTableRepeatHeader: '重复标题行', + menuTableGridlines: '查看网格线', + menuTableProperties: '表格属性…', menuWordCount: '字数统计…', + menuAutoCorrect: '自动更正选项…', + menuPreferences: '偏好设置…', menuAiProofread: 'AI 校对', menuWindow: '窗口', menuHelp: '帮助', menuShortcuts: '键盘快捷键', - menuDocsHelp: 'GenOffice Docs 帮助', }, en: { dlgOpenDoc: 'Open Document', filterWord: 'Word Documents', + odtImportMsg: 'Convert this .{ext} document?', + odtImportDetail: + 'HermesOffice edits .docx, not ODF. Body text, headings, lists, tables and images carry over; footnotes, comments and text boxes are not fully preserved. The original file is never changed; the converted .docx is a separate copy that later saves write to.', + hwpImportDetail: + 'HermesOffice edits .docx, not HWP. Body text, tables, images, headers and footers carry over; equations, footnotes and embedded objects are not converted, and automatic numbering becomes plain text. The original file is never changed; the converted .docx is a separate copy that later saves write to.', + odtImportConvertBtn: 'Convert and open', + importSkipConfirm: 'Convert without asking next time', + docImportMsg: 'Convert this .{ext} document?', + docImportDetail: + 'HermesOffice edits .docx, not Word 97-2003 documents. Body text, headings, lists, tables and images carry over; footnotes, comments and text boxes are not fully preserved. The original file is never changed; the converted .docx is a separate copy that later saves write to.', + docImportConvertBtn: 'Convert and open', dlgSaveAs: 'Save As', closeUnsavedMsg: 'This document has unsaved changes.', closeUnsavedDetail: 'Do you want to save them before closing?', @@ -280,6 +398,8 @@ const tMain = createI18n({ closeNoReplyDetail: 'Close anyway? Unsaved changes will be lost.', btnCloseAnyway: 'Close Anyway', autosaveFoundTitle: 'Recovered version found', + autosaveDiskNewer: + 'Note: the file on disk was modified after this recovery copy was saved. Restoring replaces those newer changes.', autosaveFoundBody: 'There are unsaved changes from your last session. Restore the autosaved version?', autosaveRestore: 'Restore', @@ -291,11 +411,14 @@ const tMain = createI18n({ btnOverwrite: 'Overwrite', dlgInsertImage: 'Insert Image', filterImages: 'Images', + dlgMergeDataSource: 'Select Recipient List', + filterMergeDataSource: 'Excel or CSV', dlgAddAttachment: 'Add Attachments', filterSupported: 'Supported Files', filterAll: 'All Files', dlgExportPdf: 'Export as PDF', dlgExportHtml: 'Export as HTML', + dlgExportMarkdown: 'Export as Markdown', dlgPickExportDir: 'Choose Export Folder', errUnsupportedExt: '.{ext} files are not supported', errNotFile: 'not a file', @@ -310,6 +433,8 @@ const tMain = createI18n({ errGskNotLoggedIn: 'Not signed in to Genspark: click “Sign in to Genspark” below, sign in, then retry', errNoApiKey: 'No API key configured for {provider}', + errAiRegion: + "This model's provider does not serve your current region — pick another model or change your network", errAiBusy: 'The AI service is busy right now — please try again in a moment', errNoModel: 'No model name configured', menuFile: 'File', @@ -324,6 +449,7 @@ const tMain = createI18n({ menuPageSetup: 'Page Setup…', menuExportPdf: 'Export as PDF…', menuExportHtml: 'Export as HTML…', + menuExportMarkdown: 'Export as Markdown…', menuExportImages: 'Export as Images…', menuPrint: 'Print…', menuEdit: 'Edit', @@ -334,18 +460,21 @@ const tMain = createI18n({ menuPaste: 'Paste', menuPasteMatch: 'Paste and Match Style', menuFindReplace: 'Find and Replace…', + menuGoTo: 'Go To…', menuSelectAll: 'Select All', menuView: 'View', + menuZoom: 'Zoom', menuZoomIn: 'Zoom In', menuZoomOut: 'Zoom Out', menuZoom100: 'Actual Size (100%)', menuPageWidth: 'Page Width', menuWholePage: 'Whole Page', + menuMultiPage: 'Multiple Pages', menuAiSidebar: 'AI Sidebar', menuDarkMode: 'Dark Mode', menuFullscreen: 'Enter Full Screen', menuInsert: 'Insert', - menuInsertTable: 'Table (3×3)', + menuInsertTable: 'Table…', menuInsertImage: 'Image…', menuInsertPageBreak: 'Page Break', menuInsertLink: 'Hyperlink…', @@ -363,16 +492,362 @@ const tMain = createI18n({ menuFont: 'Font…', menuParagraph: 'Paragraph…', menuTools: 'Tools', + menuTable: 'Table', + menuTableInsert: 'Insert', + menuTableInsertTable: 'Table…', + menuTableColsLeft: 'Columns to the Left', + menuTableColsRight: 'Columns to the Right', + menuTableRowsAbove: 'Rows Above', + menuTableRowsBelow: 'Rows Below', + menuTableCells: 'Cells…', + menuTableDelete: 'Delete', + menuTableDeleteTable: 'Table', + menuTableDeleteColumns: 'Columns', + menuTableDeleteRows: 'Rows', + menuTableSelect: 'Select', + menuTableSelectCell: 'Cell', + menuTableSelectColumn: 'Column', + menuTableSelectRow: 'Row', + menuTableSelectTable: 'Table', + menuTableMergeCells: 'Merge Cells', + menuTableSplitCells: 'Split Cells…', + menuTableSplitTable: 'Split Table', + menuTableAutoFit: 'AutoFit and Distribute', + menuTableAutoFitContents: 'AutoFit to Contents', + menuTableAutoFitWindow: 'AutoFit to Window', + menuTableFixedWidth: 'Fixed Column Width', + menuTableDistributeRows: 'Distribute Rows Evenly', + menuTableDistributeColumns: 'Distribute Columns Evenly', + menuTableRepeatHeader: 'Repeat Header Rows', + menuTableGridlines: 'View Gridlines', + menuTableProperties: 'Table Properties…', menuWordCount: 'Word Count…', + menuAutoCorrect: 'AutoCorrect Options…', + menuPreferences: 'Preferences…', menuAiProofread: 'AI Proofread', menuWindow: 'Window', menuHelp: 'Help', menuShortcuts: 'Keyboard Shortcuts', - menuDocsHelp: 'GenOffice Docs Help', + }, + vi: { + dlgOpenDoc: 'Mở tài liệu', + filterWord: 'Tài liệu Word', + odtImportMsg: 'Chuyển đổi tài liệu .{ext} này?', + odtImportDetail: + 'HermesOffice chỉnh sửa .docx chứ không chỉnh sửa ODF. Nội dung, tiêu đề, danh sách, bảng và hình ảnh được chuyển đổi; chú thích cuối trang, bình luận và hộp văn bản không được giữ đầy đủ. Tệp gốc không bao giờ bị sửa; bản .docx sau chuyển đổi là một bản sao độc lập để tiếp tục chỉnh sửa và lưu.', + hwpImportDetail: + 'HermesOffice chỉnh sửa .docx chứ không chỉnh sửa HWP. Nội dung, bảng, hình ảnh, đầu trang và chân trang được chuyển đổi; công thức, chú thích cuối trang và đối tượng nhúng không được chuyển đổi, còn đánh số tự động trở thành văn bản thường. Tệp gốc không bao giờ bị sửa; bản .docx sau chuyển đổi là một bản sao độc lập để tiếp tục chỉnh sửa và lưu.', + odtImportConvertBtn: 'Chuyển đổi và mở', + importSkipConfirm: 'Lần sau chuyển đổi ngay, không hỏi lại', + docImportMsg: 'Chuyển đổi tài liệu .{ext} này?', + docImportDetail: + 'HermesOffice chỉnh sửa .docx chứ không chỉnh sửa tài liệu Word 97-2003 (.doc). Nội dung, tiêu đề, danh sách, bảng và hình ảnh được chuyển đổi; chú thích cuối trang, bình luận và hộp văn bản không được giữ đầy đủ. Tệp gốc không bao giờ bị sửa; bản .docx sau chuyển đổi là một bản sao độc lập để tiếp tục chỉnh sửa và lưu.', + docImportConvertBtn: 'Chuyển đổi và mở', + dlgSaveAs: 'Lưu dưới dạng', + closeUnsavedMsg: 'Tài liệu này có những thay đổi chưa được lưu.', + closeUnsavedDetail: 'Bạn có muốn lưu các thay đổi trước khi đóng không?', + closeNoReplyMsg: 'Tài liệu không phản hồi và có thể có những thay đổi chưa được lưu.', + closeNoReplyDetail: 'Vẫn đóng? Các thay đổi chưa lưu sẽ bị mất.', + btnCloseAnyway: 'Vẫn đóng', + autosaveFoundTitle: 'Tìm thấy phiên bản khôi phục', + autosaveDiskNewer: + 'Lưu ý: tệp trên đĩa đã được sửa đổi sau khi bản sao khôi phục này được lưu. Khôi phục sẽ thay thế những thay đổi mới hơn đó.', + autosaveFoundBody: + 'Có những thay đổi chưa được lưu từ phiên làm việc trước của bạn. Khôi phục phiên bản đã lưu tự động?', + autosaveRestore: 'Khôi phục', + autosaveDiscard: 'Bỏ qua', + btnDontSave: 'Không lưu', + btnCancel: 'Hủy', + extModifiedMsg: 'Tệp đã được sửa đổi bởi một chương trình khác.', + extModifiedDetail: 'Vẫn lưu và ghi đè các thay đổi trên ổ đĩa?', + btnOverwrite: 'Ghi đè', + dlgInsertImage: 'Chèn hình ảnh', + filterImages: 'Hình ảnh', + dlgMergeDataSource: 'Chọn danh sách người nhận', + filterMergeDataSource: 'Excel hoặc CSV', + errAttachmentNotGranted: 'Tệp này không được cấp quyền làm tệp đính kèm nên không thể đọc', + dlgAddAttachment: 'Thêm tệp đính kèm', + filterSupported: 'Các tệp được hỗ trợ', + filterAll: 'Tất cả các tệp', + dlgExportPdf: 'Xuất dưới dạng PDF', + dlgExportHtml: 'Xuất dưới dạng HTML', + dlgExportMarkdown: 'Xuất dưới dạng Markdown', + dlgPickExportDir: 'Chọn thư mục xuất', + errUnsupportedExt: 'Tệp .{ext} không được hỗ trợ', + errNotFile: 'không phải là tệp', + errTooLarge: 'vượt quá giới hạn {mb}MB', + errImageTooLarge: 'hình ảnh vượt quá giới hạn 5MB', + errUnreadable: 'không thể đọc được', + errFileTooLarge: 'Tệp vượt quá giới hạn kích thước', + errParseFailed: 'Không thể phân tích tệp', + errImageNoText: + 'Tệp đính kèm hình ảnh không có văn bản; hình ảnh được gửi cùng với tin nhắn của người dùng', + errNotImage: 'loại hình ảnh không được hỗ trợ', + errGskNotLoggedIn: + 'Chưa đăng nhập vào Genspark: nhấp vào “Đăng nhập vào Genspark” bên dưới, đăng nhập, sau đó thử lại', + errNoApiKey: 'Chưa cấu hình khóa API cho {provider}', + errAiRegion: + 'Nhà cung cấp của mô hình này không phục vụ khu vực hiện tại của bạn — hãy chọn mô hình khác hoặc đổi mạng', + errAiBusy: 'Dịch vụ AI hiện đang bận — vui lòng thử lại sau giây lát', + errNoModel: 'Chưa cấu hình tên mô hình', + menuFile: 'Tệp', + menuNewDoc: 'Tài liệu mới', + menuNewWindow: 'Cửa sổ mới', + menuOpen: 'Mở…', + menuOpenRecent: 'Mở gần đây', + menuNoRecent: 'Không có tài liệu gần đây', + menuClose: 'Đóng', + menuSave: 'Lưu', + menuSaveAs: 'Lưu dưới dạng…', + menuPageSetup: 'Thiết lập trang…', + menuExportPdf: 'Xuất dưới dạng PDF…', + menuExportHtml: 'Xuất dưới dạng HTML…', + menuExportMarkdown: 'Xuất dưới dạng Markdown…', + menuExportImages: 'Xuất dưới dạng hình ảnh…', + menuPrint: 'In…', + menuEdit: 'Chỉnh sửa', + menuUndo: 'Hoàn tác', + menuRedo: 'Làm lại', + menuCut: 'Cắt', + menuCopy: 'Sao chép', + menuPaste: 'Dán', + menuPasteMatch: 'Dán và khớp kiểu định dạng', + menuFindReplace: 'Tìm kiếm và thay thế…', + menuGoTo: 'Đi tới…', + menuSelectAll: 'Chọn tất cả', + menuView: 'Xem', + menuZoom: 'Thu phóng', + menuZoomIn: 'Phóng to', + menuZoomOut: 'Thu nhỏ', + menuZoom100: 'Kích thước thực tế (100%)', + menuPageWidth: 'Chiều rộng trang', + menuWholePage: 'Toàn bộ trang', + menuMultiPage: 'Nhiều trang', + menuAiSidebar: 'Thanh bên AI', + menuDarkMode: 'Chế độ tối', + menuFullscreen: 'Vào chế độ toàn màn hình', + menuInsert: 'Chèn', + menuInsertTable: 'Bảng…', + menuInsertImage: 'Hình ảnh…', + menuInsertPageBreak: 'Ngắt trang', + menuInsertLink: 'Siêu liên kết…', + menuInsertEquation: 'Phương trình…', + menuComment: 'Bình luận', + menuFormat: 'Định dạng', + menuBold: 'In đậm', + menuItalic: 'In nghiêng', + menuUnderline: 'Gạch chân', + menuAlign: 'Căn chỉnh', + menuAlignLeft: 'Căn trái', + menuAlignCenter: 'Căn giữa', + menuAlignRight: 'Căn phải', + menuAlignJustify: 'Căn đều', + menuFont: 'Phông chữ…', + menuParagraph: 'Đoạn văn…', + menuTools: 'Công cụ', + menuTable: 'Bảng', + menuTableInsert: 'Chèn', + menuTableInsertTable: 'Bảng…', + menuTableColsLeft: 'Chèn cột bên trái', + menuTableColsRight: 'Chèn cột bên phải', + menuTableRowsAbove: 'Chèn hàng phía trên', + menuTableRowsBelow: 'Chèn hàng phía dưới', + menuTableCells: 'Ô…', + menuTableDelete: 'Xóa', + menuTableDeleteTable: 'Bảng', + menuTableDeleteColumns: 'Cột', + menuTableDeleteRows: 'Hàng', + menuTableSelect: 'Chọn', + menuTableSelectCell: 'Ô', + menuTableSelectColumn: 'Cột', + menuTableSelectRow: 'Hàng', + menuTableSelectTable: 'Bảng', + menuTableMergeCells: 'Hợp nhất các ô', + menuTableSplitCells: 'Tách ô…', + menuTableSplitTable: 'Tách bảng', + menuTableAutoFit: 'Tự động điều chỉnh', + menuTableAutoFitContents: 'Tự động điều chỉnh theo nội dung', + menuTableAutoFitWindow: 'Tự động điều chỉnh theo cửa sổ', + menuTableFixedWidth: 'Chiều rộng cột cố định', + menuTableDistributeRows: 'Phân bố hàng đều nhau', + menuTableDistributeColumns: 'Phân bố cột đều nhau', + menuTableRepeatHeader: 'Lặp lại hàng tiêu đề', + menuTableGridlines: 'Xem đường lưới', + menuTableProperties: 'Thuộc tính bảng…', + menuWordCount: 'Đếm từ…', + menuAutoCorrect: 'Tùy chọn tự sửa lỗi…', + menuPreferences: 'Tùy chọn…', + menuAiProofread: 'Hiệu đính bằng AI', + menuWindow: 'Cửa sổ', + menuHelp: 'Trợ giúp', + menuShortcuts: 'Phím tắt bàn phím', + }, + tr: { + dlgOpenDoc: 'Belge Aç', + filterWord: 'Word Belgeleri', + odtImportMsg: 'Bu .{ext} belgesi dönüştürülsün mü?', + odtImportDetail: + 'HermesOffice ODF değil .docx düzenler. Gövde metni, başlıklar, listeler, tablolar ve resimler aktarılır; dipnotlar, açıklamalar ve metin kutuları tam olarak korunmaz. Özgün dosya hiçbir zaman değiştirilmez; dönüştürülen .docx, sonraki kaydetmelerin yazıldığı ayrı bir kopyadır.', + hwpImportDetail: + 'HermesOffice HWP değil .docx düzenler. Gövde metni, tablolar, resimler, üst bilgiler ve alt bilgiler aktarılır; denklemler, dipnotlar ve gömülü nesneler dönüştürülmez, otomatik numaralandırma düz metne dönüşür. Özgün dosya hiçbir zaman değiştirilmez; dönüştürülen .docx, sonraki kaydetmelerin yazıldığı ayrı bir kopyadır.', + odtImportConvertBtn: 'Dönüştür ve aç', + importSkipConfirm: 'Bir dahaki sefere sormadan dönüştür', + docImportMsg: 'Bu .{ext} belgesi dönüştürülsün mü?', + docImportDetail: + 'HermesOffice Word 97-2003 (.doc) belgelerini değil .docx düzenler. Gövde metni, başlıklar, listeler, tablolar ve resimler aktarılır; dipnotlar, açıklamalar ve metin kutuları tam olarak korunmaz. Özgün dosya hiçbir zaman değiştirilmez; dönüştürülen .docx, sonraki kaydetmelerin yazıldığı ayrı bir kopyadır.', + docImportConvertBtn: 'Dönüştür ve aç', + dlgSaveAs: 'Farklı Kaydet', + closeUnsavedMsg: 'Bu belgede kaydedilmemiş değişiklikler var.', + closeUnsavedDetail: 'Kapatmadan önce kaydetmek ister misiniz?', + closeNoReplyMsg: 'Belge yanıt vermiyor ve kaydedilmemiş değişiklikler içeriyor olabilir.', + closeNoReplyDetail: 'Yine de kapatılsın mı? Kaydedilmemiş değişiklikler kaybolacak.', + btnCloseAnyway: 'Yine de Kapat', + autosaveFoundTitle: 'Kurtarılan sürüm bulundu', + autosaveDiskNewer: + 'Not: diskteki dosya bu kurtarma kopyası kaydedildikten sonra değiştirilmiş. Geri yüklemek bu daha yeni değişikliklerin yerini alır.', + autosaveFoundBody: + 'Son oturumunuzdan kaydedilmemiş değişiklikler var. Otomatik kaydedilen sürüm geri yüklensin mi?', + autosaveRestore: 'Geri Yükle', + autosaveDiscard: 'At', + btnDontSave: 'Kaydetme', + btnCancel: 'İptal', + extModifiedMsg: 'Dosya başka bir program tarafından değiştirildi.', + extModifiedDetail: 'Yine de kaydedilip diskteki değişikliklerin üzerine yazılsın mı?', + btnOverwrite: 'Üzerine Yaz', + dlgInsertImage: 'Resim Ekle', + filterImages: 'Resimler', + dlgMergeDataSource: 'Alıcı Listesi Seç', + filterMergeDataSource: 'Excel veya CSV', + errAttachmentNotGranted: 'Bu dosya ek olarak yetkilendirilmedi ve okunamıyor', + dlgAddAttachment: 'Ek Ekle', + filterSupported: 'Desteklenen Dosyalar', + filterAll: 'Tüm Dosyalar', + dlgExportPdf: 'PDF Olarak Dışa Aktar', + dlgExportHtml: 'HTML Olarak Dışa Aktar', + dlgExportMarkdown: 'Markdown Olarak Dışa Aktar', + dlgPickExportDir: 'Dışa Aktarma Klasörünü Seç', + errUnsupportedExt: '.{ext} dosyaları desteklenmiyor', + errNotFile: 'bir dosya değil', + errTooLarge: '{mb}MB sınırını aşıyor', + errImageTooLarge: 'resim 5MB sınırını aşıyor', + errUnreadable: 'okunamıyor', + errFileTooLarge: 'Dosya boyut sınırını aşıyor', + errParseFailed: 'Dosya ayrıştırılamadı', + errImageNoText: 'Resim eklerinde metin yok; resim kullanıcı iletisiyle birlikte gönderilir', + errNotImage: 'desteklenen bir resim türü değil', + errGskNotLoggedIn: + "Genspark'a giriş yapılmadı: aşağıdaki “Genspark'a giriş yap” düğmesine tıklayın, giriş yapın ve yeniden deneyin", + errNoApiKey: '{provider} için API anahtarı yapılandırılmamış', + errAiRegion: + 'Bu modelin sağlayıcısı bulunduğunuz bölgeye hizmet vermiyor — başka bir model seçin veya ağınızı değiştirin', + errAiBusy: 'AI hizmeti şu anda yoğun — lütfen biraz sonra yeniden deneyin', + errNoModel: 'Model adı yapılandırılmamış', + menuFile: 'Dosya', + menuNewDoc: 'Yeni Belge', + menuNewWindow: 'Yeni Pencere', + menuOpen: 'Aç…', + menuOpenRecent: 'Son Kullanılanları Aç', + menuNoRecent: 'Son Kullanılan Belge Yok', + menuClose: 'Kapat', + menuSave: 'Kaydet', + menuSaveAs: 'Farklı Kaydet…', + menuPageSetup: 'Sayfa Yapısı…', + menuExportPdf: 'PDF Olarak Dışa Aktar…', + menuExportHtml: 'HTML Olarak Dışa Aktar…', + menuExportMarkdown: 'Markdown Olarak Dışa Aktar…', + menuExportImages: 'Resim Olarak Dışa Aktar…', + menuPrint: 'Yazdır…', + menuEdit: 'Düzenle', + menuUndo: 'Geri Al', + menuRedo: 'Yinele', + menuCut: 'Kes', + menuCopy: 'Kopyala', + menuPaste: 'Yapıştır', + menuPasteMatch: 'Stili Eşleştirerek Yapıştır', + menuFindReplace: 'Bul ve Değiştir…', + menuGoTo: 'Git…', + menuSelectAll: 'Tümünü Seç', + menuView: 'Görünüm', + menuZoom: 'Yakınlaştır', + menuZoomIn: 'Yakınlaştır', + menuZoomOut: 'Uzaklaştır', + menuZoom100: 'Gerçek Boyut (%100)', + menuPageWidth: 'Sayfa Genişliği', + menuWholePage: 'Tüm Sayfa', + menuMultiPage: 'Birden Çok Sayfa', + menuAiSidebar: 'AI Kenar Çubuğu', + menuDarkMode: 'Koyu Mod', + menuFullscreen: 'Tam Ekrana Geç', + menuInsert: 'Ekle', + menuInsertTable: 'Tablo…', + menuInsertImage: 'Resim…', + menuInsertPageBreak: 'Sayfa Sonu', + menuInsertLink: 'Köprü…', + menuInsertEquation: 'Denklem…', + menuComment: 'Açıklama', + menuFormat: 'Biçim', + menuBold: 'Kalın', + menuItalic: 'İtalik', + menuUnderline: 'Altı Çizili', + menuAlign: 'Hizala', + menuAlignLeft: 'Sola Hizala', + menuAlignCenter: 'Ortala', + menuAlignRight: 'Sağa Hizala', + menuAlignJustify: 'İki Yana Yasla', + menuFont: 'Yazı Tipi…', + menuParagraph: 'Paragraf…', + menuTools: 'Araçlar', + menuTable: 'Tablo', + menuTableInsert: 'Ekle', + menuTableInsertTable: 'Tablo…', + menuTableColsLeft: 'Sola Sütun', + menuTableColsRight: 'Sağa Sütun', + menuTableRowsAbove: 'Üste Satır', + menuTableRowsBelow: 'Alta Satır', + menuTableCells: 'Hücreler…', + menuTableDelete: 'Sil', + menuTableDeleteTable: 'Tablo', + menuTableDeleteColumns: 'Sütunlar', + menuTableDeleteRows: 'Satırlar', + menuTableSelect: 'Seç', + menuTableSelectCell: 'Hücre', + menuTableSelectColumn: 'Sütun', + menuTableSelectRow: 'Satır', + menuTableSelectTable: 'Tablo', + menuTableMergeCells: 'Hücreleri Birleştir', + menuTableSplitCells: 'Hücreleri Böl…', + menuTableSplitTable: 'Tabloyu Böl', + menuTableAutoFit: 'Otomatik Sığdır ve Dağıt', + menuTableAutoFitContents: 'İçeriğe Otomatik Sığdır', + menuTableAutoFitWindow: 'Pencereye Otomatik Sığdır', + menuTableFixedWidth: 'Sabit Sütun Genişliği', + menuTableDistributeRows: 'Satırları Eşit Dağıt', + menuTableDistributeColumns: 'Sütunları Eşit Dağıt', + menuTableRepeatHeader: 'Başlık Satırlarını Yinele', + menuTableGridlines: 'Kılavuz Çizgilerini Göster', + menuTableProperties: 'Tablo Özellikleri…', + menuWordCount: 'Sözcük Sayısı…', + menuAutoCorrect: 'Otomatik Düzelt Seçenekleri…', + menuPreferences: 'Tercihler…', + menuAiProofread: 'AI Düzeltme Okuması', + menuWindow: 'Pencere', + menuHelp: 'Yardım', + menuShortcuts: 'Klavye Kısayolları', }, ja: { dlgOpenDoc: '文書を開く', filterWord: 'Word 文書', + odtImportMsg: 'この .{ext} 文書を変換しますか?', + odtImportDetail: + 'HermesOffice は ODF ではなく .docx を編集します。本文・見出し・リスト・表・画像は変換されますが、脚注・コメント・テキストボックスは完全には保持されません。元のファイルは変更されず、変換後の .docx は独立したコピーとして以後の保存先になります。', + hwpImportDetail: + 'HermesOffice は HWP ではなく .docx を編集します。本文・表・画像・ヘッダー・フッターは変換されますが、数式・脚注・埋め込みオブジェクトは変換されず、自動番号は通常のテキストになります。元のファイルは変更されず、変換後の .docx は独立したコピーとして以後の保存先になります。', + odtImportConvertBtn: '変換して開く', + importSkipConfirm: '次回から確認せずに変換する', + docImportMsg: 'この .{ext} 文書を変換しますか?', + docImportDetail: + 'HermesOffice は Word 97-2003 形式(.doc)ではなく .docx を編集します。本文・見出し・リスト・表・画像は変換されますが、脚注・コメント・テキストボックスは完全には保持されません。元のファイルは変更されず、変換後の .docx は独立したコピーとして以後の保存先になります。', + docImportConvertBtn: '変換して開く', dlgSaveAs: '名前を付けて保存', closeUnsavedMsg: 'このドキュメントに未保存の変更があります。', closeUnsavedDetail: '閉じる前に保存しますか?', @@ -380,6 +855,8 @@ const tMain = createI18n({ closeNoReplyDetail: 'それでも閉じますか?未保存の変更は失われます。', btnCloseAnyway: '閉じる', autosaveFoundTitle: '自動回復バージョンがあります', + autosaveDiskNewer: + '注意: この回復コピーの保存後にディスク上のファイルが変更されています。復元するとその新しい変更は置き換えられます。', autosaveFoundBody: '前回のセッションに未保存の変更があります。自動保存版を復元しますか?', autosaveRestore: '復元', autosaveDiscard: '破棄', @@ -390,11 +867,14 @@ const tMain = createI18n({ btnOverwrite: '上書き', dlgInsertImage: '画像の挿入', filterImages: '画像', + dlgMergeDataSource: '受信者リストを選択', + filterMergeDataSource: 'Excel または CSV', dlgAddAttachment: '添付ファイルの追加', filterSupported: 'サポートされているファイル', filterAll: 'すべてのファイル', dlgExportPdf: 'PDF としてエクスポート', dlgExportHtml: 'HTML としてエクスポート', + dlgExportMarkdown: 'Markdown としてエクスポート', dlgPickExportDir: 'エクスポート先フォルダーの選択', errUnsupportedExt: '.{ext} 形式には対応していません', errNotFile: 'ファイルではありません', @@ -410,6 +890,8 @@ const tMain = createI18n({ errGskNotLoggedIn: 'Genspark にサインインしていません。下の「Genspark にサインイン」からサインインして再試行してください', errNoApiKey: '{provider} の API キーが設定されていません', + errAiRegion: + 'このモデルの提供元は現在の地域ではサービスを提供していません。別のモデルを選ぶか、ネットワークを切り替えてください', errAiBusy: 'AI サービスが混み合っています。しばらくしてからもう一度お試しください', errNoModel: 'モデル名が設定されていません', menuFile: 'ファイル', @@ -424,6 +906,7 @@ const tMain = createI18n({ menuPageSetup: 'ページ設定…', menuExportPdf: 'PDF としてエクスポート…', menuExportHtml: 'HTML としてエクスポート…', + menuExportMarkdown: 'Markdown としてエクスポート…', menuExportImages: '画像としてエクスポート…', menuPrint: '印刷…', menuEdit: '編集', @@ -434,18 +917,21 @@ const tMain = createI18n({ menuPaste: '貼り付け', menuPasteMatch: '貼り付けて書式を合わせる', menuFindReplace: '検索と置換…', + menuGoTo: 'ジャンプ…', menuSelectAll: 'すべて選択', menuView: '表示', + menuZoom: 'ズーム', menuZoomIn: '拡大', menuZoomOut: '縮小', menuZoom100: '実際のサイズ (100%)', menuPageWidth: 'ページ幅', menuWholePage: 'ページ全体', + menuMultiPage: '複数ページ', menuAiSidebar: 'AI サイドバー', menuDarkMode: 'ダークモード', menuFullscreen: 'フルスクリーンにする', menuInsert: '挿入', - menuInsertTable: '表 (3×3)', + menuInsertTable: '表…', menuInsertImage: '画像…', menuInsertPageBreak: '改ページ', menuInsertLink: 'ハイパーリンク…', @@ -463,16 +949,57 @@ const tMain = createI18n({ menuFont: 'フォント…', menuParagraph: '段落…', menuTools: 'ツール', + menuTable: '表', + menuTableInsert: '挿入', + menuTableInsertTable: '表…', + menuTableColsLeft: '左に列', + menuTableColsRight: '右に列', + menuTableRowsAbove: '上に行', + menuTableRowsBelow: '下に行', + menuTableCells: 'セル…', + menuTableDelete: '削除', + menuTableDeleteTable: '表', + menuTableDeleteColumns: '列', + menuTableDeleteRows: '行', + menuTableSelect: '選択', + menuTableSelectCell: 'セル', + menuTableSelectColumn: '列', + menuTableSelectRow: '行', + menuTableSelectTable: '表', + menuTableMergeCells: 'セルの結合', + menuTableSplitCells: 'セルの分割…', + menuTableSplitTable: '表の分割', + menuTableAutoFit: '自動調整', + menuTableAutoFitContents: '文字列の幅に自動調整', + menuTableAutoFitWindow: 'ウィンドウ幅に自動調整', + menuTableFixedWidth: '列の幅を固定する', + menuTableDistributeRows: '行の高さを揃える', + menuTableDistributeColumns: '列の幅を揃える', + menuTableRepeatHeader: 'タイトル行の繰り返し', + menuTableGridlines: 'グリッド線の表示', + menuTableProperties: '表のプロパティ…', menuWordCount: '文字カウント…', + menuAutoCorrect: 'オートコレクトのオプション…', + menuPreferences: '環境設定…', menuAiProofread: 'AI 校正', menuWindow: 'ウィンドウ', menuHelp: 'ヘルプ', menuShortcuts: 'キーボードショートカット', - menuDocsHelp: 'GenOffice Docs ヘルプ', }, ko: { dlgOpenDoc: '문서 열기', filterWord: 'Word 문서', + odtImportMsg: '이 .{ext} 문서를 변환할까요?', + odtImportDetail: + 'HermesOffice은 ODF가 아니라 .docx를 편집합니다. 본문, 제목, 목록, 표, 이미지는 변환되지만 각주, 메모, 텍스트 상자는 완전히 유지되지 않습니다. 원본 파일은 변경되지 않으며, 변환된 .docx는 이후 저장이 기록되는 독립적인 사본입니다.', + hwpImportDetail: + 'HermesOffice은 HWP가 아니라 .docx를 편집합니다. 본문, 표, 그림, 머리말, 꼬리말은 변환되지만 수식, 각주, 삽입 개체는 변환되지 않으며 자동 번호는 일반 텍스트로 바뀝니다. 원본 파일은 변경되지 않으며, 변환된 .docx는 이후 저장이 기록되는 독립적인 사본입니다.', + odtImportConvertBtn: '변환 후 열기', + importSkipConfirm: '다음부터 묻지 않고 변환', + docImportMsg: '이 .{ext} 문서를 변환할까요?', + docImportDetail: + 'HermesOffice은 Word 97-2003(.doc) 문서가 아니라 .docx를 편집합니다. 본문, 제목, 목록, 표, 이미지는 변환되지만 각주, 메모, 텍스트 상자는 완전히 유지되지 않습니다. 원본 파일은 변경되지 않으며, 변환된 .docx는 이후 저장이 기록되는 독립적인 사본입니다.', + docImportConvertBtn: '변환 후 열기', dlgSaveAs: '다른 이름으로 저장', closeUnsavedMsg: '이 문서에 저장하지 않은 변경 사항이 있습니다.', closeUnsavedDetail: '닫기 전에 저장하시겠습니까?', @@ -480,6 +1007,8 @@ const tMain = createI18n({ closeNoReplyDetail: '그래도 닫을까요? 저장하지 않은 변경 사항은 사라집니다.', btnCloseAnyway: '닫기', autosaveFoundTitle: '자동 복구 버전 발견', + autosaveDiskNewer: + '참고: 이 복구 사본이 저장된 후 디스크의 파일이 수정되었습니다. 복원하면 그 최신 변경 내용이 대체됩니다.', autosaveFoundBody: '마지막 세션에 저장되지 않은 변경 내용이 있습니다. 자동 저장 버전을 복원할까요?', autosaveRestore: '복원', @@ -491,11 +1020,14 @@ const tMain = createI18n({ btnOverwrite: '덮어쓰기', dlgInsertImage: '그림 삽입', filterImages: '그림', + dlgMergeDataSource: '받는 사람 목록 선택', + filterMergeDataSource: 'Excel 또는 CSV', dlgAddAttachment: '첨부 파일 추가', filterSupported: '지원되는 파일', filterAll: '모든 파일', dlgExportPdf: 'PDF로 내보내기', dlgExportHtml: 'HTML로 내보내기', + dlgExportMarkdown: 'Markdown로 내보내기', dlgPickExportDir: '내보낼 폴더 선택', errUnsupportedExt: '.{ext} 형식은 지원되지 않습니다', errNotFile: '파일이 아닙니다', @@ -511,6 +1043,8 @@ const tMain = createI18n({ errGskNotLoggedIn: 'Genspark에 로그인되어 있지 않습니다. 아래 "Genspark 로그인"을 눌러 로그인한 뒤 다시 시도하세요', errNoApiKey: '{provider}의 API 키가 설정되지 않았습니다', + errAiRegion: + '이 모델의 제공업체는 현재 지역에서 서비스를 제공하지 않습니다. 다른 모델을 선택하거나 네트워크를 변경하세요', errAiBusy: 'AI 서비스가 혼잡합니다. 잠시 후 다시 시도해 주세요', errNoModel: '모델 이름이 설정되지 않았습니다', menuFile: '파일', @@ -525,6 +1059,7 @@ const tMain = createI18n({ menuPageSetup: '페이지 설정…', menuExportPdf: 'PDF로 내보내기…', menuExportHtml: 'HTML로 내보내기…', + menuExportMarkdown: 'Markdown로 내보내기…', menuExportImages: '이미지로 내보내기…', menuPrint: '인쇄…', menuEdit: '편집', @@ -535,18 +1070,21 @@ const tMain = createI18n({ menuPaste: '붙여넣기', menuPasteMatch: '서식 맞춰 붙여넣기', menuFindReplace: '찾기 및 바꾸기…', + menuGoTo: '이동…', menuSelectAll: '모두 선택', menuView: '보기', + menuZoom: '확대/축소', menuZoomIn: '확대', menuZoomOut: '축소', menuZoom100: '실제 크기(100%)', menuPageWidth: '페이지 너비', menuWholePage: '전체 페이지', + menuMultiPage: '여러 페이지', menuAiSidebar: 'AI 사이드바', menuDarkMode: '다크 모드', menuFullscreen: '전체 화면 시작', menuInsert: '삽입', - menuInsertTable: '표(3×3)', + menuInsertTable: '표…', menuInsertImage: '그림…', menuInsertPageBreak: '페이지 나누기', menuInsertLink: '하이퍼링크…', @@ -564,16 +1102,57 @@ const tMain = createI18n({ menuFont: '글꼴…', menuParagraph: '단락…', menuTools: '도구', + menuTable: '표', + menuTableInsert: '삽입', + menuTableInsertTable: '표…', + menuTableColsLeft: '왼쪽에 열', + menuTableColsRight: '오른쪽에 열', + menuTableRowsAbove: '위에 행', + menuTableRowsBelow: '아래에 행', + menuTableCells: '셀…', + menuTableDelete: '삭제', + menuTableDeleteTable: '표', + menuTableDeleteColumns: '열', + menuTableDeleteRows: '행', + menuTableSelect: '선택', + menuTableSelectCell: '셀', + menuTableSelectColumn: '열', + menuTableSelectRow: '행', + menuTableSelectTable: '표', + menuTableMergeCells: '셀 병합', + menuTableSplitCells: '셀 분할…', + menuTableSplitTable: '표 분할', + menuTableAutoFit: '자동 맞춤 및 분배', + menuTableAutoFitContents: '내용에 자동 맞춤', + menuTableAutoFitWindow: '창에 자동 맞춤', + menuTableFixedWidth: '열 너비 고정', + menuTableDistributeRows: '행 균등 분배', + menuTableDistributeColumns: '열 균등 분배', + menuTableRepeatHeader: '머리글 행 반복', + menuTableGridlines: '눈금선 보기', + menuTableProperties: '표 속성…', menuWordCount: '단어 개수…', + menuAutoCorrect: '자동 고침 옵션…', + menuPreferences: '기본 설정…', menuAiProofread: 'AI 교정', menuWindow: '창', menuHelp: '도움말', menuShortcuts: '키보드 바로 가기', - menuDocsHelp: 'GenOffice Docs 도움말', }, fr: { dlgOpenDoc: 'Ouvrir un document', filterWord: 'Documents Word', + odtImportMsg: 'Convertir ce document .{ext} ?', + odtImportDetail: + 'HermesOffice modifie des .docx, pas de l’ODF. Le texte, les titres, les listes, les tableaux et les images sont convertis ; les notes de bas de page, les commentaires et les zones de texte ne sont pas entièrement conservés. Le fichier d’origine n’est jamais modifié ; le .docx converti est une copie distincte où s’effectuent les enregistrements suivants.', + hwpImportDetail: + 'HermesOffice modifie des .docx, pas des fichiers HWP. Le texte, les tableaux, les images, les en-têtes et les pieds de page sont convertis ; les équations, les notes de bas de page et les objets incorporés ne le sont pas, et la numérotation automatique devient du texte simple. Le fichier d’origine n’est jamais modifié ; le .docx converti est une copie distincte où s’effectuent les enregistrements suivants.', + odtImportConvertBtn: 'Convertir et ouvrir', + importSkipConfirm: 'La prochaine fois, convertir sans demander', + docImportMsg: 'Convertir ce document .{ext} ?', + docImportDetail: + 'HermesOffice modifie des .docx, pas de documents Word 97-2003 (.doc). Le texte, les titres, les listes, les tableaux et les images sont convertis ; les notes de bas de page, les commentaires et les zones de texte ne sont pas entièrement conservés. Le fichier d’origine n’est jamais modifié ; le .docx converti est une copie distincte où s’effectuent les enregistrements suivants.', + docImportConvertBtn: 'Convertir et ouvrir', dlgSaveAs: 'Enregistrer sous', closeUnsavedMsg: 'Ce document contient des modifications non enregistrées.', closeUnsavedDetail: 'Voulez-vous les enregistrer avant de fermer ?', @@ -582,6 +1161,8 @@ const tMain = createI18n({ closeNoReplyDetail: 'Fermer quand même ? Les modifications non enregistrées seront perdues.', btnCloseAnyway: 'Fermer quand même', autosaveFoundTitle: 'Version récupérée trouvée', + autosaveDiskNewer: + 'Remarque : le fichier sur le disque a été modifié après l’enregistrement de cette copie de récupération. La restauration remplacera ces modifications plus récentes.', autosaveFoundBody: 'Des modifications non enregistrées existent. Restaurer la version auto-enregistrée ?', autosaveRestore: 'Restaurer', @@ -593,11 +1174,14 @@ const tMain = createI18n({ btnOverwrite: 'Écraser', dlgInsertImage: 'Insérer une image', filterImages: 'Images', + dlgMergeDataSource: 'Sélectionner une liste de destinataires', + filterMergeDataSource: 'Excel ou CSV', dlgAddAttachment: 'Ajouter des pièces jointes', filterSupported: 'Fichiers pris en charge', filterAll: 'Tous les fichiers', dlgExportPdf: 'Exporter au format PDF', dlgExportHtml: 'Exporter au format HTML', + dlgExportMarkdown: 'Exporter au format Markdown', dlgPickExportDir: "Choisir le dossier d'exportation", errUnsupportedExt: 'les fichiers .{ext} ne sont pas pris en charge', errNotFile: "n'est pas un fichier", @@ -614,6 +1198,8 @@ const tMain = createI18n({ errGskNotLoggedIn: 'Non connecté à Genspark : cliquez sur « Se connecter à Genspark » ci-dessous, connectez-vous puis réessayez', errNoApiKey: 'Aucune clé API configurée pour {provider}', + errAiRegion: + 'Le fournisseur de ce modèle ne dessert pas votre région actuelle — choisissez un autre modèle ou changez de réseau', errAiBusy: "Le service d'IA est actuellement surchargé — réessayez dans un instant", errNoModel: 'Aucun nom de modèle configuré', menuFile: 'Fichier', @@ -628,6 +1214,7 @@ const tMain = createI18n({ menuPageSetup: 'Mise en page…', menuExportPdf: 'Exporter au format PDF…', menuExportHtml: 'Exporter au format HTML…', + menuExportMarkdown: 'Exporter au format Markdown…', menuExportImages: 'Exporter en images…', menuPrint: 'Imprimer…', menuEdit: 'Édition', @@ -638,18 +1225,21 @@ const tMain = createI18n({ menuPaste: 'Coller', menuPasteMatch: 'Coller et adapter le style', menuFindReplace: 'Rechercher et remplacer…', + menuGoTo: 'Atteindre…', menuSelectAll: 'Tout sélectionner', menuView: 'Affichage', + menuZoom: 'Zoom', menuZoomIn: 'Zoom avant', menuZoomOut: 'Zoom arrière', menuZoom100: 'Taille réelle (100 %)', menuPageWidth: 'Largeur de page', menuWholePage: 'Page entière', + menuMultiPage: 'Plusieurs pages', menuAiSidebar: 'Volet IA', menuDarkMode: 'Mode sombre', menuFullscreen: 'Activer le mode plein écran', menuInsert: 'Insertion', - menuInsertTable: 'Tableau (3×3)', + menuInsertTable: 'Tableau…', menuInsertImage: 'Image…', menuInsertPageBreak: 'Saut de page', menuInsertLink: 'Lien hypertexte…', @@ -667,16 +1257,57 @@ const tMain = createI18n({ menuFont: 'Police…', menuParagraph: 'Paragraphe…', menuTools: 'Outils', + menuTable: 'Tableau', + menuTableInsert: 'Insérer', + menuTableInsertTable: 'Tableau…', + menuTableColsLeft: 'Colonnes à gauche', + menuTableColsRight: 'Colonnes à droite', + menuTableRowsAbove: 'Lignes au-dessus', + menuTableRowsBelow: 'Lignes en dessous', + menuTableCells: 'Cellules…', + menuTableDelete: 'Supprimer', + menuTableDeleteTable: 'Tableau', + menuTableDeleteColumns: 'Colonnes', + menuTableDeleteRows: 'Lignes', + menuTableSelect: 'Sélectionner', + menuTableSelectCell: 'Cellule', + menuTableSelectColumn: 'Colonne', + menuTableSelectRow: 'Ligne', + menuTableSelectTable: 'Tableau', + menuTableMergeCells: 'Fusionner les cellules', + menuTableSplitCells: 'Fractionner les cellules…', + menuTableSplitTable: 'Fractionner le tableau', + menuTableAutoFit: 'Ajustement automatique', + menuTableAutoFitContents: 'Ajuster au contenu', + menuTableAutoFitWindow: 'Ajuster à la fenêtre', + menuTableFixedWidth: 'Largeur de colonne fixe', + menuTableDistributeRows: 'Uniformiser la hauteur des lignes', + menuTableDistributeColumns: 'Uniformiser la largeur des colonnes', + menuTableRepeatHeader: 'Répéter les lignes d’en-tête', + menuTableGridlines: 'Afficher le quadrillage', + menuTableProperties: 'Propriétés du tableau…', menuWordCount: 'Statistiques…', + menuAutoCorrect: 'Options de correction automatique…', + menuPreferences: 'Préférences…', menuAiProofread: 'Relecture IA', menuWindow: 'Fenêtre', menuHelp: 'Aide', menuShortcuts: 'Raccourcis clavier', - menuDocsHelp: 'Aide GenOffice Docs', }, de: { dlgOpenDoc: 'Dokument öffnen', filterWord: 'Word-Dokumente', + odtImportMsg: 'Dieses .{ext}-Dokument konvertieren?', + odtImportDetail: + 'HermesOffice bearbeitet .docx, nicht ODF. Text, Überschriften, Listen, Tabellen und Bilder werden übernommen; Fußnoten, Kommentare und Textfelder werden nicht vollständig bewahrt. Die Originaldatei bleibt unverändert; die konvertierte .docx-Kopie ist eigenständig, und spätere Speicherungen landen in ihr.', + hwpImportDetail: + 'HermesOffice bearbeitet .docx, nicht HWP. Text, Tabellen, Bilder sowie Kopf- und Fußzeilen werden übernommen; Formeln, Fußnoten und eingebettete Objekte werden nicht konvertiert, und automatische Nummerierung wird zu normalem Text. Die Originaldatei bleibt unverändert; die konvertierte .docx-Kopie ist eigenständig, und spätere Speicherungen landen in ihr.', + odtImportConvertBtn: 'Konvertieren und öffnen', + importSkipConfirm: 'Nächstes Mal ohne Nachfrage konvertieren', + docImportMsg: 'Dieses .{ext}-Dokument konvertieren?', + docImportDetail: + 'HermesOffice bearbeitet .docx, nicht Word 97-2003-Dokumente (.doc). Text, Überschriften, Listen, Tabellen und Bilder werden übernommen; Fußnoten, Kommentare und Textfelder werden nicht vollständig bewahrt. Die Originaldatei bleibt unverändert; die konvertierte .docx-Kopie ist eigenständig, und spätere Speicherungen landen in ihr.', + docImportConvertBtn: 'Konvertieren und öffnen', dlgSaveAs: 'Speichern unter', closeUnsavedMsg: 'Dieses Dokument enthält ungespeicherte Änderungen.', closeUnsavedDetail: 'Vor dem Schließen speichern?', @@ -685,6 +1316,8 @@ const tMain = createI18n({ closeNoReplyDetail: 'Trotzdem schließen? Ungespeicherte Änderungen gehen verloren.', btnCloseAnyway: 'Trotzdem schließen', autosaveFoundTitle: 'Wiederhergestellte Version gefunden', + autosaveDiskNewer: + 'Hinweis: Die Datei auf dem Datenträger wurde nach dem Speichern dieser Wiederherstellungskopie geändert. Beim Wiederherstellen werden diese neueren Änderungen ersetzt.', autosaveFoundBody: 'Es gibt ungespeicherte Änderungen. Automatisch gespeicherte Version wiederherstellen?', autosaveRestore: 'Wiederherstellen', @@ -696,11 +1329,14 @@ const tMain = createI18n({ btnOverwrite: 'Überschreiben', dlgInsertImage: 'Bild einfügen', filterImages: 'Bilder', + dlgMergeDataSource: 'Empfängerliste auswählen', + filterMergeDataSource: 'Excel oder CSV', dlgAddAttachment: 'Anlagen hinzufügen', filterSupported: 'Unterstützte Dateien', filterAll: 'Alle Dateien', dlgExportPdf: 'Als PDF exportieren', dlgExportHtml: 'Als HTML exportieren', + dlgExportMarkdown: 'Als Markdown exportieren', dlgPickExportDir: 'Exportordner auswählen', errUnsupportedExt: '.{ext}-Dateien werden nicht unterstützt', errNotFile: 'keine Datei', @@ -717,6 +1353,8 @@ const tMain = createI18n({ errGskNotLoggedIn: 'Nicht bei Genspark angemeldet: Klicken Sie unten auf „Bei Genspark anmelden“, melden Sie sich an und versuchen Sie es erneut', errNoApiKey: 'Kein API-Schlüssel für {provider} konfiguriert', + errAiRegion: + 'Der Anbieter dieses Modells bedient Ihre aktuelle Region nicht — wählen Sie ein anderes Modell oder wechseln Sie das Netzwerk', errAiBusy: 'Der KI-Dienst ist derzeit überlastet — bitte gleich erneut versuchen', errNoModel: 'Kein Modellname konfiguriert', menuFile: 'Datei', @@ -731,6 +1369,7 @@ const tMain = createI18n({ menuPageSetup: 'Seite einrichten…', menuExportPdf: 'Als PDF exportieren…', menuExportHtml: 'Als HTML exportieren…', + menuExportMarkdown: 'Als Markdown exportieren…', menuExportImages: 'Als Bilder exportieren…', menuPrint: 'Drucken…', menuEdit: 'Bearbeiten', @@ -741,18 +1380,21 @@ const tMain = createI18n({ menuPaste: 'Einfügen', menuPasteMatch: 'Einfügen und Stil anpassen', menuFindReplace: 'Suchen und Ersetzen…', + menuGoTo: 'Gehe zu…', menuSelectAll: 'Alles auswählen', menuView: 'Ansicht', + menuZoom: 'Zoom', menuZoomIn: 'Vergrößern', menuZoomOut: 'Verkleinern', menuZoom100: 'Originalgröße (100 %)', menuPageWidth: 'Seitenbreite', menuWholePage: 'Ganze Seite', + menuMultiPage: 'Mehrere Seiten', menuAiSidebar: 'KI-Seitenleiste', menuDarkMode: 'Dunkelmodus', menuFullscreen: 'Vollbild ein', menuInsert: 'Einfügen', - menuInsertTable: 'Tabelle (3×3)', + menuInsertTable: 'Tabelle…', menuInsertImage: 'Bild…', menuInsertPageBreak: 'Seitenumbruch', menuInsertLink: 'Hyperlink…', @@ -770,16 +1412,57 @@ const tMain = createI18n({ menuFont: 'Schriftart…', menuParagraph: 'Absatz…', menuTools: 'Extras', + menuTable: 'Tabelle', + menuTableInsert: 'Einfügen', + menuTableInsertTable: 'Tabelle…', + menuTableColsLeft: 'Spalten links', + menuTableColsRight: 'Spalten rechts', + menuTableRowsAbove: 'Zeilen oberhalb', + menuTableRowsBelow: 'Zeilen unterhalb', + menuTableCells: 'Zellen…', + menuTableDelete: 'Löschen', + menuTableDeleteTable: 'Tabelle', + menuTableDeleteColumns: 'Spalten', + menuTableDeleteRows: 'Zeilen', + menuTableSelect: 'Auswählen', + menuTableSelectCell: 'Zelle', + menuTableSelectColumn: 'Spalte', + menuTableSelectRow: 'Zeile', + menuTableSelectTable: 'Tabelle', + menuTableMergeCells: 'Zellen verbinden', + menuTableSplitCells: 'Zellen teilen…', + menuTableSplitTable: 'Tabelle teilen', + menuTableAutoFit: 'AutoAnpassen und Verteilen', + menuTableAutoFitContents: 'An Inhalt anpassen', + menuTableAutoFitWindow: 'An Fenster anpassen', + menuTableFixedWidth: 'Feste Spaltenbreite', + menuTableDistributeRows: 'Zeilen gleichmäßig verteilen', + menuTableDistributeColumns: 'Spalten gleichmäßig verteilen', + menuTableRepeatHeader: 'Überschriftenzeilen wiederholen', + menuTableGridlines: 'Gitternetzlinien anzeigen', + menuTableProperties: 'Tabelleneigenschaften…', menuWordCount: 'Wörter zählen…', + menuAutoCorrect: 'AutoKorrektur-Optionen…', + menuPreferences: 'Einstellungen…', menuAiProofread: 'KI-Korrektur', menuWindow: 'Fenster', menuHelp: 'Hilfe', menuShortcuts: 'Tastenkombinationen', - menuDocsHelp: 'GenOffice Docs-Hilfe', }, es: { dlgOpenDoc: 'Abrir documento', filterWord: 'Documentos de Word', + odtImportMsg: '¿Convertir este documento .{ext}?', + odtImportDetail: + 'HermesOffice edita .docx, no ODF. El texto, los títulos, las listas, las tablas y las imágenes se convierten; las notas al pie, los comentarios y los cuadros de texto no se conservan por completo. El archivo original nunca se modifica; el .docx convertido es una copia independiente en la que se guardan los cambios posteriores.', + hwpImportDetail: + 'HermesOffice edita .docx, no HWP. El texto, las tablas, las imágenes, los encabezados y los pies de página se convierten; las ecuaciones, las notas al pie y los objetos incrustados no, y la numeración automática pasa a ser texto normal. El archivo original nunca se modifica; el .docx convertido es una copia independiente en la que se guardan los cambios posteriores.', + odtImportConvertBtn: 'Convertir y abrir', + importSkipConfirm: 'La próxima vez, convertir sin preguntar', + docImportMsg: '¿Convertir este documento .{ext}?', + docImportDetail: + 'HermesOffice edita .docx, no documentos de Word 97-2003 (.doc). El texto, los títulos, las listas, las tablas y las imágenes se convierten; las notas al pie, los comentarios y los cuadros de texto no se conservan por completo. El archivo original nunca se modifica; el .docx convertido es una copia independiente en la que se guardan los cambios posteriores.', + docImportConvertBtn: 'Convertir y abrir', dlgSaveAs: 'Guardar como', closeUnsavedMsg: 'Este documento tiene cambios sin guardar.', closeUnsavedDetail: '¿Desea guardarlos antes de cerrar?', @@ -787,6 +1470,8 @@ const tMain = createI18n({ closeNoReplyDetail: '¿Cerrar de todos modos? Los cambios sin guardar se perderán.', btnCloseAnyway: 'Cerrar de todos modos', autosaveFoundTitle: 'Se encontró una versión recuperada', + autosaveDiskNewer: + 'Nota: el archivo en disco se modificó después de guardar esta copia de recuperación. Restaurar reemplazará esos cambios más recientes.', autosaveFoundBody: 'Hay cambios sin guardar de la última sesión. ¿Restaurar la versión autoguardada?', autosaveRestore: 'Restaurar', @@ -798,11 +1483,14 @@ const tMain = createI18n({ btnOverwrite: 'Sobrescribir', dlgInsertImage: 'Insertar imagen', filterImages: 'Imágenes', + dlgMergeDataSource: 'Seleccionar lista de destinatarios', + filterMergeDataSource: 'Excel o CSV', dlgAddAttachment: 'Agregar datos adjuntos', filterSupported: 'Archivos compatibles', filterAll: 'Todos los archivos', dlgExportPdf: 'Exportar como PDF', dlgExportHtml: 'Exportar como HTML', + dlgExportMarkdown: 'Exportar como Markdown', dlgPickExportDir: 'Elegir carpeta de exportación', errUnsupportedExt: 'los archivos .{ext} no son compatibles', errNotFile: 'no es un archivo', @@ -818,6 +1506,8 @@ const tMain = createI18n({ errGskNotLoggedIn: 'No has iniciado sesión en Genspark: pulsa «Iniciar sesión en Genspark» abajo, inicia sesión y vuelve a intentarlo', errNoApiKey: 'No hay clave de API configurada para {provider}', + errAiRegion: + 'El proveedor de este modelo no da servicio en tu región actual — elige otro modelo o cambia de red', errAiBusy: 'El servicio de IA está saturado en este momento; inténtalo de nuevo en unos instantes', errNoModel: 'No se ha configurado el nombre del modelo', @@ -833,6 +1523,7 @@ const tMain = createI18n({ menuPageSetup: 'Configurar página…', menuExportPdf: 'Exportar como PDF…', menuExportHtml: 'Exportar como HTML…', + menuExportMarkdown: 'Exportar como Markdown…', menuExportImages: 'Exportar como imágenes…', menuPrint: 'Imprimir…', menuEdit: 'Edición', @@ -843,18 +1534,21 @@ const tMain = createI18n({ menuPaste: 'Pegar', menuPasteMatch: 'Pegar con el mismo estilo', menuFindReplace: 'Buscar y reemplazar…', + menuGoTo: 'Ir a…', menuSelectAll: 'Seleccionar todo', menuView: 'Ver', + menuZoom: 'Zoom', menuZoomIn: 'Acercar', menuZoomOut: 'Alejar', menuZoom100: 'Tamaño real (100 %)', menuPageWidth: 'Ancho de página', menuWholePage: 'Página completa', + menuMultiPage: 'Varias páginas', menuAiSidebar: 'Barra lateral de IA', menuDarkMode: 'Modo oscuro', menuFullscreen: 'Usar pantalla completa', menuInsert: 'Insertar', - menuInsertTable: 'Tabla (3×3)', + menuInsertTable: 'Tabla…', menuInsertImage: 'Imagen…', menuInsertPageBreak: 'Salto de página', menuInsertLink: 'Hipervínculo…', @@ -872,16 +1566,57 @@ const tMain = createI18n({ menuFont: 'Fuente…', menuParagraph: 'Párrafo…', menuTools: 'Herramientas', + menuTable: 'Tabla', + menuTableInsert: 'Insertar', + menuTableInsertTable: 'Tabla…', + menuTableColsLeft: 'Columnas a la izquierda', + menuTableColsRight: 'Columnas a la derecha', + menuTableRowsAbove: 'Filas arriba', + menuTableRowsBelow: 'Filas abajo', + menuTableCells: 'Celdas…', + menuTableDelete: 'Eliminar', + menuTableDeleteTable: 'Tabla', + menuTableDeleteColumns: 'Columnas', + menuTableDeleteRows: 'Filas', + menuTableSelect: 'Seleccionar', + menuTableSelectCell: 'Celda', + menuTableSelectColumn: 'Columna', + menuTableSelectRow: 'Fila', + menuTableSelectTable: 'Tabla', + menuTableMergeCells: 'Combinar celdas', + menuTableSplitCells: 'Dividir celdas…', + menuTableSplitTable: 'Dividir tabla', + menuTableAutoFit: 'Autoajustar y distribuir', + menuTableAutoFitContents: 'Autoajustar al contenido', + menuTableAutoFitWindow: 'Autoajustar a la ventana', + menuTableFixedWidth: 'Ancho de columna fijo', + menuTableDistributeRows: 'Distribuir filas uniformemente', + menuTableDistributeColumns: 'Distribuir columnas uniformemente', + menuTableRepeatHeader: 'Repetir filas de encabezado', + menuTableGridlines: 'Ver líneas de cuadrícula', + menuTableProperties: 'Propiedades de tabla…', menuWordCount: 'Contar palabras…', + menuAutoCorrect: 'Opciones de autocorrección…', + menuPreferences: 'Preferencias…', menuAiProofread: 'Corrección con IA', menuWindow: 'Ventana', menuHelp: 'Ayuda', menuShortcuts: 'Atajos de teclado', - menuDocsHelp: 'Ayuda de GenOffice Docs', }, th: { dlgOpenDoc: 'เปิดเอกสาร', filterWord: 'เอกสาร Word', + odtImportMsg: 'แปลงเอกสาร .{ext} นี้ไหม?', + odtImportDetail: + 'HermesOffice แก้ไข .docx ไม่ใช่ ODF ข้อความ หัวข้อ รายการ ตาราง และรูปภาพจะถูกแปลง ส่วนเชิงอรรถ ความคิดเห็น และกล่องข้อความจะไม่ถูกเก็บไว้ทั้งหมด ไฟล์ต้นทางจะไม่ถูกแก้ไข และไฟล์ .docx ที่แปลงแล้วเป็นสำเนาแยกต่างหากที่การบันทึกครั้งต่อไปจะเขียนลง', + hwpImportDetail: + 'HermesOffice แก้ไข .docx ไม่ใช่ HWP ข้อความ ตาราง รูปภาพ หัวกระดาษ และท้ายกระดาษจะถูกแปลง ส่วนสมการ เชิงอรรถ และวัตถุที่ฝังไว้จะไม่ถูกแปลง และลำดับเลขอัตโนมัติจะกลายเป็นข้อความธรรมดา ไฟล์ต้นทางจะไม่ถูกแก้ไข และไฟล์ .docx ที่แปลงแล้วเป็นสำเนาแยกต่างหากที่การบันทึกครั้งต่อไปจะเขียนลง', + odtImportConvertBtn: 'แปลงแล้วเปิด', + importSkipConfirm: 'ครั้งถัดไปแปลงทันทีโดยไม่ต้องถาม', + docImportMsg: 'แปลงเอกสาร .{ext} นี้ไหม?', + docImportDetail: + 'HermesOffice แก้ไข .docx ไม่ใช่เอกสาร Word 97-2003 (.doc) ข้อความ หัวข้อ รายการ ตาราง และรูปภาพจะถูกแปลง ส่วนเชิงอรรถ ความคิดเห็น และกล่องข้อความจะไม่ถูกเก็บไว้ทั้งหมด ไฟล์ต้นทางจะไม่ถูกแก้ไข และไฟล์ .docx ที่แปลงแล้วเป็นสำเนาแยกต่างหากที่การบันทึกครั้งต่อไปจะเขียนลง', + docImportConvertBtn: 'แปลงแล้วเปิด', dlgSaveAs: 'บันทึกเป็น', closeUnsavedMsg: 'เอกสารนี้มีการเปลี่ยนแปลงที่ยังไม่ได้บันทึก', closeUnsavedDetail: 'ต้องการบันทึกก่อนปิดหรือไม่?', @@ -889,6 +1624,8 @@ const tMain = createI18n({ closeNoReplyDetail: 'ปิดต่อไปหรือไม่ การเปลี่ยนแปลงที่ยังไม่ได้บันทึกจะหายไป', btnCloseAnyway: 'ปิดต่อไป', autosaveFoundTitle: 'พบเวอร์ชันกู้คืนอัตโนมัติ', + autosaveDiskNewer: + 'หมายเหตุ: ไฟล์บนดิสก์ถูกแก้ไขหลังจากบันทึกสำเนากู้คืนนี้ การกู้คืนจะแทนที่การเปลี่ยนแปลงที่ใหม่กว่าเหล่านั้น', autosaveFoundBody: 'มีการเปลี่ยนแปลงที่ยังไม่ได้บันทึกจากครั้งก่อน ต้องการกู้คืนหรือไม่?', autosaveRestore: 'กู้คืน', autosaveDiscard: 'ละทิ้ง', @@ -899,11 +1636,14 @@ const tMain = createI18n({ btnOverwrite: 'เขียนทับ', dlgInsertImage: 'แทรกรูปภาพ', filterImages: 'รูปภาพ', + dlgMergeDataSource: 'เลือกรายชื่อผู้รับ', + filterMergeDataSource: 'Excel หรือ CSV', dlgAddAttachment: 'เพิ่มสิ่งที่แนบ', filterSupported: 'ไฟล์ที่รองรับ', filterAll: 'ไฟล์ทั้งหมด', dlgExportPdf: 'ส่งออกเป็น PDF', dlgExportHtml: 'ส่งออกเป็น HTML', + dlgExportMarkdown: 'ส่งออกเป็น Markdown', dlgPickExportDir: 'เลือกโฟลเดอร์ส่งออก', errUnsupportedExt: 'ไม่รองรับไฟล์ .{ext}', errNotFile: 'ไม่ใช่ไฟล์', @@ -919,6 +1659,8 @@ const tMain = createI18n({ errGskNotLoggedIn: 'ยังไม่ได้ลงชื่อเข้าใช้ Genspark: แตะ “ลงชื่อเข้าใช้ Genspark” ด้านล่าง แล้วลองอีกครั้ง', errNoApiKey: 'ยังไม่ได้ตั้งค่า API Key ของ {provider}', + errAiRegion: + 'ผู้ให้บริการของโมเดลนี้ไม่ให้บริการในภูมิภาคปัจจุบันของคุณ — โปรดเลือกโมเดลอื่นหรือเปลี่ยนเครือข่าย', errAiBusy: 'บริการ AI มีผู้ใช้งานจำนวนมากในขณะนี้ โปรดลองอีกครั้งในอีกสักครู่', errNoModel: 'ยังไม่ได้ตั้งค่าชื่อโมเดล', menuFile: 'ไฟล์', @@ -933,6 +1675,7 @@ const tMain = createI18n({ menuPageSetup: 'ตั้งค่าหน้ากระดาษ…', menuExportPdf: 'ส่งออกเป็น PDF…', menuExportHtml: 'ส่งออกเป็น HTML…', + menuExportMarkdown: 'ส่งออกเป็น Markdown…', menuExportImages: 'ส่งออกเป็นรูปภาพ…', menuPrint: 'พิมพ์…', menuEdit: 'แก้ไข', @@ -943,18 +1686,21 @@ const tMain = createI18n({ menuPaste: 'วาง', menuPasteMatch: 'วางแบบจับคู่ลักษณะ', menuFindReplace: 'ค้นหาและแทนที่…', + menuGoTo: 'ไปที่…', menuSelectAll: 'เลือกทั้งหมด', menuView: 'มุมมอง', + menuZoom: 'ซูม', menuZoomIn: 'ขยาย', menuZoomOut: 'ย่อ', menuZoom100: 'ขนาดจริง (100%)', menuPageWidth: 'ความกว้างของหน้า', menuWholePage: 'ทั้งหน้า', + menuMultiPage: 'หลายหน้า', menuAiSidebar: 'แถบข้าง AI', menuDarkMode: 'โหมดมืด', menuFullscreen: 'เข้าสู่โหมดเต็มหน้าจอ', menuInsert: 'แทรก', - menuInsertTable: 'ตาราง (3×3)', + menuInsertTable: 'ตาราง…', menuInsertImage: 'รูปภาพ…', menuInsertPageBreak: 'ตัวแบ่งหน้า', menuInsertLink: 'ไฮเปอร์ลิงก์…', @@ -972,16 +1718,57 @@ const tMain = createI18n({ menuFont: 'ฟอนต์…', menuParagraph: 'ย่อหน้า…', menuTools: 'เครื่องมือ', + menuTable: 'ตาราง', + menuTableInsert: 'แทรก', + menuTableInsertTable: 'ตาราง…', + menuTableColsLeft: 'คอลัมน์ทางซ้าย', + menuTableColsRight: 'คอลัมน์ทางขวา', + menuTableRowsAbove: 'แถวด้านบน', + menuTableRowsBelow: 'แถวด้านล่าง', + menuTableCells: 'เซลล์…', + menuTableDelete: 'ลบ', + menuTableDeleteTable: 'ตาราง', + menuTableDeleteColumns: 'คอลัมน์', + menuTableDeleteRows: 'แถว', + menuTableSelect: 'เลือก', + menuTableSelectCell: 'เซลล์', + menuTableSelectColumn: 'คอลัมน์', + menuTableSelectRow: 'แถว', + menuTableSelectTable: 'ตาราง', + menuTableMergeCells: 'ผสานเซลล์', + menuTableSplitCells: 'แยกเซลล์…', + menuTableSplitTable: 'แยกตาราง', + menuTableAutoFit: 'ปรับพอดีอัตโนมัติ', + menuTableAutoFitContents: 'ปรับพอดีกับเนื้อหา', + menuTableAutoFitWindow: 'ปรับพอดีกับหน้าต่าง', + menuTableFixedWidth: 'ความกว้างคอลัมน์คงที่', + menuTableDistributeRows: 'กระจายแถวเท่ากัน', + menuTableDistributeColumns: 'กระจายคอลัมน์เท่ากัน', + menuTableRepeatHeader: 'ทำซ้ำแถวส่วนหัว', + menuTableGridlines: 'แสดงเส้นตาราง', + menuTableProperties: 'คุณสมบัติตาราง…', menuWordCount: 'นับจำนวนคำ…', + menuAutoCorrect: 'ตัวเลือกการแก้ไขอัตโนมัติ…', + menuPreferences: 'การตั้งค่า…', menuAiProofread: 'พิสูจน์อักษรด้วย AI', menuWindow: 'หน้าต่าง', menuHelp: 'วิธีใช้', menuShortcuts: 'แป้นพิมพ์ลัด', - menuDocsHelp: 'วิธีใช้ GenOffice Docs', }, id: { dlgOpenDoc: 'Buka Dokumen', filterWord: 'Dokumen Word', + odtImportMsg: 'Konversi dokumen .{ext} ini?', + odtImportDetail: + 'HermesOffice mengedit .docx, bukan ODF. Teks isi, judul, daftar, tabel, dan gambar ikut dikonversi; catatan kaki, komentar, dan kotak teks tidak sepenuhnya dipertahankan. File asli tidak pernah diubah; salinan .docx hasil konversi berdiri sendiri dan penyimpanan berikutnya ditulis ke sana.', + hwpImportDetail: + 'HermesOffice mengedit .docx, bukan HWP. Teks isi, tabel, gambar, header, dan footer ikut dikonversi; persamaan, catatan kaki, dan objek tersemat tidak dikonversi, dan penomoran otomatis menjadi teks biasa. File asli tidak pernah diubah; salinan .docx hasil konversi berdiri sendiri dan penyimpanan berikutnya ditulis ke sana.', + odtImportConvertBtn: 'Konversi dan buka', + importSkipConfirm: 'Lain kali langsung konversi tanpa bertanya', + docImportMsg: 'Konversi dokumen .{ext} ini?', + docImportDetail: + 'HermesOffice mengedit .docx, bukan dokumen Word 97-2003 (.doc). Teks isi, judul, daftar, tabel, dan gambar ikut dikonversi; catatan kaki, komentar, dan kotak teks tidak sepenuhnya dipertahankan. File asli tidak pernah diubah; salinan .docx hasil konversi berdiri sendiri dan penyimpanan berikutnya ditulis ke sana.', + docImportConvertBtn: 'Konversi dan buka', dlgSaveAs: 'Simpan Sebagai', closeUnsavedMsg: 'Dokumen ini memiliki perubahan yang belum disimpan.', closeUnsavedDetail: 'Simpan sebelum menutup?', @@ -989,6 +1776,8 @@ const tMain = createI18n({ closeNoReplyDetail: 'Tetap tutup? Perubahan yang belum disimpan akan hilang.', btnCloseAnyway: 'Tetap Tutup', autosaveFoundTitle: 'Versi pemulihan ditemukan', + autosaveDiskNewer: + 'Catatan: file di disk diubah setelah salinan pemulihan ini disimpan. Memulihkan akan menggantikan perubahan yang lebih baru tersebut.', autosaveFoundBody: 'Ada perubahan yang belum disimpan dari sesi terakhir. Pulihkan versi tersimpan otomatis?', autosaveRestore: 'Pulihkan', @@ -1000,11 +1789,14 @@ const tMain = createI18n({ btnOverwrite: 'Timpa', dlgInsertImage: 'Sisipkan Gambar', filterImages: 'Gambar', + dlgMergeDataSource: 'Pilih daftar penerima', + filterMergeDataSource: 'Excel atau CSV', dlgAddAttachment: 'Tambahkan Lampiran', filterSupported: 'File yang Didukung', filterAll: 'Semua File', dlgExportPdf: 'Ekspor sebagai PDF', dlgExportHtml: 'Ekspor sebagai HTML', + dlgExportMarkdown: 'Ekspor sebagai Markdown', dlgPickExportDir: 'Pilih Folder Ekspor', errUnsupportedExt: 'file .{ext} tidak didukung', errNotFile: 'bukan file', @@ -1019,6 +1811,8 @@ const tMain = createI18n({ errAttachmentNotGranted: 'File ini tidak diizinkan sebagai lampiran dan tidak dapat dibaca', errGskNotLoggedIn: 'Belum masuk ke Genspark: klik “Masuk ke Genspark” di bawah, lalu coba lagi', errNoApiKey: 'API Key untuk {provider} belum dikonfigurasi', + errAiRegion: + 'Penyedia model ini tidak melayani wilayah Anda saat ini — pilih model lain atau ganti jaringan', errAiBusy: 'Layanan AI sedang sibuk — silakan coba lagi sebentar lagi', errNoModel: 'Nama model belum dikonfigurasi', menuFile: 'File', @@ -1033,6 +1827,7 @@ const tMain = createI18n({ menuPageSetup: 'Penyetelan Halaman…', menuExportPdf: 'Ekspor sebagai PDF…', menuExportHtml: 'Ekspor sebagai HTML…', + menuExportMarkdown: 'Ekspor sebagai Markdown…', menuExportImages: 'Ekspor sebagai gambar…', menuPrint: 'Cetak…', menuEdit: 'Edit', @@ -1043,18 +1838,21 @@ const tMain = createI18n({ menuPaste: 'Tempel', menuPasteMatch: 'Tempel dan Samakan Gaya', menuFindReplace: 'Temukan dan Ganti…', + menuGoTo: 'Pergi ke…', menuSelectAll: 'Pilih Semua', menuView: 'Tampilan', + menuZoom: 'Zoom', menuZoomIn: 'Perbesar', menuZoomOut: 'Perkecil', menuZoom100: 'Ukuran Sebenarnya (100%)', menuPageWidth: 'Lebar Halaman', menuWholePage: 'Seluruh Halaman', + menuMultiPage: 'Beberapa halaman', menuAiSidebar: 'Bilah Samping AI', menuDarkMode: 'Mode Gelap', menuFullscreen: 'Masuk Layar Penuh', menuInsert: 'Sisipkan', - menuInsertTable: 'Tabel (3×3)', + menuInsertTable: 'Tabel…', menuInsertImage: 'Gambar…', menuInsertPageBreak: 'Pemisah Halaman', menuInsertLink: 'Hyperlink…', @@ -1072,16 +1870,57 @@ const tMain = createI18n({ menuFont: 'Font…', menuParagraph: 'Paragraf…', menuTools: 'Alat', + menuTable: 'Tabel', + menuTableInsert: 'Sisipkan', + menuTableInsertTable: 'Tabel…', + menuTableColsLeft: 'Kolom di Kiri', + menuTableColsRight: 'Kolom di Kanan', + menuTableRowsAbove: 'Baris di Atas', + menuTableRowsBelow: 'Baris di Bawah', + menuTableCells: 'Sel…', + menuTableDelete: 'Hapus', + menuTableDeleteTable: 'Tabel', + menuTableDeleteColumns: 'Kolom', + menuTableDeleteRows: 'Baris', + menuTableSelect: 'Pilih', + menuTableSelectCell: 'Sel', + menuTableSelectColumn: 'Kolom', + menuTableSelectRow: 'Baris', + menuTableSelectTable: 'Tabel', + menuTableMergeCells: 'Gabungkan Sel', + menuTableSplitCells: 'Pisahkan Sel…', + menuTableSplitTable: 'Pisahkan Tabel', + menuTableAutoFit: 'Paskan Otomatis', + menuTableAutoFitContents: 'Paskan ke Konten', + menuTableAutoFitWindow: 'Paskan ke Jendela', + menuTableFixedWidth: 'Lebar Kolom Tetap', + menuTableDistributeRows: 'Distribusikan Baris Merata', + menuTableDistributeColumns: 'Distribusikan Kolom Merata', + menuTableRepeatHeader: 'Ulangi Baris Header', + menuTableGridlines: 'Lihat Garis Kisi', + menuTableProperties: 'Properti Tabel…', menuWordCount: 'Hitungan Kata…', + menuAutoCorrect: 'Opsi Koreksi Otomatis…', + menuPreferences: 'Preferensi…', menuAiProofread: 'Koreksi AI', menuWindow: 'Jendela', menuHelp: 'Bantuan', menuShortcuts: 'Pintasan Papan Ketik', - menuDocsHelp: 'Bantuan GenOffice Docs', }, ru: { dlgOpenDoc: 'Открыть документ', filterWord: 'Документы Word', + odtImportMsg: 'Преобразовать этот документ .{ext}?', + odtImportDetail: + 'HermesOffice редактирует .docx, а не ODF. Текст, заголовки, списки, таблицы и изображения переносятся; сноски, примечания и надписи сохраняются не полностью. Исходный файл никогда не изменяется; преобразованный .docx — отдельная копия, в которую записывается дальнейшее сохранение.', + hwpImportDetail: + 'HermesOffice редактирует .docx, а не HWP. Текст, таблицы, изображения и колонтитулы переносятся; формулы, сноски и внедрённые объекты не преобразуются, а автоматическая нумерация становится обычным текстом. Исходный файл никогда не изменяется; преобразованный .docx — отдельная копия, в которую записывается дальнейшее сохранение.', + odtImportConvertBtn: 'Преобразовать и открыть', + importSkipConfirm: 'В следующий раз конвертировать без запроса', + docImportMsg: 'Преобразовать этот документ .{ext}?', + docImportDetail: + 'HermesOffice редактирует .docx, а не документы Word 97-2003 (.doc). Текст, заголовки, списки, таблицы и изображения переносятся; сноски, примечания и надписи сохраняются не полностью. Исходный файл никогда не изменяется; преобразованный .docx — отдельная копия, в которую записывается дальнейшее сохранение.', + docImportConvertBtn: 'Преобразовать и открыть', dlgSaveAs: 'Сохранить как', closeUnsavedMsg: 'В этом документе есть несохранённые изменения.', closeUnsavedDetail: 'Сохранить их перед закрытием?', @@ -1089,6 +1928,8 @@ const tMain = createI18n({ closeNoReplyDetail: 'Всё равно закрыть? Несохранённые изменения будут потеряны.', btnCloseAnyway: 'Закрыть всё равно', autosaveFoundTitle: 'Найдена восстановленная версия', + autosaveDiskNewer: + 'Внимание: файл на диске был изменён после сохранения этой копии восстановления. Восстановление заменит эти более новые изменения.', autosaveFoundBody: 'Есть несохранённые изменения из прошлого сеанса. Восстановить автосохранённую версию?', autosaveRestore: 'Восстановить', @@ -1100,11 +1941,14 @@ const tMain = createI18n({ btnOverwrite: 'Перезаписать', dlgInsertImage: 'Вставить рисунок', filterImages: 'Изображения', + dlgMergeDataSource: 'Выбрать список получателей', + filterMergeDataSource: 'Excel или CSV', dlgAddAttachment: 'Добавить вложения', filterSupported: 'Поддерживаемые файлы', filterAll: 'Все файлы', dlgExportPdf: 'Экспорт в PDF', dlgExportHtml: 'Экспорт в HTML', + dlgExportMarkdown: 'Экспорт в Markdown', dlgPickExportDir: 'Выбор папки для экспорта', errUnsupportedExt: 'файлы .{ext} не поддерживаются', errNotFile: 'не является файлом', @@ -1120,6 +1964,8 @@ const tMain = createI18n({ errGskNotLoggedIn: 'Вы не вошли в Genspark: нажмите «Войти в Genspark» ниже, войдите и повторите попытку', errNoApiKey: 'API-ключ для {provider} не настроен', + errAiRegion: + 'Поставщик этой модели не обслуживает ваш текущий регион — выберите другую модель или смените сеть', errAiBusy: 'Сервис ИИ сейчас перегружен — повторите попытку чуть позже', errNoModel: 'Не указано имя модели', menuFile: 'Файл', @@ -1134,6 +1980,7 @@ const tMain = createI18n({ menuPageSetup: 'Параметры страницы…', menuExportPdf: 'Экспорт в PDF…', menuExportHtml: 'Экспорт в HTML…', + menuExportMarkdown: 'Экспорт в Markdown…', menuExportImages: 'Экспорт в изображения…', menuPrint: 'Печать…', menuEdit: 'Правка', @@ -1144,18 +1991,21 @@ const tMain = createI18n({ menuPaste: 'Вставить', menuPasteMatch: 'Вставить и согласовать стиль', menuFindReplace: 'Найти и заменить…', + menuGoTo: 'Перейти…', menuSelectAll: 'Выделить все', menuView: 'Вид', + menuZoom: 'Масштаб', menuZoomIn: 'Увеличить', menuZoomOut: 'Уменьшить', menuZoom100: 'Фактический размер (100%)', menuPageWidth: 'По ширине страницы', menuWholePage: 'Страница целиком', + menuMultiPage: 'Несколько страниц', menuAiSidebar: 'Боковая панель ИИ', menuDarkMode: 'Темный режим', menuFullscreen: 'Перейти в полноэкранный режим', menuInsert: 'Вставка', - menuInsertTable: 'Таблица (3×3)', + menuInsertTable: 'Таблица…', menuInsertImage: 'Рисунок…', menuInsertPageBreak: 'Разрыв страницы', menuInsertLink: 'Гиперссылка…', @@ -1173,16 +2023,57 @@ const tMain = createI18n({ menuFont: 'Шрифт…', menuParagraph: 'Абзац…', menuTools: 'Сервис', + menuTable: 'Таблица', + menuTableInsert: 'Вставить', + menuTableInsertTable: 'Таблица…', + menuTableColsLeft: 'Столбцы слева', + menuTableColsRight: 'Столбцы справа', + menuTableRowsAbove: 'Строки выше', + menuTableRowsBelow: 'Строки ниже', + menuTableCells: 'Ячейки…', + menuTableDelete: 'Удалить', + menuTableDeleteTable: 'Таблицу', + menuTableDeleteColumns: 'Столбцы', + menuTableDeleteRows: 'Строки', + menuTableSelect: 'Выделить', + menuTableSelectCell: 'Ячейку', + menuTableSelectColumn: 'Столбец', + menuTableSelectRow: 'Строку', + menuTableSelectTable: 'Таблицу', + menuTableMergeCells: 'Объединить ячейки', + menuTableSplitCells: 'Разделить ячейки…', + menuTableSplitTable: 'Разделить таблицу', + menuTableAutoFit: 'Автоподбор', + menuTableAutoFitContents: 'По содержимому', + menuTableAutoFitWindow: 'По ширине окна', + menuTableFixedWidth: 'Фиксированная ширина столбца', + menuTableDistributeRows: 'Выровнять высоту строк', + menuTableDistributeColumns: 'Выровнять ширину столбцов', + menuTableRepeatHeader: 'Повторять строки заголовков', + menuTableGridlines: 'Отображать сетку', + menuTableProperties: 'Свойства таблицы…', menuWordCount: 'Статистика…', + menuAutoCorrect: 'Параметры автозамены…', + menuPreferences: 'Параметры…', menuAiProofread: 'ИИ-корректура', menuWindow: 'Окно', menuHelp: 'Справка', menuShortcuts: 'Сочетания клавиш', - menuDocsHelp: 'Справка GenOffice Docs', }, ar: { dlgOpenDoc: 'فتح مستند', filterWord: 'مستندات Word', + odtImportMsg: 'تحويل مستند .{ext} هذا؟', + odtImportDetail: + 'يحرّر HermesOffice ملفات ‎.docx ولا يحرّر ODF. يُحوَّل النص والعناوين والقوائم والجداول والصور؛ أما الحواشي السفلية والتعليقات ومربعات النص فلا تُحفظ بالكامل. لا يتغيّر الملف الأصلي أبدًا؛ والملف ‎.docx المحوَّل نسخة مستقلة تُكتب فيها عمليات الحفظ اللاحقة.', + hwpImportDetail: + 'يحرّر HermesOffice ملفات ‎.docx ولا يحرّر HWP. يُحوَّل النص والجداول والصور والرؤوس والتذييلات؛ أما المعادلات والحواشي السفلية والكائنات المضمّنة فلا تُحوَّل، ويصبح الترقيم التلقائي نصًا عاديًا. لا يتغيّر الملف الأصلي أبدًا؛ والملف ‎.docx المحوَّل نسخة مستقلة تُكتب فيها عمليات الحفظ اللاحقة.', + odtImportConvertBtn: 'تحويل وفتح', + importSkipConfirm: 'في المرة القادمة، حوّل دون سؤال', + docImportMsg: 'تحويل مستند .{ext} هذا؟', + docImportDetail: + 'يحرّر HermesOffice ملفات ‎.docx ولا يحرّر مستندات Word 97-2003 (‎.doc). يُحوَّل النص والعناوين والقوائم والجداول والصور؛ أما الحواشي السفلية والتعليقات ومربعات النص فلا تُحفظ بالكامل. لا يتغيّر الملف الأصلي أبدًا؛ والملف ‎.docx المحوَّل نسخة مستقلة تُكتب فيها عمليات الحفظ اللاحقة.', + docImportConvertBtn: 'تحويل وفتح', dlgSaveAs: 'حفظ باسم', closeUnsavedMsg: 'يحتوي هذا المستند على تغييرات غير محفوظة.', closeUnsavedDetail: 'هل تريد حفظها قبل الإغلاق؟', @@ -1190,6 +2081,8 @@ const tMain = createI18n({ closeNoReplyDetail: 'هل تريد الإغلاق رغم ذلك؟ ستفقد التغييرات غير المحفوظة.', btnCloseAnyway: 'إغلاق على أي حال', autosaveFoundTitle: 'تم العثور على نسخة مستردة', + autosaveDiskNewer: + 'ملاحظة: تم تعديل الملف على القرص بعد حفظ نسخة الاسترداد هذه. ستؤدي الاستعادة إلى استبدال تلك التغييرات الأحدث.', autosaveFoundBody: 'توجد تغييرات غير محفوظة من الجلسة الأخيرة. هل تريد استعادة النسخة المحفوظة تلقائيًا؟', autosaveRestore: 'استعادة', @@ -1201,11 +2094,14 @@ const tMain = createI18n({ btnOverwrite: 'استبدال', dlgInsertImage: 'إدراج صورة', filterImages: 'الصور', + dlgMergeDataSource: 'اختيار قائمة المستلمين', + filterMergeDataSource: 'Excel أو CSV', dlgAddAttachment: 'إضافة مرفقات', filterSupported: 'الملفات المدعومة', filterAll: 'كل الملفات', dlgExportPdf: 'تصدير بتنسيق PDF', dlgExportHtml: 'تصدير بتنسيق HTML', + dlgExportMarkdown: 'تصدير بتنسيق Markdown', dlgPickExportDir: 'اختيار مجلد التصدير', errUnsupportedExt: 'ملفات .{ext} غير مدعومة', errNotFile: 'ليس ملفًا', @@ -1221,6 +2117,8 @@ const tMain = createI18n({ errGskNotLoggedIn: 'لم تسجّل الدخول إلى Genspark: انقر على «تسجيل الدخول إلى Genspark» أدناه ثم أعد المحاولة', errNoApiKey: 'لم يتم تكوين مفتاح API لـ {provider}', + errAiRegion: + 'مزوّد هذا النموذج لا يقدم الخدمة في منطقتك الحالية — اختر نموذجًا آخر أو غيّر الشبكة', errAiBusy: 'خدمة الذكاء الاصطناعي مشغولة حاليًا — يرجى المحاولة مرة أخرى بعد قليل', errNoModel: 'لم يتم تكوين اسم النموذج', menuFile: 'ملف', @@ -1235,6 +2133,7 @@ const tMain = createI18n({ menuPageSetup: 'إعداد الصفحة…', menuExportPdf: 'تصدير بتنسيق PDF…', menuExportHtml: 'تصدير بتنسيق HTML…', + menuExportMarkdown: 'تصدير بتنسيق Markdown…', menuExportImages: 'تصدير كصور…', menuPrint: 'طباعة…', menuEdit: 'تحرير', @@ -1245,18 +2144,21 @@ const tMain = createI18n({ menuPaste: 'لصق', menuPasteMatch: 'لصق مع مطابقة النمط', menuFindReplace: 'بحث واستبدال…', + menuGoTo: 'الانتقال إلى…', menuSelectAll: 'تحديد الكل', menuView: 'عرض', + menuZoom: 'تكبير/تصغير', menuZoomIn: 'تكبير', menuZoomOut: 'تصغير', menuZoom100: 'الحجم الفعلي (100%)', menuPageWidth: 'عرض الصفحة', menuWholePage: 'صفحة كاملة', + menuMultiPage: 'صفحات متعددة', menuAiSidebar: 'الشريط الجانبي للذكاء الاصطناعي', menuDarkMode: 'الوضع الداكن', menuFullscreen: 'الدخول إلى ملء الشاشة', menuInsert: 'إدراج', - menuInsertTable: 'جدول (3×3)', + menuInsertTable: 'جدول…', menuInsertImage: 'صورة…', menuInsertPageBreak: 'فاصل صفحات', menuInsertLink: 'ارتباط تشعبي…', @@ -1274,16 +2176,57 @@ const tMain = createI18n({ menuFont: 'الخط…', menuParagraph: 'فقرة…', menuTools: 'أدوات', + menuTable: 'جدول', + menuTableInsert: 'إدراج', + menuTableInsertTable: 'جدول…', + menuTableColsLeft: 'أعمدة إلى اليسار', + menuTableColsRight: 'أعمدة إلى اليمين', + menuTableRowsAbove: 'صفوف أعلى', + menuTableRowsBelow: 'صفوف أسفل', + menuTableCells: 'خلايا…', + menuTableDelete: 'حذف', + menuTableDeleteTable: 'الجدول', + menuTableDeleteColumns: 'الأعمدة', + menuTableDeleteRows: 'الصفوف', + menuTableSelect: 'تحديد', + menuTableSelectCell: 'الخلية', + menuTableSelectColumn: 'العمود', + menuTableSelectRow: 'الصف', + menuTableSelectTable: 'الجدول', + menuTableMergeCells: 'دمج الخلايا', + menuTableSplitCells: 'تقسيم الخلايا…', + menuTableSplitTable: 'تقسيم الجدول', + menuTableAutoFit: 'احتواء تلقائي', + menuTableAutoFitContents: 'احتواء تلقائي للمحتويات', + menuTableAutoFitWindow: 'احتواء تلقائي للنافذة', + menuTableFixedWidth: 'عرض عمود ثابت', + menuTableDistributeRows: 'توزيع الصفوف بالتساوي', + menuTableDistributeColumns: 'توزيع الأعمدة بالتساوي', + menuTableRepeatHeader: 'تكرار صفوف الرأس', + menuTableGridlines: 'عرض خطوط الشبكة', + menuTableProperties: 'خصائص الجدول…', menuWordCount: 'عدد الكلمات…', + menuAutoCorrect: 'خيارات التصحيح التلقائي…', + menuPreferences: 'التفضيلات…', menuAiProofread: 'تدقيق بالذكاء الاصطناعي', menuWindow: 'نافذة', menuHelp: 'تعليمات', menuShortcuts: 'اختصارات لوحة المفاتيح', - menuDocsHelp: 'تعليمات GenOffice Docs', }, pt: { dlgOpenDoc: 'Abrir Documento', filterWord: 'Documentos do Word', + odtImportMsg: 'Converter este documento .{ext}?', + odtImportDetail: + 'O HermesOffice edita .docx, não ODF. Texto, títulos, listas, tabelas e imagens são convertidos; notas de rodapé, comentários e caixas de texto não são preservados por completo. O arquivo original nunca é alterado; o .docx convertido é uma cópia independente em que os salvamentos seguintes são gravados.', + hwpImportDetail: + 'O HermesOffice edita .docx, não HWP. Texto, tabelas, imagens, cabeçalhos e rodapés são convertidos; equações, notas de rodapé e objetos incorporados não são, e a numeração automática vira texto comum. O arquivo original nunca é alterado; o .docx convertido é uma cópia independente em que os salvamentos seguintes são gravados.', + odtImportConvertBtn: 'Converter e abrir', + importSkipConfirm: 'Da próxima vez, converter sem perguntar', + docImportMsg: 'Converter este documento .{ext}?', + docImportDetail: + 'O HermesOffice edita .docx, não documentos do Word 97-2003 (.doc). Texto, títulos, listas, tabelas e imagens são convertidos; notas de rodapé, comentários e caixas de texto não são preservados por completo. O arquivo original nunca é alterado; o .docx convertido é uma cópia independente em que os salvamentos seguintes são gravados.', + docImportConvertBtn: 'Converter e abrir', dlgSaveAs: 'Salvar Como', closeUnsavedMsg: 'Este documento tem alterações não salvas.', closeUnsavedDetail: 'Deseja salvá-las antes de fechar?', @@ -1291,6 +2234,8 @@ const tMain = createI18n({ closeNoReplyDetail: 'Fechar mesmo assim? As alterações não salvas serão perdidas.', btnCloseAnyway: 'Fechar mesmo assim', autosaveFoundTitle: 'Versão recuperada encontrada', + autosaveDiskNewer: + 'Nota: o arquivo no disco foi modificado depois que esta cópia de recuperação foi salva. Restaurar substituirá essas alterações mais recentes.', autosaveFoundBody: 'Há alterações não salvas da sua última sessão. Restaurar a versão salva automaticamente?', autosaveRestore: 'Restaurar', @@ -1302,11 +2247,14 @@ const tMain = createI18n({ btnOverwrite: 'Sobrescrever', dlgInsertImage: 'Inserir Imagem', filterImages: 'Imagens', + dlgMergeDataSource: 'Selecionar lista de destinatários', + filterMergeDataSource: 'Excel ou CSV', dlgAddAttachment: 'Adicionar Anexos', filterSupported: 'Arquivos Compatíveis', filterAll: 'Todos os Arquivos', dlgExportPdf: 'Exportar como PDF', dlgExportHtml: 'Exportar como HTML', + dlgExportMarkdown: 'Exportar como Markdown', dlgPickExportDir: 'Escolher Pasta de Exportação', errUnsupportedExt: 'arquivos .{ext} não são suportados', errNotFile: 'não é um arquivo', @@ -1322,6 +2270,8 @@ const tMain = createI18n({ errGskNotLoggedIn: 'Não conectado ao Genspark: clique em “Entrar no Genspark” abaixo, entre e tente novamente', errNoApiKey: 'Nenhuma chave de API configurada para {provider}', + errAiRegion: + 'O fornecedor deste modelo não atende à sua região atual — escolha outro modelo ou mude de rede', errAiBusy: 'O serviço de IA está sobrecarregado no momento — tente novamente em instantes', errNoModel: 'Nenhum nome de modelo configurado', menuFile: 'Arquivo', @@ -1336,6 +2286,7 @@ const tMain = createI18n({ menuPageSetup: 'Configurar Página…', menuExportPdf: 'Exportar como PDF…', menuExportHtml: 'Exportar como HTML…', + menuExportMarkdown: 'Exportar como Markdown…', menuExportImages: 'Exportar como imagens…', menuPrint: 'Imprimir…', menuEdit: 'Editar', @@ -1346,18 +2297,21 @@ const tMain = createI18n({ menuPaste: 'Colar', menuPasteMatch: 'Colar com a Mesma Formatação', menuFindReplace: 'Localizar e Substituir…', + menuGoTo: 'Ir para…', menuSelectAll: 'Selecionar Tudo', menuView: 'Exibir', + menuZoom: 'Zoom', menuZoomIn: 'Ampliar', menuZoomOut: 'Reduzir', menuZoom100: 'Tamanho Real (100%)', menuPageWidth: 'Largura da Página', menuWholePage: 'Página Inteira', + menuMultiPage: 'Várias páginas', menuAiSidebar: 'Barra Lateral de IA', menuDarkMode: 'Modo Escuro', menuFullscreen: 'Entrar em Tela Cheia', menuInsert: 'Inserir', - menuInsertTable: 'Tabela (3×3)', + menuInsertTable: 'Tabela…', menuInsertImage: 'Imagem…', menuInsertPageBreak: 'Quebra de Página', menuInsertLink: 'Hiperlink…', @@ -1375,16 +2329,57 @@ const tMain = createI18n({ menuFont: 'Fonte…', menuParagraph: 'Parágrafo…', menuTools: 'Ferramentas', + menuTable: 'Tabela', + menuTableInsert: 'Inserir', + menuTableInsertTable: 'Tabela…', + menuTableColsLeft: 'Colunas à esquerda', + menuTableColsRight: 'Colunas à direita', + menuTableRowsAbove: 'Linhas acima', + menuTableRowsBelow: 'Linhas abaixo', + menuTableCells: 'Células…', + menuTableDelete: 'Excluir', + menuTableDeleteTable: 'Tabela', + menuTableDeleteColumns: 'Colunas', + menuTableDeleteRows: 'Linhas', + menuTableSelect: 'Selecionar', + menuTableSelectCell: 'Célula', + menuTableSelectColumn: 'Coluna', + menuTableSelectRow: 'Linha', + menuTableSelectTable: 'Tabela', + menuTableMergeCells: 'Mesclar células', + menuTableSplitCells: 'Dividir células…', + menuTableSplitTable: 'Dividir tabela', + menuTableAutoFit: 'AutoAjuste', + menuTableAutoFitContents: 'AutoAjustar ao conteúdo', + menuTableAutoFitWindow: 'AutoAjustar à janela', + menuTableFixedWidth: 'Largura de coluna fixa', + menuTableDistributeRows: 'Distribuir linhas uniformemente', + menuTableDistributeColumns: 'Distribuir colunas uniformemente', + menuTableRepeatHeader: 'Repetir linhas de cabeçalho', + menuTableGridlines: 'Ver linhas de grade', + menuTableProperties: 'Propriedades da tabela…', menuWordCount: 'Contagem de Palavras…', + menuAutoCorrect: 'Opções de Correção Automática…', + menuPreferences: 'Preferências…', menuAiProofread: 'Revisão com IA', menuWindow: 'Janela', menuHelp: 'Ajuda', menuShortcuts: 'Atalhos de Teclado', - menuDocsHelp: 'Ajuda do GenOffice Docs', }, it: { dlgOpenDoc: 'Apri documento', filterWord: 'Documenti Word', + odtImportMsg: 'Convertire questo documento .{ext}?', + odtImportDetail: + 'HermesOffice modifica .docx, non ODF. Testo, titoli, elenchi, tabelle e immagini vengono convertiti; note a piè di pagina, commenti e caselle di testo non sono conservati completamente. Il file originale non viene mai modificato; il .docx convertito è una copia indipendente su cui vengono salvate le modifiche successive.', + hwpImportDetail: + 'HermesOffice modifica .docx, non HWP. Testo, tabelle, immagini, intestazioni e piè di pagina vengono convertiti; equazioni, note a piè di pagina e oggetti incorporati no, e la numerazione automatica diventa testo semplice. Il file originale non viene mai modificato; il .docx convertito è una copia indipendente su cui vengono salvate le modifiche successive.', + odtImportConvertBtn: 'Converti e apri', + importSkipConfirm: 'La prossima volta converti senza chiedere', + docImportMsg: 'Convertire questo documento .{ext}?', + docImportDetail: + 'HermesOffice modifica .docx, non documenti Word 97-2003 (.doc). Testo, titoli, elenchi, tabelle e immagini vengono convertiti; note a piè di pagina, commenti e caselle di testo non sono conservati completamente. Il file originale non viene mai modificato; il .docx convertito è una copia indipendente su cui vengono salvate le modifiche successive.', + docImportConvertBtn: 'Converti e apri', dlgSaveAs: 'Salva con nome', closeUnsavedMsg: 'Questo documento contiene modifiche non salvate.', closeUnsavedDetail: 'Salvarle prima di chiudere?', @@ -1392,6 +2387,8 @@ const tMain = createI18n({ closeNoReplyDetail: 'Chiudere comunque? Le modifiche non salvate andranno perse.', btnCloseAnyway: 'Chiudi comunque', autosaveFoundTitle: 'Trovata versione recuperata', + autosaveDiskNewer: + 'Nota: il file su disco è stato modificato dopo il salvataggio di questa copia di ripristino. Il ripristino sostituirà tali modifiche più recenti.', autosaveFoundBody: "Ci sono modifiche non salvate dall'ultima sessione. Ripristinare la versione salvata automaticamente?", autosaveRestore: 'Ripristina', @@ -1403,11 +2400,14 @@ const tMain = createI18n({ btnOverwrite: 'Sovrascrivi', dlgInsertImage: 'Inserisci immagine', filterImages: 'Immagini', + dlgMergeDataSource: 'Seleziona elenco destinatari', + filterMergeDataSource: 'Excel o CSV', dlgAddAttachment: 'Aggiungi allegati', filterSupported: 'File supportati', filterAll: 'Tutti i file', dlgExportPdf: 'Esporta come PDF', dlgExportHtml: 'Esporta come HTML', + dlgExportMarkdown: 'Esporta come Markdown', dlgPickExportDir: 'Scegli la cartella di esportazione', errUnsupportedExt: 'i file .{ext} non sono supportati', errNotFile: 'non è un file', @@ -1423,6 +2423,8 @@ const tMain = createI18n({ errGskNotLoggedIn: 'Accesso a Genspark non effettuato: fai clic su “Accedi a Genspark” qui sotto, accedi e riprova', errNoApiKey: 'Nessuna chiave API configurata per {provider}', + errAiRegion: + 'Il fornitore di questo modello non serve la tua regione attuale — scegli un altro modello o cambia rete', errAiBusy: 'Il servizio IA è momentaneamente sovraccarico — riprova tra poco', errNoModel: 'Nessun nome di modello configurato', menuFile: 'File', @@ -1437,6 +2439,7 @@ const tMain = createI18n({ menuPageSetup: 'Imposta pagina…', menuExportPdf: 'Esporta come PDF…', menuExportHtml: 'Esporta come HTML…', + menuExportMarkdown: 'Esporta come Markdown…', menuExportImages: 'Esporta come immagini…', menuPrint: 'Stampa…', menuEdit: 'Modifica', @@ -1447,18 +2450,21 @@ const tMain = createI18n({ menuPaste: 'Incolla', menuPasteMatch: 'Incolla e adatta lo stile', menuFindReplace: 'Trova e sostituisci…', + menuGoTo: 'Vai a…', menuSelectAll: 'Seleziona tutto', menuView: 'Visualizza', + menuZoom: 'Zoom', menuZoomIn: 'Ingrandisci', menuZoomOut: 'Riduci', menuZoom100: 'Dimensioni effettive (100%)', menuPageWidth: 'Larghezza pagina', menuWholePage: 'Pagina intera', + menuMultiPage: 'Più pagine', menuAiSidebar: 'Barra laterale IA', menuDarkMode: 'Modalità scura', menuFullscreen: 'Attiva schermo intero', menuInsert: 'Inserisci', - menuInsertTable: 'Tabella (3×3)', + menuInsertTable: 'Tabella…', menuInsertImage: 'Immagine…', menuInsertPageBreak: 'Interruzione di pagina', menuInsertLink: 'Collegamento ipertestuale…', @@ -1476,16 +2482,57 @@ const tMain = createI18n({ menuFont: 'Carattere…', menuParagraph: 'Paragrafo…', menuTools: 'Strumenti', + menuTable: 'Tabella', + menuTableInsert: 'Inserisci', + menuTableInsertTable: 'Tabella…', + menuTableColsLeft: 'Colonne a sinistra', + menuTableColsRight: 'Colonne a destra', + menuTableRowsAbove: 'Righe sopra', + menuTableRowsBelow: 'Righe sotto', + menuTableCells: 'Celle…', + menuTableDelete: 'Elimina', + menuTableDeleteTable: 'Tabella', + menuTableDeleteColumns: 'Colonne', + menuTableDeleteRows: 'Righe', + menuTableSelect: 'Seleziona', + menuTableSelectCell: 'Cella', + menuTableSelectColumn: 'Colonna', + menuTableSelectRow: 'Riga', + menuTableSelectTable: 'Tabella', + menuTableMergeCells: 'Unisci celle', + menuTableSplitCells: 'Dividi celle…', + menuTableSplitTable: 'Dividi tabella', + menuTableAutoFit: 'Adatta e distribuisci', + menuTableAutoFitContents: 'Adatta al contenuto', + menuTableAutoFitWindow: 'Adatta alla finestra', + menuTableFixedWidth: 'Larghezza colonna fissa', + menuTableDistributeRows: 'Distribuisci righe uniformemente', + menuTableDistributeColumns: 'Distribuisci colonne uniformemente', + menuTableRepeatHeader: 'Ripeti righe di intestazione', + menuTableGridlines: 'Mostra griglia', + menuTableProperties: 'Proprietà tabella…', menuWordCount: 'Conteggio parole…', + menuAutoCorrect: 'Opzioni correzione automatica…', + menuPreferences: 'Preferenze…', menuAiProofread: 'Correzione IA', menuWindow: 'Finestra', menuHelp: 'Aiuto', menuShortcuts: 'Scelte rapide da tastiera', - menuDocsHelp: 'Guida di GenOffice Docs', }, pl: { dlgOpenDoc: 'Otwórz dokument', filterWord: 'Dokumenty programu Word', + odtImportMsg: 'Skonwertować ten dokument .{ext}?', + odtImportDetail: + 'HermesOffice edytuje .docx, a nie ODF. Tekst, nagłówki, listy, tabele i obrazy są konwertowane; przypisy, komentarze i pola tekstowe nie są zachowywane w całości. Plik oryginalny nigdy nie jest zmieniany; skonwertowany .docx to niezależna kopia, do której trafiają kolejne zapisy.', + hwpImportDetail: + 'HermesOffice edytuje .docx, a nie HWP. Tekst, tabele, obrazy, nagłówki i stopki są konwertowane; równania, przypisy i obiekty osadzone nie są, a automatyczna numeracja staje się zwykłym tekstem. Plik oryginalny nigdy nie jest zmieniany; skonwertowany .docx to niezależna kopia, do której trafiają kolejne zapisy.', + odtImportConvertBtn: 'Konwertuj i otwórz', + importSkipConfirm: 'Następnym razem konwertuj bez pytania', + docImportMsg: 'Skonwertować ten dokument .{ext}?', + docImportDetail: + 'HermesOffice edytuje .docx, a nie dokumenty Word 97-2003 (.doc). Tekst, nagłówki, listy, tabele i obrazy są konwertowane; przypisy, komentarze i pola tekstowe nie są zachowywane w całości. Plik oryginalny nigdy nie jest zmieniany; skonwertowany .docx to niezależna kopia, do której trafiają kolejne zapisy.', + docImportConvertBtn: 'Konwertuj i otwórz', dlgSaveAs: 'Zapisz jako', closeUnsavedMsg: 'Ten dokument zawiera niezapisane zmiany.', closeUnsavedDetail: 'Czy zapisać je przed zamknięciem?', @@ -1493,6 +2540,8 @@ const tMain = createI18n({ closeNoReplyDetail: 'Zamknąć mimo to? Niezapisane zmiany zostaną utracone.', btnCloseAnyway: 'Zamknij mimo to', autosaveFoundTitle: 'Znaleziono odzyskaną wersję', + autosaveDiskNewer: + 'Uwaga: plik na dysku został zmodyfikowany po zapisaniu tej kopii odzyskiwania. Przywrócenie zastąpi te nowsze zmiany.', autosaveFoundBody: 'Istnieją niezapisane zmiany z ostatniej sesji. Przywrócić wersję zapisaną automatycznie?', autosaveRestore: 'Przywróć', @@ -1504,11 +2553,14 @@ const tMain = createI18n({ btnOverwrite: 'Nadpisz', dlgInsertImage: 'Wstaw obraz', filterImages: 'Obrazy', + dlgMergeDataSource: 'Wybierz listę odbiorców', + filterMergeDataSource: 'Excel lub CSV', dlgAddAttachment: 'Dodaj załączniki', filterSupported: 'Obsługiwane pliki', filterAll: 'Wszystkie pliki', dlgExportPdf: 'Eksportuj jako PDF', dlgExportHtml: 'Eksportuj jako HTML', + dlgExportMarkdown: 'Eksportuj jako Markdown', dlgPickExportDir: 'Wybierz folder eksportu', errUnsupportedExt: 'pliki .{ext} nie są obsługiwane', errNotFile: 'to nie jest plik', @@ -1525,6 +2577,8 @@ const tMain = createI18n({ errGskNotLoggedIn: 'Nie zalogowano do Genspark: kliknij „Zaloguj się do Genspark” poniżej, zaloguj się i spróbuj ponownie', errNoApiKey: 'Nie skonfigurowano klucza API dla {provider}', + errAiRegion: + 'Dostawca tego modelu nie obsługuje Twojego obecnego regionu — wybierz inny model lub zmień sieć', errAiBusy: 'Usługa AI jest obecnie przeciążona — spróbuj ponownie za chwilę', errNoModel: 'Nie skonfigurowano nazwy modelu', menuFile: 'Plik', @@ -1539,6 +2593,7 @@ const tMain = createI18n({ menuPageSetup: 'Ustawienia strony…', menuExportPdf: 'Eksportuj jako PDF…', menuExportHtml: 'Eksportuj jako HTML…', + menuExportMarkdown: 'Eksportuj jako Markdown…', menuExportImages: 'Eksportuj jako obrazy…', menuPrint: 'Drukuj…', menuEdit: 'Edycja', @@ -1549,18 +2604,21 @@ const tMain = createI18n({ menuPaste: 'Wklej', menuPasteMatch: 'Wklej i dopasuj styl', menuFindReplace: 'Znajdź i zamień…', + menuGoTo: 'Przejdź do…', menuSelectAll: 'Zaznacz wszystko', menuView: 'Widok', + menuZoom: 'Powiększenie', menuZoomIn: 'Powiększ', menuZoomOut: 'Pomniejsz', menuZoom100: 'Rzeczywisty rozmiar (100%)', menuPageWidth: 'Szerokość strony', menuWholePage: 'Cała strona', + menuMultiPage: 'Wiele stron', menuAiSidebar: 'Pasek boczny AI', menuDarkMode: 'Tryb ciemny', menuFullscreen: 'Przejdź do pełnego ekranu', menuInsert: 'Wstaw', - menuInsertTable: 'Tabela (3×3)', + menuInsertTable: 'Tabela…', menuInsertImage: 'Obraz…', menuInsertPageBreak: 'Podział strony', menuInsertLink: 'Hiperłącze…', @@ -1578,16 +2636,57 @@ const tMain = createI18n({ menuFont: 'Czcionka…', menuParagraph: 'Akapit…', menuTools: 'Narzędzia', + menuTable: 'Tabela', + menuTableInsert: 'Wstaw', + menuTableInsertTable: 'Tabela…', + menuTableColsLeft: 'Kolumny z lewej', + menuTableColsRight: 'Kolumny z prawej', + menuTableRowsAbove: 'Wiersze powyżej', + menuTableRowsBelow: 'Wiersze poniżej', + menuTableCells: 'Komórki…', + menuTableDelete: 'Usuń', + menuTableDeleteTable: 'Tabela', + menuTableDeleteColumns: 'Kolumny', + menuTableDeleteRows: 'Wiersze', + menuTableSelect: 'Zaznacz', + menuTableSelectCell: 'Komórka', + menuTableSelectColumn: 'Kolumna', + menuTableSelectRow: 'Wiersz', + menuTableSelectTable: 'Tabela', + menuTableMergeCells: 'Scal komórki', + menuTableSplitCells: 'Podziel komórki…', + menuTableSplitTable: 'Podziel tabelę', + menuTableAutoFit: 'Autodopasowanie', + menuTableAutoFitContents: 'Dopasuj do zawartości', + menuTableAutoFitWindow: 'Dopasuj do okna', + menuTableFixedWidth: 'Stała szerokość kolumny', + menuTableDistributeRows: 'Rozłóż wiersze równomiernie', + menuTableDistributeColumns: 'Rozłóż kolumny równomiernie', + menuTableRepeatHeader: 'Powtórz wiersze nagłówka', + menuTableGridlines: 'Wyświetl linie siatki', + menuTableProperties: 'Właściwości tabeli…', menuWordCount: 'Statystyka wyrazów…', + menuAutoCorrect: 'Opcje Autokorekty…', + menuPreferences: 'Preferencje…', menuAiProofread: 'Korekta AI', menuWindow: 'Okno', menuHelp: 'Pomoc', menuShortcuts: 'Skróty klawiaturowe', - menuDocsHelp: 'Pomoc GenOffice Docs', }, cs: { dlgOpenDoc: 'Otevřít dokument', filterWord: 'Dokumenty Wordu', + odtImportMsg: 'Převést tento dokument .{ext}?', + odtImportDetail: + 'HermesOffice upravuje .docx, nikoli ODF. Text, nadpisy, seznamy, tabulky a obrázky se převedou; poznámky pod čarou, komentáře a textová pole se nepřevezmou úplně. Původní soubor se nikdy nemění; převedený .docx je samostatná kopie, do níž se dalším ukládáním zapisuje.', + hwpImportDetail: + 'HermesOffice upravuje .docx, nikoli HWP. Text, tabulky, obrázky, záhlaví a zápatí se převedou; rovnice, poznámky pod čarou a vložené objekty se nepřevedou a automatické číslování se změní na prostý text. Původní soubor se nikdy nemění; převedený .docx je samostatná kopie, do níž se dalším ukládáním zapisuje.', + odtImportConvertBtn: 'Převést a otevřít', + importSkipConfirm: 'Příště převést bez ptaní', + docImportMsg: 'Převést tento dokument .{ext}?', + docImportDetail: + 'HermesOffice upravuje .docx, nikoli dokumenty Word 97-2003 (.doc). Text, nadpisy, seznamy, tabulky a obrázky se převedou; poznámky pod čarou, komentáře a textová pole se nepřevezmou úplně. Původní soubor se nikdy nemění; převedený .docx je samostatná kopie, do níž se dalším ukládáním zapisuje.', + docImportConvertBtn: 'Převést a otevřít', dlgSaveAs: 'Uložit jako', closeUnsavedMsg: 'Tento dokument obsahuje neuložené změny.', closeUnsavedDetail: 'Chcete je před zavřením uložit?', @@ -1595,6 +2694,8 @@ const tMain = createI18n({ closeNoReplyDetail: 'Přesto zavřít? Neuložené změny budou ztraceny.', btnCloseAnyway: 'Přesto zavřít', autosaveFoundTitle: 'Nalezena obnovená verze', + autosaveDiskNewer: + 'Poznámka: soubor na disku byl změněn po uložení této kopie pro obnovení. Obnovení tyto novější změny nahradí.', autosaveFoundBody: 'Z poslední relace existují neuložené změny. Obnovit automaticky uloženou verzi?', autosaveRestore: 'Obnovit', @@ -1606,11 +2707,14 @@ const tMain = createI18n({ btnOverwrite: 'Přepsat', dlgInsertImage: 'Vložit obrázek', filterImages: 'Obrázky', + dlgMergeDataSource: 'Vybrat seznam příjemců', + filterMergeDataSource: 'Excel nebo CSV', dlgAddAttachment: 'Přidat přílohy', filterSupported: 'Podporované soubory', filterAll: 'Všechny soubory', dlgExportPdf: 'Exportovat jako PDF', dlgExportHtml: 'Exportovat jako HTML', + dlgExportMarkdown: 'Exportovat jako Markdown', dlgPickExportDir: 'Zvolte složku pro export', errUnsupportedExt: 'soubory .{ext} nejsou podporovány', errNotFile: 'není soubor', @@ -1626,6 +2730,8 @@ const tMain = createI18n({ errGskNotLoggedIn: 'Nejste přihlášeni do Genspark: klikněte níže na „Přihlásit se do Genspark“, přihlaste se a zkuste to znovu', errNoApiKey: 'Pro {provider} není nakonfigurován žádný klíč API', + errAiRegion: + 'Poskytovatel tohoto modelu neposkytuje služby ve vaší aktuální oblasti — vyberte jiný model nebo změňte síť', errAiBusy: 'Služba AI je právě zaneprázdněna — zkuste to prosím za chvíli znovu', errNoModel: 'Není nakonfigurován název modelu', menuFile: 'Soubor', @@ -1640,6 +2746,7 @@ const tMain = createI18n({ menuPageSetup: 'Vzhled stránky…', menuExportPdf: 'Exportovat jako PDF…', menuExportHtml: 'Exportovat jako HTML…', + menuExportMarkdown: 'Exportovat jako Markdown…', menuExportImages: 'Exportovat jako obrázky…', menuPrint: 'Tisk…', menuEdit: 'Úpravy', @@ -1650,18 +2757,21 @@ const tMain = createI18n({ menuPaste: 'Vložit', menuPasteMatch: 'Vložit a přizpůsobit styl', menuFindReplace: 'Najít a nahradit…', + menuGoTo: 'Přejít na…', menuSelectAll: 'Vybrat vše', menuView: 'Zobrazení', + menuZoom: 'Lupa', menuZoomIn: 'Zvětšit', menuZoomOut: 'Zmenšit', menuZoom100: 'Skutečná velikost (100 %)', menuPageWidth: 'Šířka stránky', menuWholePage: 'Celá stránka', + menuMultiPage: 'Více stránek', menuAiSidebar: 'Boční panel AI', menuDarkMode: 'Tmavý režim', menuFullscreen: 'Přejít na celou obrazovku', menuInsert: 'Vložení', - menuInsertTable: 'Tabulka (3×3)', + menuInsertTable: 'Tabulka…', menuInsertImage: 'Obrázek…', menuInsertPageBreak: 'Konec stránky', menuInsertLink: 'Hypertextový odkaz…', @@ -1679,16 +2789,57 @@ const tMain = createI18n({ menuFont: 'Písmo…', menuParagraph: 'Odstavec…', menuTools: 'Nástroje', + menuTable: 'Tabulka', + menuTableInsert: 'Vložit', + menuTableInsertTable: 'Tabulka…', + menuTableColsLeft: 'Sloupce vlevo', + menuTableColsRight: 'Sloupce vpravo', + menuTableRowsAbove: 'Řádky nad', + menuTableRowsBelow: 'Řádky pod', + menuTableCells: 'Buňky…', + menuTableDelete: 'Odstranit', + menuTableDeleteTable: 'Tabulka', + menuTableDeleteColumns: 'Sloupce', + menuTableDeleteRows: 'Řádky', + menuTableSelect: 'Vybrat', + menuTableSelectCell: 'Buňka', + menuTableSelectColumn: 'Sloupec', + menuTableSelectRow: 'Řádek', + menuTableSelectTable: 'Tabulka', + menuTableMergeCells: 'Sloučit buňky', + menuTableSplitCells: 'Rozdělit buňky…', + menuTableSplitTable: 'Rozdělit tabulku', + menuTableAutoFit: 'Přizpůsobit', + menuTableAutoFitContents: 'Přizpůsobit obsahu', + menuTableAutoFitWindow: 'Přizpůsobit oknu', + menuTableFixedWidth: 'Pevná šířka sloupce', + menuTableDistributeRows: 'Rozdělit řádky rovnoměrně', + menuTableDistributeColumns: 'Rozdělit sloupce rovnoměrně', + menuTableRepeatHeader: 'Opakovat řádky záhlaví', + menuTableGridlines: 'Zobrazit mřížku', + menuTableProperties: 'Vlastnosti tabulky…', menuWordCount: 'Počet slov…', + menuAutoCorrect: 'Možnosti automatických oprav…', + menuPreferences: 'Předvolby…', menuAiProofread: 'Korektura AI', menuWindow: 'Okno', menuHelp: 'Nápověda', menuShortcuts: 'Klávesové zkratky', - menuDocsHelp: 'Nápověda GenOffice Docs', }, nl: { dlgOpenDoc: 'Document openen', filterWord: 'Word-documenten', + odtImportMsg: 'Dit .{ext}-document converteren?', + odtImportDetail: + 'HermesOffice bewerkt .docx, niet ODF. Tekst, koppen, lijsten, tabellen en afbeeldingen worden omgezet; voetnoten, opmerkingen en tekstvakken niet volledig. Het oorspronkelijke bestand wordt nooit gewijzigd; de omgezette .docx is een zelfstandige kopie waarin volgende opslaan-acties terechtkomen.', + hwpImportDetail: + 'HermesOffice bewerkt .docx, niet HWP. Tekst, tabellen, afbeeldingen, kop- en voetteksten worden omgezet; vergelijkingen, voetnoten en ingesloten objecten niet, en automatische nummering wordt gewone tekst. Het oorspronkelijke bestand wordt nooit gewijzigd; de omgezette .docx is een zelfstandige kopie waarin volgende opslaan-acties terechtkomen.', + odtImportConvertBtn: 'Converteren en openen', + importSkipConfirm: 'Volgende keer converteren zonder te vragen', + docImportMsg: 'Dit .{ext}-document converteren?', + docImportDetail: + 'HermesOffice bewerkt .docx, geen Word 97-2003-documenten (.doc). Tekst, koppen, lijsten, tabellen en afbeeldingen worden omgezet; voetnoten, opmerkingen en tekstvakken niet volledig. Het oorspronkelijke bestand wordt nooit gewijzigd; de omgezette .docx is een zelfstandige kopie waarin volgende opslaan-acties terechtkomen.', + docImportConvertBtn: 'Converteren en openen', dlgSaveAs: 'Opslaan als', closeUnsavedMsg: 'Dit document bevat niet-opgeslagen wijzigingen.', closeUnsavedDetail: 'Wilt u ze opslaan voordat u sluit?', @@ -1696,6 +2847,8 @@ const tMain = createI18n({ closeNoReplyDetail: 'Toch sluiten? Niet-opgeslagen wijzigingen gaan verloren.', btnCloseAnyway: 'Toch sluiten', autosaveFoundTitle: 'Herstelde versie gevonden', + autosaveDiskNewer: + 'Let op: het bestand op schijf is gewijzigd nadat deze herstelkopie is opgeslagen. Herstellen vervangt die nieuwere wijzigingen.', autosaveFoundBody: 'Er zijn niet-opgeslagen wijzigingen van uw laatste sessie. De automatisch opgeslagen versie herstellen?', autosaveRestore: 'Herstellen', @@ -1707,11 +2860,14 @@ const tMain = createI18n({ btnOverwrite: 'Overschrijven', dlgInsertImage: 'Afbeelding invoegen', filterImages: 'Afbeeldingen', + dlgMergeDataSource: 'Ontvangerslijst selecteren', + filterMergeDataSource: 'Excel of CSV', dlgAddAttachment: 'Bijlagen toevoegen', filterSupported: 'Ondersteunde bestanden', filterAll: 'Alle bestanden', dlgExportPdf: 'Exporteren als PDF', dlgExportHtml: 'Exporteren als HTML', + dlgExportMarkdown: 'Exporteren als Markdown', dlgPickExportDir: 'Exportmap kiezen', errUnsupportedExt: '.{ext}-bestanden worden niet ondersteund', errNotFile: 'geen bestand', @@ -1728,6 +2884,8 @@ const tMain = createI18n({ errGskNotLoggedIn: 'Niet aangemeld bij Genspark: klik hieronder op “Aanmelden bij Genspark”, meld u aan en probeer het opnieuw', errNoApiKey: 'Geen API-sleutel geconfigureerd voor {provider}', + errAiRegion: + 'De aanbieder van dit model bedient uw huidige regio niet — kies een ander model of wissel van netwerk', errAiBusy: 'De AI-service is momenteel overbelast — probeer het zo opnieuw', errNoModel: 'Geen modelnaam geconfigureerd', menuFile: 'Bestand', @@ -1742,6 +2900,7 @@ const tMain = createI18n({ menuPageSetup: 'Pagina-instelling…', menuExportPdf: 'Exporteren als PDF…', menuExportHtml: 'Exporteren als HTML…', + menuExportMarkdown: 'Exporteren als Markdown…', menuExportImages: 'Exporteren als afbeeldingen…', menuPrint: 'Afdrukken…', menuEdit: 'Bewerken', @@ -1752,18 +2911,21 @@ const tMain = createI18n({ menuPaste: 'Plakken', menuPasteMatch: 'Plakken met dezelfde stijl', menuFindReplace: 'Zoeken en vervangen…', + menuGoTo: 'Ga naar…', menuSelectAll: 'Alles selecteren', menuView: 'Beeld', + menuZoom: 'Zoomen', menuZoomIn: 'Inzoomen', menuZoomOut: 'Uitzoomen', menuZoom100: 'Ware grootte (100%)', menuPageWidth: 'Paginabreedte', menuWholePage: 'Hele pagina', + menuMultiPage: "Meerdere pagina's", menuAiSidebar: 'AI-zijbalk', menuDarkMode: 'Donkere modus', menuFullscreen: 'Schermvullende weergave', menuInsert: 'Invoegen', - menuInsertTable: 'Tabel (3×3)', + menuInsertTable: 'Tabel…', menuInsertImage: 'Afbeelding…', menuInsertPageBreak: 'Pagina-einde', menuInsertLink: 'Hyperlink…', @@ -1781,16 +2943,57 @@ const tMain = createI18n({ menuFont: 'Lettertype…', menuParagraph: 'Alinea…', menuTools: 'Extra', + menuTable: 'Tabel', + menuTableInsert: 'Invoegen', + menuTableInsertTable: 'Tabel…', + menuTableColsLeft: 'Kolommen links', + menuTableColsRight: 'Kolommen rechts', + menuTableRowsAbove: 'Rijen boven', + menuTableRowsBelow: 'Rijen onder', + menuTableCells: 'Cellen…', + menuTableDelete: 'Verwijderen', + menuTableDeleteTable: 'Tabel', + menuTableDeleteColumns: 'Kolommen', + menuTableDeleteRows: 'Rijen', + menuTableSelect: 'Selecteren', + menuTableSelectCell: 'Cel', + menuTableSelectColumn: 'Kolom', + menuTableSelectRow: 'Rij', + menuTableSelectTable: 'Tabel', + menuTableMergeCells: 'Cellen samenvoegen', + menuTableSplitCells: 'Cellen splitsen…', + menuTableSplitTable: 'Tabel splitsen', + menuTableAutoFit: 'AutoAanpassen', + menuTableAutoFitContents: 'Aanpassen aan inhoud', + menuTableAutoFitWindow: 'Aanpassen aan venster', + menuTableFixedWidth: 'Vaste kolombreedte', + menuTableDistributeRows: 'Rijen gelijkmatig verdelen', + menuTableDistributeColumns: 'Kolommen gelijkmatig verdelen', + menuTableRepeatHeader: 'Koprijen herhalen', + menuTableGridlines: 'Rasterlijnen weergeven', + menuTableProperties: 'Tabeleigenschappen…', menuWordCount: 'Woorden tellen…', + menuAutoCorrect: 'AutoCorrectie-opties…', + menuPreferences: 'Voorkeuren…', menuAiProofread: 'AI-proeflezen', menuWindow: 'Venster', menuHelp: 'Help', menuShortcuts: 'Sneltoetsen', - menuDocsHelp: 'GenOffice Docs Help', }, ms: { dlgOpenDoc: 'Buka Dokumen', filterWord: 'Dokumen Word', + odtImportMsg: 'Tukar dokumen .{ext} ini?', + odtImportDetail: + 'HermesOffice menyunting .docx, bukan ODF. Teks kandungan, tajuk, senarai, jadual dan imej ditukar; nota kaki, komen dan kotak teks tidak dikekalkan sepenuhnya. Fail asal tidak pernah diubah; salinan .docx yang ditukar ialah salinan berasingan tempat simpanan seterusnya ditulis.', + hwpImportDetail: + 'HermesOffice menyunting .docx, bukan HWP. Teks kandungan, jadual, imej, pengepala dan pengaki ditukar; persamaan, nota kaki dan objek terbenam tidak ditukar, dan penomboran automatik menjadi teks biasa. Fail asal tidak pernah diubah; salinan .docx yang ditukar ialah salinan berasingan tempat simpanan seterusnya ditulis.', + odtImportConvertBtn: 'Tukar dan buka', + importSkipConfirm: 'Lain kali terus tukar tanpa bertanya', + docImportMsg: 'Tukar dokumen .{ext} ini?', + docImportDetail: + 'HermesOffice menyunting .docx, bukan dokumen Word 97-2003 (.doc). Teks kandungan, tajuk, senarai, jadual dan imej ditukar; nota kaki, komen dan kotak teks tidak dikekalkan sepenuhnya. Fail asal tidak pernah diubah; salinan .docx yang ditukar ialah salinan berasingan tempat simpanan seterusnya ditulis.', + docImportConvertBtn: 'Tukar dan buka', dlgSaveAs: 'Simpan Sebagai', closeUnsavedMsg: 'Dokumen ini mempunyai perubahan yang belum disimpan.', closeUnsavedDetail: 'Adakah anda mahu menyimpannya sebelum menutup?', @@ -1798,6 +3001,8 @@ const tMain = createI18n({ closeNoReplyDetail: 'Tutup juga? Perubahan yang belum disimpan akan hilang.', btnCloseAnyway: 'Tutup Juga', autosaveFoundTitle: 'Versi pulihan ditemui', + autosaveDiskNewer: + 'Nota: fail pada cakera telah diubah suai selepas salinan pemulihan ini disimpan. Memulihkan akan menggantikan perubahan yang lebih baharu itu.', autosaveFoundBody: 'Terdapat perubahan yang belum disimpan daripada sesi terakhir anda. Pulihkan versi yang disimpan secara automatik?', autosaveRestore: 'Pulihkan', @@ -1809,11 +3014,14 @@ const tMain = createI18n({ btnOverwrite: 'Tulis Ganti', dlgInsertImage: 'Sisipkan Imej', filterImages: 'Imej', + dlgMergeDataSource: 'Pilih senarai penerima', + filterMergeDataSource: 'Excel atau CSV', dlgAddAttachment: 'Tambah Lampiran', filterSupported: 'Fail yang Disokong', filterAll: 'Semua Fail', dlgExportPdf: 'Eksport sebagai PDF', dlgExportHtml: 'Eksport sebagai HTML', + dlgExportMarkdown: 'Eksport sebagai Markdown', dlgPickExportDir: 'Pilih Folder Eksport', errUnsupportedExt: 'fail .{ext} tidak disokong', errNotFile: 'bukan fail', @@ -1829,6 +3037,8 @@ const tMain = createI18n({ errGskNotLoggedIn: 'Belum log masuk ke Genspark: klik “Log masuk ke Genspark” di bawah, kemudian cuba lagi', errNoApiKey: 'Kunci API untuk {provider} belum dikonfigurasikan', + errAiRegion: + 'Penyedia model ini tidak memberi perkhidmatan di rantau semasa anda — pilih model lain atau tukar rangkaian', errAiBusy: 'Perkhidmatan AI sedang sibuk — sila cuba lagi sebentar lagi', errNoModel: 'Nama model belum dikonfigurasikan', menuFile: 'Fail', @@ -1843,6 +3053,7 @@ const tMain = createI18n({ menuPageSetup: 'Persediaan Halaman…', menuExportPdf: 'Eksport sebagai PDF…', menuExportHtml: 'Eksport sebagai HTML…', + menuExportMarkdown: 'Eksport sebagai Markdown…', menuExportImages: 'Eksport sebagai imej…', menuPrint: 'Cetak…', menuEdit: 'Edit', @@ -1853,18 +3064,21 @@ const tMain = createI18n({ menuPaste: 'Tampal', menuPasteMatch: 'Tampal dan Padankan Gaya', menuFindReplace: 'Cari dan Ganti…', + menuGoTo: 'Pergi ke…', menuSelectAll: 'Pilih Semua', menuView: 'Lihat', + menuZoom: 'Zum', menuZoomIn: 'Zum Masuk', menuZoomOut: 'Zum Keluar', menuZoom100: 'Saiz Sebenar (100%)', menuPageWidth: 'Lebar Halaman', menuWholePage: 'Seluruh Halaman', + menuMultiPage: 'Beberapa halaman', menuAiSidebar: 'Bar Sisi AI', menuDarkMode: 'Mod Gelap', menuFullscreen: 'Masuk Skrin Penuh', menuInsert: 'Sisip', - menuInsertTable: 'Jadual (3×3)', + menuInsertTable: 'Jadual…', menuInsertImage: 'Imej…', menuInsertPageBreak: 'Pemisah Halaman', menuInsertLink: 'Hiperpautan…', @@ -1882,16 +3096,57 @@ const tMain = createI18n({ menuFont: 'Fon…', menuParagraph: 'Perenggan…', menuTools: 'Alat', + menuTable: 'Jadual', + menuTableInsert: 'Sisipkan', + menuTableInsertTable: 'Jadual…', + menuTableColsLeft: 'Lajur di Kiri', + menuTableColsRight: 'Lajur di Kanan', + menuTableRowsAbove: 'Baris di Atas', + menuTableRowsBelow: 'Baris di Bawah', + menuTableCells: 'Sel…', + menuTableDelete: 'Padam', + menuTableDeleteTable: 'Jadual', + menuTableDeleteColumns: 'Lajur', + menuTableDeleteRows: 'Baris', + menuTableSelect: 'Pilih', + menuTableSelectCell: 'Sel', + menuTableSelectColumn: 'Lajur', + menuTableSelectRow: 'Baris', + menuTableSelectTable: 'Jadual', + menuTableMergeCells: 'Cantum Sel', + menuTableSplitCells: 'Pisahkan Sel…', + menuTableSplitTable: 'Pisahkan Jadual', + menuTableAutoFit: 'Autopadan', + menuTableAutoFitContents: 'Padan kepada Kandungan', + menuTableAutoFitWindow: 'Padan kepada Tetingkap', + menuTableFixedWidth: 'Lebar Lajur Tetap', + menuTableDistributeRows: 'Agihkan Baris Sama Rata', + menuTableDistributeColumns: 'Agihkan Lajur Sama Rata', + menuTableRepeatHeader: 'Ulang Baris Pengepala', + menuTableGridlines: 'Lihat Garis Grid', + menuTableProperties: 'Sifat Jadual…', menuWordCount: 'Kiraan Perkataan…', + menuAutoCorrect: 'Pilihan AutoBetul…', + menuPreferences: 'Keutamaan…', menuAiProofread: 'Pembacaan Pruf AI', menuWindow: 'Tetingkap', menuHelp: 'Bantuan', menuShortcuts: 'Pintasan Papan Kekunci', - menuDocsHelp: 'Bantuan GenOffice Docs', }, he: { dlgOpenDoc: 'פתיחת מסמך', filterWord: 'מסמכי Word', + odtImportMsg: 'להמיר את מסמך ה‑.{ext} הזה?', + odtImportDetail: + 'HermesOffice עורך קבצי ‎.docx ולא ODF. טקסט, כותרות, רשימות, טבלאות ותמונות מומרים; הערות שוליים, תגובות ותיבות טקסט לא נשמרים במלואם. קובץ המקור לעולם אינו משתנה; קובץ ה‑.docx שהומר הוא עותק נפרד שאליו נכתבות השמירות הבאות.', + hwpImportDetail: + 'HermesOffice עורך קבצי ‎.docx ולא HWP. טקסט, טבלאות, תמונות, כותרות עליונות ותחתונות מומרים; משוואות, הערות שוליים ואובייקטים מוטמעים אינם מומרים, ומספור אוטומטי הופך לטקסט רגיל. קובץ המקור לעולם אינו משתנה; קובץ ה‑.docx שהומר הוא עותק נפרד שאליו נכתבות השמירות הבאות.', + odtImportConvertBtn: 'המר ופתח', + importSkipConfirm: 'בפעם הבאה להמיר בלי לשאול', + docImportMsg: 'להמיר את מסמך ה‑.{ext} הזה?', + docImportDetail: + 'HermesOffice עורך קבצי ‎.docx ולא מסמכי Word 97-2003 (‎.doc). טקסט, כותרות, רשימות, טבלאות ותמונות מומרים; הערות שוליים, תגובות ותיבות טקסט לא נשמרים במלואם. קובץ המקור לעולם אינו משתנה; קובץ ה‑.docx שהומר הוא עותק נפרד שאליו נכתבות השמירות הבאות.', + docImportConvertBtn: 'המר ופתח', dlgSaveAs: 'שמירה בשם', closeUnsavedMsg: 'במסמך זה יש שינויים שלא נשמרו.', closeUnsavedDetail: 'האם לשמור אותם לפני הסגירה?', @@ -1899,6 +3154,8 @@ const tMain = createI18n({ closeNoReplyDetail: 'לסגור בכל זאת? שינויים שלא נשמרו יאבדו.', btnCloseAnyway: 'סגור בכל זאת', autosaveFoundTitle: 'נמצאה גרסה משוחזרת', + autosaveDiskNewer: + 'שים לב: הקובץ בדיסק שונה לאחר שמירת עותק השחזור הזה. שחזור יחליף את השינויים החדשים יותר.', autosaveFoundBody: 'קיימים שינויים שלא נשמרו מהפעלה הקודמת. לשחזר את הגרסה שנשמרה אוטומטית?', autosaveRestore: 'שחזר', autosaveDiscard: 'התעלם', @@ -1909,11 +3166,14 @@ const tMain = createI18n({ btnOverwrite: 'דרוס', dlgInsertImage: 'הוספת תמונה', filterImages: 'תמונות', + dlgMergeDataSource: 'בחירת רשימת נמענים', + filterMergeDataSource: 'Excel או CSV', dlgAddAttachment: 'הוספת קבצים מצורפים', filterSupported: 'קבצים נתמכים', filterAll: 'כל הקבצים', dlgExportPdf: 'ייצוא כ-PDF', dlgExportHtml: 'ייצוא כ-HTML', + dlgExportMarkdown: 'ייצוא כ-Markdown', dlgPickExportDir: 'בחירת תיקיית ייצוא', errUnsupportedExt: 'קובצי .{ext} אינם נתמכים', errNotFile: 'אינו קובץ', @@ -1928,6 +3188,7 @@ const tMain = createI18n({ errAttachmentNotGranted: 'הקובץ לא אושר כקובץ מצורף ולא ניתן לקרוא אותו', errGskNotLoggedIn: 'לא מחובר ל-Genspark: לחץ על "התחבר ל-Genspark" למטה, התחבר ונסה שוב', errNoApiKey: 'לא הוגדר מפתח API עבור {provider}', + errAiRegion: 'ספק המודל הזה אינו פועל באזור הנוכחי שלך — בחרו מודל אחר או החליפו רשת', errAiBusy: 'שירות ה-AI עמוס כרגע — נסו שוב בעוד רגע', errNoModel: 'לא הוגדר שם מודל', menuFile: 'קובץ', @@ -1942,6 +3203,7 @@ const tMain = createI18n({ menuPageSetup: 'הגדרת עמוד…', menuExportPdf: 'ייצוא כ-PDF…', menuExportHtml: 'ייצוא כ-HTML…', + menuExportMarkdown: 'ייצוא כ-Markdown…', menuExportImages: 'ייצוא כתמונות…', menuPrint: 'הדפסה…', menuEdit: 'עריכה', @@ -1952,18 +3214,21 @@ const tMain = createI18n({ menuPaste: 'הדבק', menuPasteMatch: 'הדבק והתאם סגנון', menuFindReplace: 'חיפוש והחלפה…', + menuGoTo: 'עבור אל…', menuSelectAll: 'בחר הכול', menuView: 'תצוגה', + menuZoom: 'זום', menuZoomIn: 'התקרבות', menuZoomOut: 'התרחקות', menuZoom100: 'גודל אמיתי (100%)', menuPageWidth: 'רוחב עמוד', menuWholePage: 'עמוד שלם', + menuMultiPage: 'עמודים מרובים', menuAiSidebar: 'סרגל צד AI', menuDarkMode: 'מצב כהה', menuFullscreen: 'מעבר למסך מלא', menuInsert: 'הוספה', - menuInsertTable: 'טבלה (3×3)', + menuInsertTable: 'טבלה…', menuInsertImage: 'תמונה…', menuInsertPageBreak: 'מעבר עמוד', menuInsertLink: 'היפר-קישור…', @@ -1981,16 +3246,57 @@ const tMain = createI18n({ menuFont: 'גופן…', menuParagraph: 'פסקה…', menuTools: 'כלים', + menuTable: 'טבלה', + menuTableInsert: 'הוסף', + menuTableInsertTable: 'טבלה…', + menuTableColsLeft: 'עמודות משמאל', + menuTableColsRight: 'עמודות מימין', + menuTableRowsAbove: 'שורות מעל', + menuTableRowsBelow: 'שורות מתחת', + menuTableCells: 'תאים…', + menuTableDelete: 'מחק', + menuTableDeleteTable: 'טבלה', + menuTableDeleteColumns: 'עמודות', + menuTableDeleteRows: 'שורות', + menuTableSelect: 'בחר', + menuTableSelectCell: 'תא', + menuTableSelectColumn: 'עמודה', + menuTableSelectRow: 'שורה', + menuTableSelectTable: 'טבלה', + menuTableMergeCells: 'מזג תאים', + menuTableSplitCells: 'פצל תאים…', + menuTableSplitTable: 'פצל טבלה', + menuTableAutoFit: 'התאמה אוטומטית', + menuTableAutoFitContents: 'התאם לתוכן', + menuTableAutoFitWindow: 'התאם לחלון', + menuTableFixedWidth: 'רוחב עמודה קבוע', + menuTableDistributeRows: 'פזר שורות באופן שווה', + menuTableDistributeColumns: 'פזר עמודות באופן שווה', + menuTableRepeatHeader: 'חזור על שורות כותרת', + menuTableGridlines: 'הצג קווי רשת', + menuTableProperties: 'מאפייני טבלה…', menuWordCount: 'ספירת מילים…', + menuAutoCorrect: 'אפשרויות תיקון אוטומטי…', + menuPreferences: 'העדפות…', menuAiProofread: 'הגהת AI', menuWindow: 'חלון', menuHelp: 'עזרה', menuShortcuts: 'קיצורי מקלדת', - menuDocsHelp: 'עזרה של GenOffice Docs', }, hi: { dlgOpenDoc: 'दस्तावेज़ खोलें', filterWord: 'Word दस्तावेज़', + odtImportMsg: 'इस .{ext} दस्तावेज़ को बदलें?', + odtImportDetail: + 'HermesOffice .docx संपादित करता है, ODF नहीं। मुख्य पाठ, शीर्षक, सूचियाँ, तालिकाएँ और चित्र बदल दिए जाते हैं; फुटनोट, टिप्पणियाँ और टेक्स्ट बॉक्स पूरी तरह नहीं रखे जाते। मूल फ़ाइल कभी नहीं बदलती; बदली हुई .docx एक स्वतंत्र प्रति है जिसमें आगे के सेव लिखे जाते हैं।', + hwpImportDetail: + 'HermesOffice .docx संपादित करता है, HWP नहीं। मुख्य पाठ, तालिकाएँ, चित्र, हेडर और फुटर बदल दिए जाते हैं; समीकरण, फुटनोट और एम्बेड किए गए ऑब्जेक्ट नहीं बदले जाते, और स्वचालित क्रमांकन सामान्य पाठ बन जाता है। मूल फ़ाइल कभी नहीं बदलती; बदली हुई .docx एक स्वतंत्र प्रति है जिसमें आगे के सेव लिखे जाते हैं।', + odtImportConvertBtn: 'बदलकर खोलें', + importSkipConfirm: 'अगली बार बिना पूछे बदलें', + docImportMsg: 'इस .{ext} दस्तावेज़ को बदलें?', + docImportDetail: + 'HermesOffice Word 97-2003 (.doc) दस्तावेज़ नहीं, .docx संपादित करता है। मुख्य पाठ, शीर्षक, सूचियाँ, तालिकाएँ और चित्र बदल दिए जाते हैं; फुटनोट, टिप्पणियाँ और टेक्स्ट बॉक्स पूरी तरह नहीं रखे जाते। मूल फ़ाइल कभी नहीं बदलती; बदली हुई .docx एक स्वतंत्र प्रति है जिसमें आगे के सेव लिखे जाते हैं।', + docImportConvertBtn: 'बदलकर खोलें', dlgSaveAs: 'इस रूप में सहेजें', closeUnsavedMsg: 'इस दस्तावेज़ में सहेजे नहीं गए परिवर्तन हैं।', closeUnsavedDetail: 'क्या आप बंद करने से पहले उन्हें सहेजना चाहते हैं?', @@ -1998,6 +3304,8 @@ const tMain = createI18n({ closeNoReplyDetail: 'फिर भी बंद करें? सहेजे न गए बदलाव खो जाएँगे।', btnCloseAnyway: 'फिर भी बंद करें', autosaveFoundTitle: 'पुनर्प्राप्त संस्करण मिला', + autosaveDiskNewer: + 'ध्यान दें: यह पुनर्प्राप्ति प्रति सहेजे जाने के बाद डिस्क पर फ़ाइल में बदलाव किया गया है। पुनर्स्थापित करने पर वे नए बदलाव बदल दिए जाएँगे।', autosaveFoundBody: 'आपके पिछले सत्र से सहेजे नहीं गए परिवर्तन हैं। स्वतः सहेजा गया संस्करण पुनर्स्थापित करें?', autosaveRestore: 'पुनर्स्थापित करें', @@ -2009,11 +3317,14 @@ const tMain = createI18n({ btnOverwrite: 'अधिलेखित करें', dlgInsertImage: 'छवि सम्मिलित करें', filterImages: 'छवियाँ', + dlgMergeDataSource: 'प्राप्तकर्ता सूची चुनें', + filterMergeDataSource: 'Excel या CSV', dlgAddAttachment: 'अनुलग्नक जोड़ें', filterSupported: 'समर्थित फ़ाइलें', filterAll: 'सभी फ़ाइलें', dlgExportPdf: 'PDF के रूप में निर्यात करें', dlgExportHtml: 'HTML के रूप में निर्यात करें', + dlgExportMarkdown: 'Markdown के रूप में निर्यात करें', dlgPickExportDir: 'निर्यात फ़ोल्डर चुनें', errUnsupportedExt: '.{ext} फ़ाइलें समर्थित नहीं हैं', errNotFile: 'फ़ाइल नहीं है', @@ -2029,6 +3340,8 @@ const tMain = createI18n({ errGskNotLoggedIn: 'Genspark में साइन इन नहीं है: नीचे “Genspark में साइन इन करें” पर क्लिक करें, साइन इन करें और फिर से कोशिश करें', errNoApiKey: '{provider} के लिए कोई API कुंजी कॉन्फ़िगर नहीं है', + errAiRegion: + 'इस मॉडल का प्रदाता आपके वर्तमान क्षेत्र में सेवा नहीं देता — कोई दूसरा मॉडल चुनें या नेटवर्क बदलें', errAiBusy: 'AI सेवा अभी व्यस्त है — कृपया थोड़ी देर बाद फिर से प्रयास करें', errNoModel: 'कोई मॉडल नाम कॉन्फ़िगर नहीं है', menuFile: 'फ़ाइल', @@ -2043,6 +3356,7 @@ const tMain = createI18n({ menuPageSetup: 'पृष्ठ सेटअप…', menuExportPdf: 'PDF के रूप में निर्यात करें…', menuExportHtml: 'HTML के रूप में निर्यात करें…', + menuExportMarkdown: 'Markdown के रूप में निर्यात करें…', menuExportImages: 'छवियों के रूप में निर्यात…', menuPrint: 'प्रिंट करें…', menuEdit: 'संपादन', @@ -2053,18 +3367,21 @@ const tMain = createI18n({ menuPaste: 'चिपकाएँ', menuPasteMatch: 'चिपकाएँ और शैली मिलाएँ', menuFindReplace: 'ढूँढें और बदलें…', + menuGoTo: 'यहाँ जाएँ…', menuSelectAll: 'सभी चुनें', menuView: 'दृश्य', + menuZoom: 'ज़ूम', menuZoomIn: 'ज़ूम इन', menuZoomOut: 'ज़ूम आउट', menuZoom100: 'वास्तविक आकार (100%)', menuPageWidth: 'पृष्ठ चौड़ाई', menuWholePage: 'पूरा पृष्ठ', + menuMultiPage: 'एकाधिक पृष्ठ', menuAiSidebar: 'AI साइडबार', menuDarkMode: 'डार्क मोड', menuFullscreen: 'पूर्ण स्क्रीन में जाएँ', menuInsert: 'सम्मिलित करें', - menuInsertTable: 'तालिका (3×3)', + menuInsertTable: 'तालिका…', menuInsertImage: 'छवि…', menuInsertPageBreak: 'पृष्ठ विराम', menuInsertLink: 'हाइपरलिंक…', @@ -2082,16 +3399,57 @@ const tMain = createI18n({ menuFont: 'फ़ॉन्ट…', menuParagraph: 'अनुच्छेद…', menuTools: 'उपकरण', + menuTable: 'तालिका', + menuTableInsert: 'सम्मिलित करें', + menuTableInsertTable: 'तालिका…', + menuTableColsLeft: 'बाईं ओर स्तंभ', + menuTableColsRight: 'दाईं ओर स्तंभ', + menuTableRowsAbove: 'ऊपर पंक्तियाँ', + menuTableRowsBelow: 'नीचे पंक्तियाँ', + menuTableCells: 'कक्ष…', + menuTableDelete: 'हटाएँ', + menuTableDeleteTable: 'तालिका', + menuTableDeleteColumns: 'स्तंभ', + menuTableDeleteRows: 'पंक्तियाँ', + menuTableSelect: 'चुनें', + menuTableSelectCell: 'कक्ष', + menuTableSelectColumn: 'स्तंभ', + menuTableSelectRow: 'पंक्ति', + menuTableSelectTable: 'तालिका', + menuTableMergeCells: 'कक्ष मर्ज करें', + menuTableSplitCells: 'कक्ष विभाजित करें…', + menuTableSplitTable: 'तालिका विभाजित करें', + menuTableAutoFit: 'स्वतः फ़िट', + menuTableAutoFitContents: 'सामग्री के अनुसार फ़िट', + menuTableAutoFitWindow: 'विंडो के अनुसार फ़िट', + menuTableFixedWidth: 'निश्चित स्तंभ चौड़ाई', + menuTableDistributeRows: 'पंक्तियाँ समान रूप से बाँटें', + menuTableDistributeColumns: 'स्तंभ समान रूप से बाँटें', + menuTableRepeatHeader: 'शीर्ष पंक्तियाँ दोहराएँ', + menuTableGridlines: 'ग्रिडलाइन देखें', + menuTableProperties: 'तालिका गुण…', menuWordCount: 'शब्द गणना…', + menuAutoCorrect: 'स्वतः सुधार विकल्प…', + menuPreferences: 'प्राथमिकताएँ…', menuAiProofread: 'AI प्रूफ़रीडिंग', menuWindow: 'विंडो', menuHelp: 'सहायता', menuShortcuts: 'कीबोर्ड शॉर्टकट', - menuDocsHelp: 'GenOffice Docs सहायता', }, 'zh-TW': { dlgOpenDoc: '開啟文件', filterWord: 'Word 文件', + odtImportMsg: '轉換這個 .{ext} 文件?', + odtImportDetail: + 'HermesOffice 只編輯 .docx,不編輯 ODF。內文、標題、清單、表格與圖片會轉換過來;註腳、註解與文字方塊不會完整保留。原始檔案不會被修改,轉換後的 .docx 是獨立副本,之後的儲存都寫入它。', // public-hygiene: allow + hwpImportDetail: + 'HermesOffice 只編輯 .docx,不編輯 HWP。內文、表格、圖片、頁首與頁尾會轉換過來;方程式、註腳與內嵌物件不會轉換,自動編號會變成一般文字。原始檔案不會被修改,轉換後的 .docx 是獨立副本,之後的儲存都寫入它。', // public-hygiene: allow + odtImportConvertBtn: '轉換並開啟', + importSkipConfirm: '下次直接轉換,不再提示', + docImportMsg: '轉換這個 .{ext} 文件?', + docImportDetail: + 'HermesOffice 只編輯 .docx,不編輯 Word 97-2003 舊版 .doc。內文、標題、清單、表格與圖片會轉換過來;註腳、註解與文字方塊不會完整保留。原始檔案不會被修改,轉換後的 .docx 是獨立副本,之後的儲存都寫入它。', // public-hygiene: allow + docImportConvertBtn: '轉換並開啟', dlgSaveAs: '另存新檔', closeUnsavedMsg: '此文件有未儲存的變更。', closeUnsavedDetail: '關閉前是否要儲存?', @@ -2099,6 +3457,7 @@ const tMain = createI18n({ closeNoReplyDetail: '仍要關閉嗎?未儲存的變更將遺失。', btnCloseAnyway: '仍要關閉', autosaveFoundTitle: '發現自動復原版本', + autosaveDiskNewer: '注意:此復原副本儲存後,磁碟上的檔案又被修改過。復原將取代這些較新的變更。', autosaveFoundBody: '上次工作階段有未儲存的變更。要復原自動儲存的版本嗎?', autosaveRestore: '復原', autosaveDiscard: '放棄', @@ -2109,11 +3468,14 @@ const tMain = createI18n({ btnOverwrite: '覆寫', dlgInsertImage: '插入圖片', filterImages: '圖片', + dlgMergeDataSource: '選擇收件人清單', + filterMergeDataSource: 'Excel 或 CSV', dlgAddAttachment: '新增附件', filterSupported: '支援的檔案', filterAll: '所有檔案', dlgExportPdf: '匯出為 PDF', dlgExportHtml: '匯出為 HTML', + dlgExportMarkdown: '匯出為 Markdown', dlgPickExportDir: '選擇匯出目錄', errUnsupportedExt: '暫不支援 .{ext} 類型', errNotFile: '不是檔案', @@ -2127,6 +3489,7 @@ const tMain = createI18n({ errAttachmentNotGranted: '此檔案未作為附件新增,無法讀取', errGskNotLoggedIn: '未登入 Genspark:請點擊下方「登入 Genspark」完成登入後重試', errNoApiKey: '未設定 {provider} 的 API Key', + errAiRegion: '該模型的廠商不向目前地區提供服務,請換一個模型或切換網路', errAiBusy: 'AI 服務目前繁忙,請稍後重試', errNoModel: '未設定模型名稱', menuFile: '檔案', @@ -2141,6 +3504,7 @@ const tMain = createI18n({ menuPageSetup: '版面設定…', menuExportPdf: '匯出為 PDF…', menuExportHtml: '匯出為 HTML…', + menuExportMarkdown: '匯出為 Markdown…', menuExportImages: '匯出為圖片…', menuPrint: '列印…', menuEdit: '編輯', @@ -2151,18 +3515,21 @@ const tMain = createI18n({ menuPaste: '貼上', menuPasteMatch: '貼上並符合格式', menuFindReplace: '尋找與取代…', + menuGoTo: '定位…', menuSelectAll: '全選', menuView: '檢視', + menuZoom: '縮放', menuZoomIn: '放大', menuZoomOut: '縮小', menuZoom100: '實際大小 (100%)', menuPageWidth: '頁面寬度', menuWholePage: '整頁', + menuMultiPage: '多頁', menuAiSidebar: 'AI 側邊欄', menuDarkMode: '深色模式', menuFullscreen: '進入全螢幕', menuInsert: '插入', - menuInsertTable: '表格(3×3)', + menuInsertTable: '表格…', menuInsertImage: '圖片…', menuInsertPageBreak: '分頁符號', menuInsertLink: '超連結…', @@ -2180,12 +3547,42 @@ const tMain = createI18n({ menuFont: '字型…', menuParagraph: '段落…', menuTools: '工具', + menuTable: '表格', + menuTableInsert: '插入', + menuTableInsertTable: '表格…', + menuTableColsLeft: '在左側插入欄', + menuTableColsRight: '在右側插入欄', + menuTableRowsAbove: '在上方插入列', + menuTableRowsBelow: '在下方插入列', + menuTableCells: '儲存格…', + menuTableDelete: '刪除', + menuTableDeleteTable: '表格', + menuTableDeleteColumns: '欄', + menuTableDeleteRows: '列', + menuTableSelect: '選取', + menuTableSelectCell: '儲存格', + menuTableSelectColumn: '欄', + menuTableSelectRow: '列', + menuTableSelectTable: '表格', + menuTableMergeCells: '合併儲存格', + menuTableSplitCells: '分割儲存格…', + menuTableSplitTable: '拆分表格', + menuTableAutoFit: '自動調整', + menuTableAutoFitContents: '根據內容自動調整', + menuTableAutoFitWindow: '根據視窗自動調整', + menuTableFixedWidth: '固定欄寬', + menuTableDistributeRows: '平均分佈各列', + menuTableDistributeColumns: '平均分佈各欄', + menuTableRepeatHeader: '重複標題列', + menuTableGridlines: '檢視格線', + menuTableProperties: '表格屬性…', menuWordCount: '字數統計…', + menuAutoCorrect: '自動校正選項…', + menuPreferences: '偏好設定…', menuAiProofread: 'AI 校對', menuWindow: '視窗', menuHelp: '說明', menuShortcuts: '鍵盤快速鍵', - menuDocsHelp: 'GenOffice Docs 說明', }, }) const tm = (key: Parameters[1], params?: Parameters[2]) => @@ -2218,7 +3615,7 @@ export function configureDocsRuntime(config: DocsRuntimeConfig): void { let mainWindow: BrowserWindow | null = null let rendererReady = false -let pendingOpenPath = findDocxPath(process.argv) +let pendingOpenPath = findOpenableDocPath(process.argv) /** documents queued for windows/tabs spawned via New Tab, keyed by webContents id */ const pendingWindowOpens = new Map() /** webContents ids that should open as a new blank doc instead of the start screen */ @@ -2243,6 +3640,21 @@ export function setDocsShellWindow(win: BrowserWindow | null): void { docsShellWindow = win } +/** the window hosting a tab's WebContentsView when BrowserWindow.fromWebContents + * cannot tell (detached "Open in New Window" editors) */ +let hostWindowHook: ((wc: WebContents) => BrowserWindow | undefined) | null = null +export function setDocsHostWindowHook( + fn: ((wc: WebContents) => BrowserWindow | undefined) | null, +): void { + hostWindowHook = fn +} + +function hostWindowFor(wc: WebContents | null | undefined): BrowserWindow | undefined { + const own = wc && (hostWindowHook?.(wc) ?? BrowserWindow.fromWebContents(wc)) + if (own && !own.isDestroyed()) return own + return docsShellWindow && !docsShellWindow.isDestroyed() ? docsShellWindow : undefined +} + /** injected by the shell in tab mode: resolves the webContents of the currently active docs tab, * used for menu-command forwarding where there is no IpcMainInvokeEvent to key off of. */ let activeDocsResolver: (() => WebContents | null) | null = null @@ -2255,9 +3667,10 @@ function activeDocsWebContents(): WebContents | null { return BrowserWindow.getFocusedWindow()?.webContents ?? mainWindow?.webContents ?? null } -/** dialog parent for the calling tab (standalone mode falls back to its own BrowserWindow) */ +/** dialog parent for the calling tab: the sender's own window when it has one + * (standalone mode, detached "Open in New Window" editors), else the shell window */ function dialogParent(event: IpcMainInvokeEvent): BrowserWindow | undefined { - return docsShellWindow ?? BrowserWindow.fromWebContents(event.sender) ?? undefined + return hostWindowFor(event.sender) } async function openDialog(event: IpcMainInvokeEvent, options: OpenDialogOptions) { @@ -2267,26 +3680,63 @@ async function openDialog(event: IpcMainInvokeEvent, options: OpenDialogOptions) async function saveDialog(event: IpcMainInvokeEvent, options: SaveDialogOptions) { // 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()) + 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. User-configurable (app-settings.json), falls back to /GenOffice. */ +/** 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 { return configuredDefaultSaveDir(app) } -// shared hardened implementation lives in @hermesoffice/electron-utils; -// re-exported because the shell imports it from this module -export { uniquePathIn } +/** first free path for fileName inside dir: name.ext, name-2.ext, name-3.ext… */ +export function uniquePathIn(dir: string, fileName: string): string { + const dot = fileName.lastIndexOf('.') + const base = dot > 0 ? fileName.slice(0, dot) : fileName + const ext = dot > 0 ? fileName.slice(dot) : '' + let candidate = join(dir, fileName) + for (let i = 2; existsSync(candidate); i++) candidate = join(dir, `${base}-${i}${ext}`) + return candidate +} + +/** + * An encrypted file the window holds no password for does not become a + * document yet: loadDocx hands back a password marker with no side effects and + * the renderer runs the replace guard once the password decrypted it + * (submitDocPwd). Guarding here as well would prompt twice, and a Don't Save + * answer would drop the recovery copy before anything replaced the document. + */ +async function opensAsPasswordPrompt(filePath: string, wcId: number): Promise { + if (docPasswordFor(wcId, filePath)) return false + try { + return isEncryptedDocx(await readFile(filePath)) + } catch { + return false + } +} export function openExternalDocx(filePath: string | null): void { - if (!filePath || !/\.docx$/i.test(filePath)) return + if (!filePath) return + // .odt/.ott and legacy Word .doc/.dot join the flow as an import (converted + // .docx copy), like the shell route + if (!isOpenableDocPath(filePath)) return const win = BrowserWindow.getFocusedWindow() ?? mainWindow if (!rendererReady || !win) { pendingOpenPath = filePath return } - void loadDocx(filePath, win.webContents.id) + void (async () => { + const wcId = win.webContents.id + const prepared = await prepareDocumentForOpen(filePath, win) + if (!prepared) return null + if ( + rendererReady && + !(await opensAsPasswordPrompt(prepared, wcId)) && + !(await requestDocsClose(win.webContents, win)) + ) { + return + } + return loadDocx(prepared, wcId) + })() .then((result) => { if (!result || win.isDestroyed()) return if (win.isMinimized()) win.restore() @@ -2310,14 +3760,10 @@ function readJson(path: string, fallback: T): T { return fallback } -function writeJson(path: string, value: unknown): void { - mkdirSync(join(path, '..'), { recursive: true }) - writeFileSync(path, JSON.stringify(value, null, 2)) -} - // ---- recent files ---- const RECENT_PATH = () => userDataPath('recent.json') +const APP_SETTINGS_PATH = () => join(app.getPath('userData'), 'app-settings.json') // the home screen lists all of these; the File menu shows only the first few const RECENT_LIMIT = 100 @@ -2328,7 +3774,12 @@ function pushRecent(filePath: string): void { // rebuild when the file is already at the head of the list if (recent[0] === filePath) return const next = [filePath, ...recent.filter((p) => p !== filePath)].slice(0, RECENT_LIMIT) - writeJson(RECENT_PATH(), next) + try { + writeJsonAtomic(RECENT_PATH(), next) + } catch (err) { + // the document itself is already saved; a lost recents entry must not fail the save + console.warn('[docs] recent.json write failed:', err) + } buildDocsMenu() // keep File > Open Recent in sync } @@ -2346,7 +3797,7 @@ export function recordRecentFile(filePath: string): void { export function removeRecentFiles(filePaths: string[]): void { const drop = new Set(filePaths) const recent = readJson(RECENT_PATH(), []) - writeJson( + writeJsonAtomic( RECENT_PATH(), recent.filter((p) => !drop.has(p)), ) @@ -2360,6 +3811,7 @@ export function docsFileRenamed(wc: WebContents, oldPath: string, newPath: strin // keep the save allowlist in sync so docs:save accepts the renamed path docWritablePaths.get(wc.id)?.delete(oldPath) allowDocWrite(wc.id, newPath) + rememberOpenDoc(wc.id, newPath) const states = docDiskStates.get(wc.id) const recorded = states?.get(oldPath) if (states && recorded) { @@ -2369,27 +3821,25 @@ export function docsFileRenamed(wc: WebContents, oldPath: string, newPath: strin // an encrypted document's password must follow the path, or the next save // finds no password under the new name and silently writes plaintext renameDocPassword(wc.id, oldPath, newPath) + moveLazyMediaSource(oldPath, newPath) wc.send('docs:renamed', { oldPath, newPath }) } /** keep a renamed file at its old position in the recent/starred lists */ export function replaceRecentFile(oldPath: string, newPath: string): void { const recent = readJson(RECENT_PATH(), []) - writeJson( + writeJsonAtomic( RECENT_PATH(), recent.map((p) => (p === oldPath ? newPath : p)), ) - const starred = readJson(STARRED_PATH(), []) - if (starred.includes(oldPath)) { - writeJson( - STARRED_PATH(), - starred.map((p) => (p === oldPath ? newPath : p)), - ) - } + const renamed = renameStarredItem(readStarredItems(STARRED_PATH()), oldPath, newPath) + if (renamed) writeStarredItems(STARRED_PATH(), renamed) buildDocsMenu() } // ---- starred files (home screen favorites) ---- +// the store lives in starred-files.ts (pure, unit-tested); legacy flat +// string[] files migrate to the versioned shape on the first write const STARRED_PATH = () => userDataPath('starred.json') @@ -2397,25 +3847,37 @@ const STARRED_PATH = () => userDataPath('starred.json') * unavailable starred file must keep its star and its Starred-view row — * filtering here also desynced the star state shown on recents rows (r158) */ export function readStarredFiles(): string[] { - return readJson(STARRED_PATH(), []) + return readStarredItems(STARRED_PATH()).map((item) => item.path) } export function toggleStarredFile(filePath: string): void { - const starred = readJson(STARRED_PATH(), []) - const next = starred.includes(filePath) - ? starred.filter((p) => p !== filePath) - : [...starred, filePath] - writeJson(STARRED_PATH(), next) + writeStarredItems(STARRED_PATH(), toggleStarredItem(readStarredItems(STARRED_PATH()), filePath)) } -/** Bulk unstar (in-app delete, or removing an unavailable entry from the - * recents list): the star must not outlive the row it pointed at (r158) */ +/** Bulk unstar (in-app delete, bulk unfollow from the Starred view, or + * removing an unavailable entry from the recents list): the star must not + * outlive the row it pointed at (r158) */ export function removeStarredFiles(filePaths: string[]): void { - const drop = new Set(filePaths) - if (drop.size === 0) return - const starred = readJson(STARRED_PATH(), []) - const next = starred.filter((p) => !drop.has(p)) - if (next.length !== starred.length) writeJson(STARRED_PATH(), next) + const next = dropStarredItems(readStarredItems(STARRED_PATH()), filePaths) + if (next) writeStarredItems(STARRED_PATH(), next) +} + +/** put each starred path into `group` (null = back to ungrouped). Groups have + * no separate list: a group exists while an entry carries its name, so + * creating one is assigning its first file and emptying one is implicit. */ +export function setStarredGroup(filePaths: string[], group: string | null): void { + const next = assignStarredGroup(readStarredItems(STARRED_PATH()), filePaths, group) + if (next) writeStarredItems(STARRED_PATH(), next) +} + +/** group names that currently have at least one starred file, first-seen order */ +export function readStarredGroups(): string[] { + return starredGroupNames(readStarredItems(STARRED_PATH())) +} + +/** starred path → its group (ungrouped paths absent); feeds the home IPC entries */ +export function readStarredGroupMap(): Map { + return starredGroupMap(readStarredItems(STARRED_PATH())) } // ---- original archive (pass-through base: original file archived by content hash) ---- @@ -2471,6 +3933,17 @@ const docWritablePaths = new Map>() const pdfWritablePaths = new Map>() const tornDownWcIds = new Set() +// per renderer, kept current on open/save/save-as/rename; only the local-media allowlist reads it +const openDocByWc = new Map() + +function rememberOpenDoc(wcId: number, filePath: string): void { + openDocByWc.set(wcId, filePath) +} + +function docsMediaRoots(wcId: number): string[] { + return documentMediaRoots(openDocByWc.get(wcId), join(app.getPath('temp'), 'hermesoffice-pasted')) +} + function allowDocWrite(wcId: number, filePath: string): void { const set = docWritablePaths.get(wcId) ?? new Set() set.add(filePath) @@ -2527,10 +4000,30 @@ function isImageExportTemp(wcId: number, filePath: string): boolean { return imageExportTemps.get(wcId)?.has(filePath) === true } +// Word's Ignore All lasts for the document session. Chromium has no +// per-document skip list, so the word sits in the custom dictionary while a +// renderer holds it and leaves when the last holder goes; the journal pulls +// crash-orphaned words back out on the next start. +const SPELL_IGNORED_PATH = () => userDataPath('spell-ignored.json') +const spellIgnored = new Map>() +const journalIgnoredWords = () => writeJsonAtomic(SPELL_IGNORED_PATH(), [...spellIgnored.keys()]) + +function releaseSpellIgnores(wcId: number): void { + let changed = false + for (const [word, holders] of spellIgnored) { + if (!holders.delete(wcId) || holders.size > 0) continue + spellIgnored.delete(word) + session.defaultSession.removeWordFromSpellCheckerDictionary(word) + changed = true + } + if (changed) journalIgnoredWords() +} + function dropDocWriter(wcId: number): void { + releaseSpellIgnores(wcId) docWritablePaths.delete(wcId) pdfWritablePaths.delete(wcId) - attachmentReadablePaths.delete(wcId) + openDocByWc.delete(wcId) for (const p of imageExportTemps.get(wcId) ?? []) void rm(p, { force: true }) imageExportTemps.delete(wcId) imageExportDirs.delete(wcId) @@ -2582,6 +4075,7 @@ async function diskChangedExternally(wcId: number, filePath: string): Promise { const asPath = recoveryPathFor(filePath) + let diskNewer: boolean try { if (!existsSync(asPath)) return { bytes: original, recovered: false } - if (statSync(asPath).mtimeMs <= statSync(filePath).mtimeMs) { - // a crashed partial write bumps mtime yet corrupts the file — keep the copy - // then (an encrypted original is a CFB container, not a zip: intact too) - if (looksLikeZip(original) || isEncryptedDocx(original)) { - unlinkSync(asPath) - return { bytes: original, recovered: false } - } - } + diskNewer = recoveryCopyDiskNewer(statSync(asPath).mtimeMs, statSync(filePath).mtimeMs) } catch { return { bytes: original, recovered: false } } @@ -2647,7 +4135,9 @@ async function maybeRecoverDocBytes( defaultId: 0, cancelId: 1, message: tm('autosaveFoundTitle'), - detail: tm('autosaveFoundBody'), + detail: diskNewer + ? `${tm('autosaveFoundBody')}\n\n${tm('autosaveDiskNewer')}` + : tm('autosaveFoundBody'), } const parent = BrowserWindow.getFocusedWindow() ?? mainWindow const r = @@ -2665,17 +4155,94 @@ async function maybeRecoverDocBytes( return { bytes: original, recovered: false } } -// Word's own .docx ceiling; past ~1 GB the IPC reply serializer doubles its buffer beyond the allocator's map limit and crashes the main process +// Word's own .docx ceiling const MAX_OPEN_BYTES = 512 * 1024 * 1024 async function showOpenError(wcId: number, detail: string): Promise { - const wc = webContents.fromId(wcId) - const parent = docsShellWindow ?? (wc && BrowserWindow.fromWebContents(wc)) ?? mainWindow + const parent = hostWindowFor(webContents.fromId(wcId)) ?? mainWindow const options = { type: 'error' as const, message: tm('dlgOpenDoc'), detail } if (parent && !parent.isDestroyed()) await dialog.showMessageBox(parent, options) else await dialog.showMessageBox(options) } +/** + * An .odt/.ott, a legacy Word .doc/.dot or an .hwp/.hwpx opens only as a + * converted .docx copy: confirm the lossy import, pick the destination (the + * sibling .docx by default), convert, write the file and open it — the + * explicit-destination flow the PDF "Export as Word" path already uses, so + * every later Save lands in the copy the user chose and the original is never + * written. Anything that is not an import format passes through unchanged; + * null cancels the open. + */ +async function prepareDocumentForOpen( + filePath: string, + parent?: BrowserWindow | undefined, +): Promise { + const importKind = docImportKindForPath(filePath) + if (!importKind) return filePath + const extension = filePath.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] ?? '' + // .hwp and .hwpx route by content: sites serve each under the other's name + const isHwp = importKind === 'hwp' + const word97 = importKind === 'word97' + const skipKind = word97 ? 'word97' : isHwp ? 'hwp' : 'odt' + if (!readLegacyImportSkip(APP_SETTINGS_PATH(), skipKind)) { + const options = { + type: 'warning' as const, + message: tm(word97 ? 'docImportMsg' : 'odtImportMsg', { ext: extension }), + detail: tm(word97 ? 'docImportDetail' : isHwp ? 'hwpImportDetail' : 'odtImportDetail'), + buttons: [tm(word97 ? 'docImportConvertBtn' : 'odtImportConvertBtn'), tm('btnCancel')], + defaultId: 0, + cancelId: 1, + noLink: true, + checkboxLabel: tm('importSkipConfirm'), + } + const { response, checkboxChecked } = parent + ? await dialog.showMessageBox(parent, options) + : await dialog.showMessageBox(options) + if (response !== 0) return null + if (checkboxChecked) { + try { + writeLegacyImportSkip(APP_SETTINGS_PATH(), skipKind, true) + } catch { + /* unwritable settings: ask again next time */ + } + } + } + const picked = await showSaveDialogWithMemory( + dialog, + parent, + { + title: tm('dlgSaveAs'), + defaultPath: join( + dirname(filePath), + basename(filePath).replace(/\.(odt|ott|hwpx?|doc|dot)$/i, '.docx'), + ), + filters: [{ name: tm('filterWord'), extensions: ['docx'] }], + }, + defaultSaveDir(), + ) + if (picked.canceled || !picked.filePath) return null + try { + const source = new Uint8Array(await readFile(filePath)) + const result = word97 + ? await convertLegacyDocToDocx(source) + : isHwp + ? await convertHwpToDocx(source) + : await convertOdtToDocx(source) + await atomicWriteFile(picked.filePath, Buffer.from(result.docx)) + } catch { + if (parent && !parent.isDestroyed()) { + await dialog.showMessageBox(parent, { + type: 'error', + message: tm('dlgOpenDoc'), + detail: `${basename(filePath)}: ${tm('errUnreadable')}`, + }) + } + return null + } + return picked.filePath +} + async function loadDocx( filePath: string, wcId: number, @@ -2724,6 +4291,7 @@ async function loadDocx( if (recovered) await adoptLazyMediaHashes(bytes, filePath, wcId) pushRecent(filePath) allowDocWrite(wcId, filePath) + rememberOpenDoc(wcId, filePath) if (fileOpenedHook) fileOpenedHook(wcId, filePath) markDiskEncrypted(wcId, filePath, encrypted) // record the on-disk file, not the recovery copy: what matters is what save would overwrite @@ -2731,7 +4299,7 @@ async function loadDocx( return { path: filePath, name: basename(filePath), - data: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer, + dataUrl: handOffBytes(bytes), hash, encrypted, recovered: recovered || undefined, @@ -2871,7 +4439,7 @@ function savePastedImage(data: unknown, ext: unknown): string | null { ? Buffer.from(data.buffer, data.byteOffset, data.byteLength) : null if (!bytes || bytes.byteLength === 0) return null - const dir = join(app.getPath('temp'), 'genoffice-pasted') + const dir = join(app.getPath('temp'), 'hermesoffice-pasted') mkdirSync(dir, { recursive: true }) prunePastedImages(dir) const stamp = new Date().toISOString().slice(0, 19).replace(/[-:]/g, '').replace('T', '-') @@ -2912,6 +4480,13 @@ const SETTINGS_PATH = () => userDataPath('ai-settings.json') const activeAiStreams = new Map() +/** every renderer (tabs, home) re-reads ai-settings.json — the composer chip and + * the settings page edit the same file from different windows */ +function broadcastAiSettingsChanged(): void { + for (const wc of webContents.getAllWebContents()) + if (!wc.isDestroyed()) wc.send('ai:settings-changed') +} + /** * AI settings + chat/stream proxy handlers. Split out so the shell can * register them exactly once for all window types (docs, sheets, home) — @@ -2919,7 +4494,7 @@ const activeAiStreams = new Map() */ export function registerAiIpc(): void { app.once('before-quit', shutdownCodexAppServers) - ipcMain.handle('ai:get-settings', (): AiSettings => { + ipcMain.handle('ai:get-settings', async (): Promise => { const stored = readJson & LegacyAiSettings>(SETTINGS_PATH(), {}) // pre-lock legacy file: genspark selected with cloud tools opted out. The // settings UI locks the tools switch on with genspark and apps read this @@ -2928,10 +4503,10 @@ export function registerAiIpc(): void { // file and clobber a saved (half-configured) BYOK selection. if ((stored.provider ?? 'genspark') === 'genspark' && stored.gskToolsEnabled === false) { stored.gskToolsEnabled = true - writeJson(SETTINGS_PATH(), stored) + writeJsonAtomic(SETTINGS_PATH(), stored) } const settings = resolveAiSettings(stored, defaultAiSettings()) - // Honor usable saved providers; incomplete configurations fall back to Hermes. + // a stored BYOK provider is honored when usable; half-filled configs fall back to genspark settings.provider = activeProvider(settings) return settings }) @@ -2947,31 +4522,61 @@ export function registerAiIpc(): void { }, ) + // a 401 on the gsk key opens the device-code login once (hermesoffice-auth recovery) + setGenofficeLoginOpener((url) => void shell.openExternal(url)) + ipcMain.handle('ai:gsk-login', () => { - ensureGenofficeLogin((url) => { - const target = safeExternalUrl(url) - if (target) void shell.openExternal(target) - }) + ensureGenofficeLogin((url) => void shell.openExternal(url)) }) ipcMain.handle('ai:set-settings', (_event, settings: AiSettings) => { - writeJson(SETTINGS_PATH(), settings) + // SECURITY.md: payloads are schema-checked in the main process. The settings + // file feeds cliPath into spawn() and baseUrl receives the gsk bearer token, + // so the renderer's copy is sanitized before it touches disk. + const sanitized = sanitizeAiSettings(settings) + if (!sanitized) { + console.warn('[ai] rejected invalid ai:set-settings payload') + return + } + writeJsonAtomic(SETTINGS_PATH(), sanitized) + broadcastAiSettingsChanged() }) ipcMain.handle('ai:codex-models', async (_event, cliPath: unknown) => { - return listCodexModels(typeof cliPath === 'string' ? cliPath : undefined) + // the probe spawns the path directly, so it gets the same metacharacter and + // existence check as the stored setting (anything else: auto-detect) + return listCodexModels(sanitizeCliPath(cliPath)) }) + ipcMain.handle('ai:custom-models', (_event, input: unknown) => listCustomModelsForIpc(input)) + ipcMain.handle('ai:stream', async (event, request: AiStreamRequest) => { - const { requestId, settings, system, messages } = request + // per-request settings get the same schema check as the persisted ones: a + // compromised renderer could otherwise hand cliPath/baseUrl straight to + // the provider layer without ever touching the settings file + const settings = sanitizeAiSettings(request.settings) + if (!settings) { + event.sender.send('ai:stream-chunk', { + requestId: request.requestId, + type: 'error', + error: 'invalid AI settings payload', + } satisfies AiStreamChunk) + return + } + const { requestId, system, messages } = request const tools = request.tools ?? [] const maxTokens = request.maxTokens ?? maxOutputTokensOf(settings) const provider = settings.provider let config = settings.providers?.[provider] // the genspark key never enters the settings file; requests take it from the gsk login state + // set only when the gsk key was injected: a 401 then means that key expired + let gskKey = '' if (provider === 'genspark' && config && !config.apiKey) { - config = { ...config, apiKey: gskApiKey() } + gskKey = gskApiKey() + config = { ...config, apiKey: gskKey } } + // a custom endpoint may name an environment variable instead of storing a key + config = config ? resolveProfileCredential(config) : config const send = (chunk: AiStreamChunk) => { if (!event.sender.isDestroyed()) event.sender.send('ai:stream-chunk', chunk) } @@ -2989,6 +4594,7 @@ export function registerAiIpc(): void { } const controller = new AbortController() activeAiStreams.set(requestId, controller) + const unwatchSender = abortOnDestroyed(event.sender, controller) // wire-activity keepalive: lets the renderer's silence watchdog tell a slow turn from a dead one let lastPing = 0 const ping = () => { @@ -2997,32 +4603,55 @@ export function registerAiIpc(): void { lastPing = now send({ requestId, type: 'ping' }) } + const trace = startAiTrace({ + requestId, + sessionId: request.sessionId, + source: event.sender.getURL(), + provider, + model: config.model, + maxTokens, + system, + messages, + tools, + }) try { let stopReason: string | undefined - await streamForProvider( - provider, - config, - system, - messages, - tools, - maxTokens, - { - signal: controller.signal, - onDelta: (text) => send({ requestId, type: 'delta', text }), - onReasoningDelta: (text) => send({ requestId, type: 'reasoning', text }), - onToolCall: (toolCall) => send({ requestId, type: 'tool-call', toolCall }), - onActivity: ping, - onStopReason: (reason) => { - stopReason = reason - }, + await streamForProvider(provider, config, system, messages, tools, maxTokens, { + ...(request.sessionId ? { sessionId: request.sessionId } : {}), + signal: controller.signal, + onDelta: (text) => { + trace?.onDelta(text) + send({ requestId, type: 'delta', text }) }, - request.sessionId, - ) + onReasoningDelta: (text) => { + trace?.onReasoningDelta(text) + send({ requestId, type: 'reasoning', text }) + }, + onToolCall: (toolCall) => { + trace?.onToolCall(toolCall) + send({ requestId, type: 'tool-call', toolCall }) + }, + onActivity: ping, + onStopReason: (reason) => { + stopReason = reason + trace?.onStopReason(reason) + }, + onUsage: (usage) => { + trace?.onUsage(usage) + send({ requestId, type: 'usage', usage }) + }, + }) + trace?.finish() send({ requestId, type: 'done', stopReason }) } catch (err) { + trace?.finish( + controller.signal.aborted ? 'aborted' : err instanceof Error ? err.message : String(err), + ) if (controller.signal.aborted) { send({ requestId, type: 'done' }) } else { + // an expired/revoked key drops to signed-out so the inline sign-in button shows + noteGskCallFailure(gskKey, err) send({ requestId, type: 'error', @@ -3035,10 +4664,13 @@ export function registerAiIpc(): void { ? { errorCode: 'network' as const } : isAiOverloadedError(err) ? { errorCode: 'overloaded' as const } - : {}), + : isAiRegionBlockedError(err) + ? { errorCode: 'region' as const } + : {}), }) } } finally { + unwatchSender() activeAiStreams.delete(requestId) } }) @@ -3073,9 +4705,10 @@ export function registerAiIpc(): void { // media understanding (pictures in the document, attachments, local files): BYOK media // provider when one is configured, otherwise the Genspark CLI behind its login gate. + // docs-prefixed: slides registers its own ai:analyze-media in the same shell process. ipcMain.handle( 'docs:analyze-media', - async (_event, op: { mediaUrls: string[]; requirements: string }) => { + async (event, op: { mediaUrls: string[]; requirements: string }) => { const mediaUrls = (op.mediaUrls ?? []).map(String).filter(Boolean) // a picture opened lazily from a large docx is only addressable by its main-process // store; hand its bytes over as a data URL so the loader can read them like any other @@ -3084,10 +4717,14 @@ export function registerAiIpc(): void { const lazy = await readLazyMedia(url).catch(() => null) resolved.push(lazy ? `data:${lazy.mime};base64,${lazy.body.toString('base64')}` : url) } - return analyzeMediaTool(SETTINGS_PATH(), { - mediaUrls: resolved, - requirements: String(op.requirements ?? ''), - }) + return analyzeMediaTool( + SETTINGS_PATH(), + { + mediaUrls: resolved, + requirements: String(op.requirements ?? ''), + }, + { mediaRoots: docsMediaRoots(event.sender.id) }, + ) }, ) @@ -3102,7 +4739,7 @@ export function registerAiIpc(): void { // fetchRemoteImage adds CDN-friendly headers and transient-error retries. const resp = await fetchRemoteImage(String(url)) if (!resp || !resp.ok) return null - const buf = Buffer.from(await resp.arrayBuffer()) + const buf = Buffer.from(await readBodyCapped(resp, MAX_REMOTE_IMAGE_BYTES)) const ct = resp.headers.get('content-type') ?? '' const mime = ct.includes('png') ? 'image/png' @@ -3120,11 +4757,15 @@ export function registerAiIpc(): void { // registered once a slides view exists, so docs needs its own channel ipcMain.handle( 'docs:ai-generate-image', - (_event, op: { prompt?: unknown; aspectRatio?: unknown }) => - generateImageTool(SETTINGS_PATH(), { - prompt: String(op?.prompt ?? ''), - aspectRatio: op?.aspectRatio ? String(op.aspectRatio) : undefined, - }), + (event, op: { prompt?: unknown; aspectRatio?: unknown }) => + generateImageTool( + SETTINGS_PATH(), + { + prompt: String(op?.prompt ?? ''), + aspectRatio: op?.aspectRatio ? String(op.aspectRatio) : undefined, + }, + { mediaRoots: docsMediaRoots(event.sender.id) }, + ), ) ipcMain.handle('ai:search-test', (_event, input: unknown) => { @@ -3149,12 +4790,21 @@ export function registerAiIpc(): void { }) ipcMain.handle('ai:chat', async (_event, request: AiChatRequest) => { - const { settings, system, user } = request + // same schema check as ai:stream: one-shot requests would otherwise act on + // the renderer's settings copy verbatim + const settings = sanitizeAiSettings(request.settings) + if (!settings) return { ok: false, error: 'invalid AI settings payload' } + const { system, user } = request const provider = settings.provider let config = settings.providers?.[provider] + // set only when the gsk key was injected: a 401 then means that key expired + let gskKey = '' if (provider === 'genspark' && config && !config.apiKey) { - config = { ...config, apiKey: gskApiKey() } + gskKey = gskApiKey() + config = { ...config, apiKey: gskKey } } + // a custom endpoint may name an environment variable instead of storing a key + config = config ? resolveProfileCredential(config) : config if (!config || (provider !== 'codex' && !config.apiKey)) { return { ok: false, @@ -3169,9 +4819,21 @@ export function registerAiIpc(): void { if (!result.ok && isAiOverloadedError(result.error)) { return { ok: false, error: tm('errAiBusy') } } + if (!result.ok && isAiRegionBlockedError(result.error)) { + return { ok: false, error: tm('errAiRegion') } + } + if (!result.ok) noteGskCallFailure(gskKey, result.error) return result } catch (err) { - return { ok: false, error: isAiOverloadedError(err) ? tm('errAiBusy') : String(err) } + noteGskCallFailure(gskKey, err) + return { + ok: false, + error: isAiOverloadedError(err) + ? tm('errAiBusy') + : isAiRegionBlockedError(err) + ? tm('errAiRegion') + : String(err), + } } }) } @@ -3190,15 +4852,19 @@ function getProjectStore(): ProjectStore { * Fired when a save lands on a new path (save-as / first silent save). The shell * uses it to sync the tab title/path, record recents and apply a pending project — * same contract as the sheets/slides opened hooks. Never called standalone. + * Returns the final path when the shell filed the new file into a Home folder. */ -let fileSavedHook: ((wc: WebContents, filePath: string) => void) | null = null +let fileSavedHook: ((wc: WebContents, filePath: string) => string | void) | null = null -export function setDocsFileSavedHook(hook: (wc: WebContents, filePath: string) => void): void { +export function setDocsFileSavedHook( + hook: (wc: WebContents, filePath: string) => string | void, +): void { fileSavedHook = hook } -function notifyFileSaved(wc: WebContents, filePath: string): void { - if (fileSavedHook) fileSavedHook(wc, filePath) +function notifyFileSaved(wc: WebContents, filePath: string): string { + const moved = fileSavedHook ? fileSavedHook(wc, filePath) : undefined + return typeof moved === 'string' && moved ? moved : filePath } /** @@ -3228,6 +4894,14 @@ export function setSessionPathResolver( } /** After a file is renamed/moved on disk, sync project-store (fileMap/chatIdByPath re-key accordingly; history follows the file). */ +export function projectFilePaths(): string[] { + try { + return getProjectStore().knownFilePaths() + } catch { + return [] + } +} + export function projectFileRenamed(oldPath: string, newPath: string): void { try { getProjectStore().fileRenamed(oldPath, newPath) @@ -3291,11 +4965,9 @@ export function registerProjectIpc(): void { if (typeof args.text !== 'string' || args.text.length > 200_000) { throw new Error('Invalid chat text: must be a string up to 200000 chars') } - if (args.tools && (!Array.isArray(args.tools) || args.tools.length > 50)) { - throw new Error('Invalid chat tools: must be an array up to 50 entries') - } - if (args.attachments && (!Array.isArray(args.attachments) || args.attachments.length > 20)) { - throw new Error('Invalid chat attachments: must be an array up to 20 entries') + if (args.tools && !Array.isArray(args.tools)) throw new Error('Invalid chat tools') + if (args.attachments && !Array.isArray(args.attachments)) { + throw new Error('Invalid chat attachments') } const store = getProjectStore() const msg: Parameters[2] = { @@ -3326,6 +4998,18 @@ export function registerProjectIpc(): void { }, ) + /** Lists the conversations in a project, newest first. */ + ipcMain.handle('project:listChats', (_event, args: { projectId: string }) => { + const store = getProjectStore() + return store.listChats(args.projectId) + }) + + /** Deletes one conversation. A file-less chat is the home screen's ask panel. */ + ipcMain.handle('project:deleteChat', (_event, args: { projectId: string; chatId: string }) => { + const store = getProjectStore() + store.deleteChat(args.projectId, args.chatId) + }) + /** rebind chat (called after a file first hits disk): newFilePath/sessionId take priority; the main process computes chatId and records fileMap */ ipcMain.handle( 'project:rebindChat', @@ -3356,6 +5040,9 @@ export function registerProjectIpc(): void { /** A4 at 96dpi, as the HTML app exports */ const ALT_CHUNK_VIEWPORT = { width: 794, height: 1123, deviceScaleFactor: 2 } const ALT_CHUNK_HTML_MAX_CHARS = 64 * 1024 * 1024 +// An AI-generated page whose scripts never yield must not strand the hidden +// conversion window; the slides export path uses the same shape. +const ALT_CHUNK_TIMEOUT_MS = 120_000 /** an encrypted save leaves no plain file to serve lazy pictures from: the * renderer takes the materialized document back and leaves lazy mode */ @@ -3363,15 +5050,7 @@ const reissuedDoc = ( encrypted: boolean, hashes: Set, plain: Buffer, -): { data?: ArrayBuffer } => - encrypted && hashes.size > 0 - ? { - data: plain.buffer.slice( - plain.byteOffset, - plain.byteOffset + plain.byteLength, - ) as ArrayBuffer, - } - : {} +): { dataUrl?: string } => (encrypted && hashes.size > 0 ? { dataUrl: handOffBytes(plain) } : {}) /** document/attachment/window IPC (everything except the AI proxy above) */ export function registerDocsIpc(): void { @@ -3379,11 +5058,15 @@ export function registerDocsIpc(): void { void app.whenReady().then(registerLazyMediaProtocol) // Node fetch (undici) direct connections get reset under VPN/tun setups; retry over Chromium's stack setRescueFetch((url, init) => net.fetch(url, init)) - setAiUserAgent(`GenOffice/${app.getVersion()}`) + setAiUserAgent(`HermesOffice/${app.getVersion()}`) // shared with the other editor modules — last (identical) registration wins ipcMain.removeHandler('app:get-language') ipcMain.handle('app:get-language', () => getUiLang()) + ipcMain.handle('docs:confirm-document-replace', (event) => + requestDocsClose(event.sender, dialogParent(event)), + ) + ipcMain.handle('docs:system-locale', () => app.getSystemLocale()) configureMetricsCache(userDataPath('font-metrics')) ipcMain.handle('docs:font-metrics', (_event, family: string) => @@ -3393,14 +5076,20 @@ export function registerDocsIpc(): void { ipcMain.handle('docs:open', async (event) => { const result = await openDialog(event, { title: tm('dlgOpenDoc'), - filters: [{ name: tm('filterWord'), extensions: ['docx'] }], + filters: [{ name: tm('filterWord'), extensions: [...OPENABLE_DOC_EXTENSIONS] }], properties: ['openFile'], }) if (result.canceled || result.filePaths.length === 0) return null - return loadDocx(result.filePaths[0], event.sender.id) + const prepared = await prepareDocumentForOpen(result.filePaths[0], dialogParent(event)) + if (!prepared) return null + return loadDocx(prepared, event.sender.id) }) - ipcMain.handle('docs:open-path', (event, filePath: string) => loadDocx(filePath, event.sender.id)) + ipcMain.handle('docs:open-path', async (event, filePath: string) => { + const prepared = await prepareDocumentForOpen(filePath, dialogParent(event)) + if (!prepared) return null + return loadDocx(prepared, event.sender.id) + }) // w:altChunk HTML: the same html2docx chain as the HTML app's export, in a // hidden window; the renderer parses the result and shows its blocks @@ -3408,16 +5097,33 @@ export function registerDocsIpc(): void { if (typeof html !== 'string' || !html.trim() || html.length > ALT_CHUNK_HTML_MAX_CHARS) { return null } - const workDir = await mkdtemp(join(tmpdir(), 'genoffice-altchunk-')) + const workDir = await mkdtemp(join(tmpdir(), 'hermesoffice-altchunk-')) let driver: ElectronBrowserDriver | null = null try { const htmlPath = join(workDir, 'chunk.html') // the BOM outranks a stale left in the decoded markup await writeFile(htmlPath, `\ufeff${html}`, 'utf8') driver = await ElectronBrowserDriver.create(ALT_CHUNK_VIEWPORT) - const { docx } = await convertHtmlToDocx({ url: pathToFileURL(htmlPath).href }, driver, { + // The markup is an unsanitised AI artifact: a script that never yields + // would otherwise keep executeJavaScript pending forever, and the + // finally below would never run (the hidden window and workDir leak for + // good). Race a watchdog and destroy the window on timeout, matching + // the slides export guard. + const conversion = convertHtmlToDocx({ url: pathToFileURL(htmlPath).href }, driver, { naturalTableWidth: true, - }) + }).then(({ docx }) => docx) + let watchdog: ReturnType | undefined + const docx = await Promise.race([ + conversion, + new Promise((resolve) => { + watchdog = setTimeout(() => { + if (driver && !driver.isWindowDestroyed()) driver.destroyNow() + driver = null + console.warn('[docs] altChunk conversion timed out; window destroyed') + resolve(null) + }, ALT_CHUNK_TIMEOUT_MS) + }), + ]).finally(() => clearTimeout(watchdog)) return docx } catch (err) { console.warn('[docs] altChunk conversion failed:', err) @@ -3488,17 +5194,22 @@ export function registerDocsIpc(): void { }, ) - ipcMain.handle('docs:consume-pending-open', (event) => { + ipcMain.handle('docs:consume-pending-open', async (event) => { rendererReady = true // a tab spawned via New Tab loads the document queued for it specifically const queued = pendingWindowOpens.get(event.sender.id) if (queued) { pendingWindowOpens.delete(event.sender.id) - return loadDocx(queued, event.sender.id) + const prepared = await prepareDocumentForOpen(queued, dialogParent(event)) + if (!prepared) return null + return loadDocx(prepared, event.sender.id) } const filePath = pendingOpenPath pendingOpenPath = null - return filePath ? loadDocx(filePath, event.sender.id) : null + if (!filePath) return null + const prepared = await prepareDocumentForOpen(filePath, dialogParent(event)) + if (!prepared) return null + return loadDocx(prepared, event.sender.id) }) /** returns true when this tab was opened via "New Document" and should start blank */ @@ -3672,11 +5383,95 @@ export function registerDocsIpc(): void { } }) + // The document body draws its own React context menu, but Chromium's + // misspelling + suggestions for the clicked word only surface in the main + // process `context-menu` event. The renderer claims each body right-click + // synchronously from its DOM handler, i.e. before Blink requests the menu, + // so claims and events arrive in the same order: a claimed click gets its + // data forwarded and no native menu, anything else (header/footer surfaces, + // inputs) pops the native menu as before. + const ctxMenuClaims = new Map() + ipcMain.on('docs:context-menu-claim', (event, seq: unknown) => { + event.returnValue = true + if (typeof seq !== 'number') return + let claims = ctxMenuClaims.get(event.sender.id) + if (!claims) { + claims = new ClickClaims() + ctxMenuClaims.set(event.sender.id, claims) + } + claims.claim(seq, Date.now()) + }) + ipcMain.on('docs:context-menu-arm', (event) => { + const wc = event.sender + setContextMenuInterceptor(app, wc, (contents, params) => { + if (contents.isDestroyed() || tornDownWcIds.has(contents.id)) return Promise.resolve(false) + const seq = ctxMenuClaims.get(contents.id)?.take(Date.now()) ?? null + if (seq !== null) { + const request: ContextMenuRequest = { + seq, + misspelledWord: params.misspelledWord, + suggestions: params.dictionarySuggestions, + } + contents.send('docs:context-menu', request) + } + return Promise.resolve(seq !== null) + }) + wc.once('destroyed', () => { + setContextMenuInterceptor(app, wc, null) + ctxMenuClaims.delete(wc.id) + }) + }) + void app.whenReady().then(() => { + const orphans = readJson(SPELL_IGNORED_PATH(), []) + for (const w of orphans) session.defaultSession.removeWordFromSpellCheckerDictionary(w) + if (orphans.length) journalIgnoredWords() + }) + ipcMain.handle('docs:spell-ignore-word', (event, word: unknown) => { + if (typeof word !== 'string' || !word.trim()) return false + const w = word.trim() + let holders = spellIgnored.get(w) + if (!holders) { + holders = new Set() + spellIgnored.set(w, holders) + journalIgnoredWords() + } + holders.add(event.sender.id) + return event.sender.session.addWordToSpellCheckerDictionary(w) + }) + ipcMain.handle('docs:spell-add-word', (event, word: unknown) => { + if (typeof word !== 'string' || !word.trim()) return false + const w = word.trim() + if (spellIgnored.delete(w)) journalIgnoredWords() + return event.sender.session.addWordToSpellCheckerDictionary(w) + }) + ipcMain.handle('docs:spell-replace', (event, word: unknown) => { + if (typeof word === 'string' && word) event.sender.replaceMisspelling(word) + }) + ipcMain.handle('docs:spell-languages', (event): SpellLanguages => { + const session = event.sender.session + return { + active: session.getSpellCheckerLanguages(), + available: session.availableSpellCheckerLanguages, + } + }) + ipcMain.handle('docs:spell-set-languages', (event, langs: unknown): SpellLanguages => { + const session = event.sender.session + const available = new Set(session.availableSpellCheckerLanguages) + const next = Array.isArray(langs) + ? langs.filter((l): l is string => typeof l === 'string' && available.has(l)) + : [] + if (next.length) session.setSpellCheckerLanguages(next) + return { active: session.getSpellCheckerLanguages(), available: [...available] } + }) + ipcMain.handle('docs:respell-kick', async (event) => { const wc = event.sender if (tornDownWcIds.has(wc.id) || wc.isDestroyed()) return wc.focus() + // Blink only respells after a user activation, and only a keydown grants one + wc.sendInputEvent({ type: 'keyDown', keyCode: 'Space' }) wc.sendInputEvent({ type: 'char', keyCode: ' ' }) + wc.sendInputEvent({ type: 'keyUp', keyCode: 'Space' }) // resolve only after the input pipeline has delivered the keystroke, so // the caller can scrub the space it produced await new Promise((r) => setTimeout(r, 120)) @@ -3709,6 +5504,7 @@ export function registerDocsIpc(): void { await atomicWriteFile(result.filePath, bytes) if (tornDownWcIds.has(event.sender.id)) return { ok: false } allowDocWrite(event.sender.id, result.filePath) + rememberOpenDoc(event.sender.id, result.filePath) await rememberDiskState(event.sender.id, result.filePath, sha256Hex(bytes)) pointLazyMediaAt( hashes, @@ -3723,10 +5519,12 @@ export function registerDocsIpc(): void { result.filePath, ) pushRecent(result.filePath) - notifyFileSaved(event.sender, result.filePath) + // the renderer has no path yet to match a rename notification against, + // so the reply must carry the path it may save to next + const savedPath = notifyFileSaved(event.sender, result.filePath) return { ok: true, - path: result.filePath, + path: savedPath, passwordIntentPending, ...reissuedDoc(!!passwordState.password, hashes, plain), } @@ -3753,6 +5551,7 @@ export function registerDocsIpc(): void { return { ok: false } } allowDocWrite(event.sender.id, filePath) + rememberOpenDoc(event.sender.id, filePath) await rememberDiskState(event.sender.id, filePath, sha256Hex(bytes)) pointLazyMediaAt( hashes, @@ -3766,10 +5565,10 @@ export function registerDocsIpc(): void { } const passwordIntentPending = commitDocPasswordSave(event.sender.id, passwordState, filePath) pushRecent(filePath) - notifyFileSaved(event.sender, filePath) + const savedPath = notifyFileSaved(event.sender, filePath) return { ok: true, - path: filePath, + path: savedPath, passwordIntentPending, ...reissuedDoc(!!passwordState.password, hashes, plain), } @@ -3836,6 +5635,7 @@ export function registerDocsIpc(): void { filePath, ) pushRecent(filePath) + rememberOpenDoc(event.sender.id, filePath) notifyFileSaved(event.sender, filePath) return { ok: true, @@ -3871,6 +5671,68 @@ export function registerDocsIpc(): void { } }) + // Mailings tab: pick a local recipient list and read it as a data source. + // The file is read here, in the main process, where the file system is. + ipcMain.handle('docs:merge-data-source', async (event) => { + const result = await openDialog(event, { + title: tm('dlgMergeDataSource'), + filters: [ + { + name: tm('filterMergeDataSource'), + extensions: [...MERGE_DATA_SOURCE_EXTENSIONS], + }, + ], + properties: ['openFile'], + }) + if (result.canceled || result.filePaths.length === 0) return null + try { + return { ok: true, source: await readMergeDataSource(result.filePaths[0]) } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } + }) + + // Mailings tab: write the merged documents. One file for the whole merge + // goes where the user says; one file per record goes into a folder they + // pick, every name kept unique so two records of the same name do not + // overwrite each other. The bytes carry the document's lazily served + // pictures, so they are materialized first — the merged file is a document + // of its own, not a view of the open one. + ipcMain.handle('docs:merge-output', async (event, request: MergeOutputRequest) => { + const files = Array.isArray(request?.files) ? request.files : [] + if (files.length === 0) return { ok: false, error: 'nothing to write' } + const write = async (path: string, data: ArrayBuffer): Promise => { + const { bytes } = await materializeLazyDocx(Buffer.from(data)) + await writeFile(path, bytes) + } + try { + if (request.mode === 'single' || request.mode === 'labels') { + const file = files[0]! + const name = mergeOutputFileName(file.name) + const result = await saveDialog(event, { + title: tm('dlgSaveAs'), + defaultPath: name, + filters: [{ name: tm('filterWord'), extensions: ['docx'] }], + }) + if (result.canceled || !result.filePath) return { ok: false, canceled: true } + await write(result.filePath, file.data) + return { ok: true, path: result.filePath, count: 1 } + } + const result = await openDialog(event, { + title: tm('dlgPickExportDir'), + properties: ['openDirectory', 'createDirectory'], + }) + if (result.canceled || result.filePaths.length === 0) return { ok: false, canceled: true } + const folder = result.filePaths[0]! + for (const file of files) { + await write(mergeOutputPath(folder, file.name), file.data) + } + return { ok: true, folder, count: files.length } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } + }) + ipcMain.handle('files:pick', async (event): Promise => { const result = await openDialog(event, { title: tm('dlgAddAttachment'), @@ -3885,7 +5747,7 @@ export function registerDocsIpc(): void { return collectAttachments(result.filePaths) }) - ipcMain.handle('files:add', (event, paths: string[]) => { + ipcMain.handle('files:add', (_event, paths: string[]) => { // Renderer-supplied paths are not proof of an OS drop/paste: collect the // metadata for display, but do NOT grant read access — grants only come // from the picker, pasted-image temp files, and files:add-bytes. @@ -3989,7 +5851,10 @@ export function registerDocsIpc(): void { ipcMain.handle( 'docs:copy-image-to-clipboard', async (_event, dataUrl: unknown, meta: unknown): Promise => { - if (typeof dataUrl !== 'string') return false + // Renderer-supplied bitmap: validate before base64 decode + nativeImage + // (a huge data URL would OOM the main process). Non-data URLs are + // lazy-media ids resolved below. + if (!validCopyImageDataUrl(dataUrl)) return false let bytes: Buffer let htmlSrc = dataUrl if (dataUrl.startsWith('data:image/')) { @@ -4012,11 +5877,9 @@ export function registerDocsIpc(): void { let metaAttr = '' if (typeof meta === 'string' && meta.length <= 2048) { try { - const parsed = JSON.parse(meta) as Record - if (typeof parsed.imageWidthPx === 'number' && parsed.imageWidthPx > 0) - width = Math.round(parsed.imageWidthPx) - if (typeof parsed.imageHeightPx === 'number' && parsed.imageHeightPx > 0) - height = Math.round(parsed.imageHeightPx) + const parsed = copyImageDisplaySize(meta) + if (parsed.width !== undefined) width = parsed.width + if (parsed.height !== undefined) height = parsed.height const escaped = meta.replace(/&/g, '&').replace(/"/g, '"').replace(/ (scale && scale > 0 && scale !== 1 ? { scale } : {}) + // Infinity passes a `> 0` check, so require finiteness before handing it to Chromium. + const pdfScale = (scale?: number) => printScaleOption(scale) + const withInternalLinks = async (data: Buffer, links: unknown): Promise => { + if (!Array.isArray(links) || links.length === 0) return data + const { PDFDocument } = await import('pdf-lib') + const pdf = await PDFDocument.load(data) + if (addPdfInternalLinks(pdf, links) === 0) return data + return Buffer.from(await pdf.save()) + } const printScale = (scale?: number) => - scale && scale > 0 && scale !== 1 ? { scaleFactor: Math.round(scale * 100) } : {} + typeof scale === 'number' && Number.isFinite(scale) && scale > 0 && scale !== 1 + ? { scaleFactor: Math.round(scale * 100) } + : {} + + ipcMain.handle('docs:printers', async (event) => { + const printers = await event.sender.getPrintersAsync() + return printers.map((p) => ({ name: p.name, displayName: p.displayName || p.name })) + }) - ipcMain.handle('docs:print', async (event, scale?: number) => { + ipcMain.handle('docs:print', async (event, scale?: number, job?: unknown, paper?: unknown) => { // print the calling tab's own content; zero margins — the docx page padding provides them. - // Resolves when the system dialog is dismissed; the print dialog stays open on cancel - // (ok=false without error) and surfaces real failures. + // Resolves when the system dialog is dismissed (or the silent job is queued); the print + // dialog stays open on cancel (ok=false without error) and surfaces real failures. + const printOpts = { + margins: { marginType: 'none' as const }, + ...printScale(scale), + ...printJobOptions(job), + } + // A named printer prints silently, and Chromium's silent print on macOS + // can drop the job outright — no queue entry, no callback error + // (hermesoffice#2347). Route those jobs through CUPS instead: printToPDF + // bakes the pages at the document's own paper size, and lp spools a + // real job whose queue state and failures are diagnosable. + if (process.platform === 'darwin' && printOpts.silent) { + const geometry = validPrintPaper(paper) + const printed = await event.sender.printToPDF({ + printBackground: true, + margins: { top: 0, bottom: 0, left: 0, right: 0 }, + ...(geometry + ? { + pageSize: { + width: geometry.widthTwips / TWIPS_PER_INCH, + height: geometry.heightTwips / TWIPS_PER_INCH, + }, + } + : {}), + ...printScaleOption(scale), + }) + return printPdfViaCups(printed, printOpts) + } return new Promise<{ ok: boolean; error?: string }>((resolve) => { - event.sender.print( - { margins: { marginType: 'none' }, ...printScale(scale) }, - (success, failureReason) => { - resolve({ - ok: success, - ...(failureReason && !/cancel/i.test(failureReason) ? { error: failureReason } : {}), - }) - }, - ) + event.sender.print(printOpts, (success, failureReason) => { + resolve(printOutcome(success, failureReason, printOpts.silent)) + }) }) }) @@ -4062,7 +5961,15 @@ export function registerDocsIpc(): void { pageHeightTwips: number, outPath?: string, scale?: number, + links?: unknown, ) => { + // Renderer-supplied page geometry reaches Chromium printToPDF verbatim: + // reject non-finite/out-of-range sizes (0.1in..50in) and scales (0.1..5), + // same guard as docs:print-pdf-buffer (a malformed w:pgSz in a doc would + // otherwise hand Chromium a page thousands of inches wide). + if (!validPrintGeometry(pageWidthTwips, pageHeightTwips, scale)) { + return { ok: false, error: 'invalid page size or scale' } + } // renderer-supplied outPath is only honored when a save dialog authorized it before let filePath = outPath ?? null if (filePath && !canPdfWrite(event.sender.id, filePath)) { @@ -4079,7 +5986,7 @@ export function registerDocsIpc(): void { allowPdfWrite(event.sender.id, filePath) } try { - const data = await event.sender.printToPDF({ + const printed = await event.sender.printToPDF({ printBackground: true, // custom pageSize is in inches; the docx page padding provides the margins pageSize: { @@ -4089,7 +5996,8 @@ export function registerDocsIpc(): void { margins: { top: 0, bottom: 0, left: 0, right: 0 }, ...pdfScale(scale), }) - writeFileSync(filePath, data) + const data = await withInternalLinks(printed, links) + await atomicWriteFile(filePath, data) if (!isImageExportTemp(event.sender.id, filePath)) openGeneratedFile(filePath) return { ok: true, path: filePath } } catch (err) { @@ -4118,7 +6026,7 @@ export function registerDocsIpc(): void { } if (!dir) return null const wcId = event.sender.id - const pdfPath = join(tmpdir(), `genoffice-docs-images-${randomUUID()}.pdf`) + const pdfPath = join(tmpdir(), `hermesoffice-docs-images-${randomUUID()}.pdf`) allowPdfWrite(wcId, pdfPath) imageExportTemps.set(wcId, (imageExportTemps.get(wcId) ?? new Set()).add(pdfPath)) imageExportDirs.set(wcId, (imageExportDirs.get(wcId) ?? new Set()).add(dir)) @@ -4156,7 +6064,7 @@ export function registerDocsIpc(): void { } try { const filePath = join(dir, fileName) - await writeFile(filePath, Buffer.from(String(pngBase64), 'base64')) + await atomicWriteFile(filePath, Buffer.from(String(pngBase64), 'base64')) return { ok: true, path: filePath } } catch (err) { return { ok: false, error: String(err) } @@ -4183,7 +6091,10 @@ export function registerDocsIpc(): void { allowPdfWrite(event.sender.id, filePath) } try { - writeFileSync(filePath, html, 'utf8') + await atomicWriteFile( + filePath, + Buffer.from(await inlineLazyMediaInHtml(html, readLazyMedia), 'utf8'), + ) openGeneratedFile(filePath) return { ok: true, path: filePath } } catch (err) { @@ -4192,10 +6103,58 @@ export function registerDocsIpc(): void { }, ) + ipcMain.handle( + 'docs:export-markdown', + async ( + event, + defaultName: string, + markdown: string, + images: MarkdownImage[], + outPath?: string, + ) => { + if (typeof markdown !== 'string' || !markdown) return { ok: false, error: 'empty document' } + if (!Array.isArray(images)) return { ok: false, error: 'bad image list' } + let filePath = outPath ?? null + if (filePath && !canPdfWrite(event.sender.id, filePath)) { + return { ok: false, error: 'export target is not an authorized path' } + } + if (!filePath) { + const result = await saveDialog(event, { + title: tm('dlgExportMarkdown'), + defaultPath: defaultName.replace(/\.docx$/i, '') + '.md', + filters: [{ name: 'Markdown', extensions: ['md'] }], + }) + if (result.canceled || !result.filePath) return { ok: false } + filePath = result.filePath + allowPdfWrite(event.sender.id, filePath) + } + try { + const written = await writeMarkdownMedia(filePath, images) + // the Markdown destination was escaped before the swap: parentheses must not survive it + const mediaDir = encodeURIComponent( + basename(filePath).replace(/\.md$/i, '') + '_files', + ).replace(/[()]/g, (c) => (c === '(' ? '%28' : '%29')) + await atomicWriteFile( + filePath, + Buffer.from(markdown.split(`${MARKDOWN_MEDIA_DIR_TOKEN}/`).join(`${mediaDir}/`), 'utf8'), + ) + openGeneratedFile(filePath) + return { ok: true, path: filePath, imagesDropped: images.length - written } + } catch (err) { + return { ok: false, error: String(err), path: filePath } + } + }, + ) + // mixed paper-size export: the renderer prints group by group per size (other pages hidden via CSS); this produces one group's bytes ipcMain.handle( 'docs:print-pdf-buffer', async (event, pageWidthTwips: number, pageHeightTwips: number, scale?: number) => { + // Renderer-supplied page geometry reaches Chromium printToPDF verbatim: + // reject non-finite/out-of-range sizes (0.5in..50in) and scales (0.1..5). + if (!validPrintGeometry(pageWidthTwips, pageHeightTwips, scale)) { + return { ok: false, error: 'invalid page size or scale' } + } try { const data = await event.sender.printToPDF({ printBackground: true, @@ -4216,7 +6175,13 @@ export function registerDocsIpc(): void { // merge grouped PDF fragments into one file in page order (pdf-lib) ipcMain.handle( 'docs:save-merged-pdf', - async (event, defaultName: string, base64Parts: string[], outPath?: string) => { + async ( + event, + defaultName: string, + base64Parts: string[], + outPath?: string, + links?: unknown, + ) => { let filePath = outPath ?? null if (filePath && !canPdfWrite(event.sender.id, filePath)) { return { ok: false, error: 'export target is not an authorized path' } @@ -4239,7 +6204,8 @@ export function registerDocsIpc(): void { const pages = await merged.copyPages(part, part.getPageIndices()) for (const page of pages) merged.addPage(page) } - writeFileSync(filePath, Buffer.from(await merged.save())) + if (Array.isArray(links) && links.length > 0) addPdfInternalLinks(merged, links) + await atomicWriteFile(filePath, Buffer.from(await merged.save())) if (!isImageExportTemp(event.sender.id, filePath)) openGeneratedFile(filePath) return { ok: true, path: filePath } } catch (err) { @@ -4290,7 +6256,7 @@ interface DocsShellHooks { focusTab(id: string): void /** closes the calling tab instead of the whole shell window (Cmd+W / role:'close') */ closeActiveTab(): void - /** Shell router used to open exported PDFs in a new GenOffice tab. */ + /** Shell router used to open exported PDFs in a new HermesOffice tab. */ openGeneratedPath?(path: string): boolean /** Cmd/Ctrl+O opens the shell's quick switcher instead of the file dialog */ openQuickSwitcher?(): void @@ -4303,6 +6269,30 @@ export function setDocsShellHooks(hooks: DocsShellHooks | null): void { /** After writing an exported/AI-generated file: open it in the right tab * (shell) or reveal it in the folder (standalone). Tab-opening failure must * not report the write itself as failed — the file is already persisted. */ +/** + * Pictures of a Markdown export land in `_files/` next to it, like a + * browser's "save page" folder. Returns how many were written; unreadable + * sources are skipped and the reference in the Markdown dangles. + */ +async function writeMarkdownMedia(mdPath: string, images: MarkdownImage[]): Promise { + if (images.length === 0) return 0 + const dir = join(dirname(mdPath), basename(mdPath).replace(/\.md$/i, '') + '_files') + let written = 0 + for (const img of images) { + if (typeof img?.name !== 'string' || !/^image\d+\.\w+$/.test(img.name)) continue + if (typeof img.src !== 'string') continue + const data = /^data:image\/[\w.+-]+;base64,(.*)$/s.exec(img.src) + const body = data + ? Buffer.from(data[1], 'base64') + : ((await readLazyMedia(img.src).catch(() => null))?.body ?? null) + if (!body || body.length === 0) continue + if (written === 0) await mkdir(dir, { recursive: true }) + await atomicWriteFile(join(dir, img.name), body) + written++ + } + return written +} + function openGeneratedFile(path: string): void { // Headless export has no tab strip and no user: revealing the file in Finder // would be the only visible effect of a run that must stay silent. @@ -4436,25 +6426,13 @@ export function buildDocsMenu(): void { })) : [{ label: tm('menuNoRecent'), enabled: false }] + const labels = appMenuLabels(getUiLang()) + const appMenu = appMenuTemplate(process.platform, labels, { + appLabel: app.name, + extra: [settingsMenuItem(labels, () => sendCommand('preferences'))], + }) const template: MenuItemConstructorOptions[] = [ - ...(isMac - ? [ - { - label: app.name, - submenu: [ - { role: 'about' as const }, - { type: 'separator' as const }, - { role: 'services' as const }, - { type: 'separator' as const }, - { role: 'hide' as const }, - { role: 'hideOthers' as const }, - { role: 'unhide' as const }, - { type: 'separator' as const }, - { role: 'quit' as const }, - ], - }, - ] - : []), + ...(appMenu ? [appMenu] : []), { label: tm('menuFile'), submenu: [ @@ -4491,10 +6469,14 @@ export function buildDocsMenu(): void { accelerator: 'Shift+CmdOrCtrl+S', click: () => sendCommand('save-as'), }, + // also in the native File menu, the only entry on macOS: the ribbon's + // File tab is Windows/Linux-only + { label: labels.nameWithAi, click: () => sendCommand('name-with-ai') }, { type: 'separator' }, { label: tm('menuPageSetup'), click: () => sendCommand('page-setup') }, { label: tm('menuExportPdf'), click: () => sendCommand('export-pdf') }, { label: tm('menuExportHtml'), click: () => sendCommand('export-html') }, + { label: tm('menuExportMarkdown'), click: () => sendCommand('export-markdown') }, { label: tm('menuExportImages'), click: () => sendCommand('export-images') }, { label: tm('menuPrint'), @@ -4525,6 +6507,11 @@ export function buildDocsMenu(): void { accelerator: 'CmdOrCtrl+F', click: () => sendCommand('find'), }, + { + label: tm('menuGoTo'), + accelerator: isMac ? 'Alt+Cmd+G' : 'Ctrl+G', + click: () => sendCommand('goto'), + }, { type: 'separator' }, { role: 'selectAll', label: tm('menuSelectAll') }, ], @@ -4547,8 +6534,18 @@ export function buildDocsMenu(): void { accelerator: 'CmdOrCtrl+0', click: () => sendCommand('zoom-100'), }, - { label: tm('menuPageWidth'), click: () => sendCommand('zoom-page-width') }, - { label: tm('menuWholePage'), click: () => sendCommand('zoom-whole-page') }, + { + label: tm('menuZoom'), + submenu: [ + ...[500, 200, 150, 125, 100, 75, 50, 25, 10].map((pct) => ({ + label: `${pct}%`, + click: () => sendCommand('zoom-set', String(pct)), + })), + { label: tm('menuPageWidth'), click: () => sendCommand('zoom-page-width') }, + { label: tm('menuWholePage'), click: () => sendCommand('zoom-whole-page') }, + { label: tm('menuMultiPage'), click: () => sendCommand('zoom-multi-page') }, + ], + }, { type: 'separator' }, { id: 'docs-menu-ai-sidebar', @@ -4621,11 +6618,93 @@ export function buildDocsMenu(): void { }, ], }, + { + // Word's Table menu; the renderer answers with a hint when the caret is outside a table + label: tm('menuTable'), + submenu: [ + { + label: tm('menuTableInsert'), + submenu: [ + { label: tm('menuTableInsertTable'), click: () => sendCommand('insert-table') }, + { label: tm('menuTableColsLeft'), click: () => sendCommand('table-insert-cols-left') }, + { + label: tm('menuTableColsRight'), + click: () => sendCommand('table-insert-cols-right'), + }, + { + label: tm('menuTableRowsAbove'), + click: () => sendCommand('table-insert-rows-above'), + }, + { + label: tm('menuTableRowsBelow'), + click: () => sendCommand('table-insert-rows-below'), + }, + { label: tm('menuTableCells'), click: () => sendCommand('table-insert-cells') }, + ], + }, + { + label: tm('menuTableDelete'), + submenu: [ + { label: tm('menuTableDeleteTable'), click: () => sendCommand('table-delete-table') }, + { + label: tm('menuTableDeleteColumns'), + click: () => sendCommand('table-delete-columns'), + }, + { label: tm('menuTableDeleteRows'), click: () => sendCommand('table-delete-rows') }, + { label: tm('menuTableCells'), click: () => sendCommand('table-delete-cells') }, + ], + }, + { + label: tm('menuTableSelect'), + submenu: [ + { label: tm('menuTableSelectTable'), click: () => sendCommand('table-select-table') }, + { label: tm('menuTableSelectColumn'), click: () => sendCommand('table-select-column') }, + { label: tm('menuTableSelectRow'), click: () => sendCommand('table-select-row') }, + { label: tm('menuTableSelectCell'), click: () => sendCommand('table-select-cell') }, + ], + }, + { type: 'separator' }, + { label: tm('menuTableMergeCells'), click: () => sendCommand('table-merge-cells') }, + { label: tm('menuTableSplitCells'), click: () => sendCommand('table-split-cells') }, + { label: tm('menuTableSplitTable'), click: () => sendCommand('table-split-table') }, + { type: 'separator' }, + { + label: tm('menuTableAutoFit'), + submenu: [ + { + label: tm('menuTableAutoFitContents'), + click: () => sendCommand('table-autofit-contents'), + }, + { + label: tm('menuTableAutoFitWindow'), + click: () => sendCommand('table-autofit-window'), + }, + { label: tm('menuTableFixedWidth'), click: () => sendCommand('table-autofit-fixed') }, + { type: 'separator' }, + { + label: tm('menuTableDistributeRows'), + click: () => sendCommand('table-distribute-rows'), + }, + { + label: tm('menuTableDistributeColumns'), + click: () => sendCommand('table-distribute-columns'), + }, + ], + }, + { label: tm('menuTableRepeatHeader'), click: () => sendCommand('table-repeat-header') }, + { type: 'separator' }, + { label: tm('menuTableGridlines'), click: () => sendCommand('table-gridlines') }, + { label: tm('menuTableProperties'), click: () => sendCommand('table-properties') }, + ], + }, { // Word for Mac keeps Word Count in the Tools menu, not on the ribbon label: tm('menuTools'), submenu: [ { label: tm('menuWordCount'), click: () => sendCommand('word-count') }, + { label: tm('menuAutoCorrect'), click: () => sendCommand('autocorrect-options') }, + // Word for Mac keeps Settings in the application menu + ...(isMac ? [] : [settingsMenuItem(labels, () => sendCommand('preferences'))]), { type: 'separator' }, // Runs the same AI proofread as Review > Editor (renderer shows the one-time ack) { label: tm('menuAiProofread'), click: () => sendCommand('ai-proofread') }, @@ -4642,8 +6721,6 @@ export function buildDocsMenu(): void { click: () => sendCommand('shortcuts'), }, { type: 'separator' }, - { label: tm('menuDocsHelp'), enabled: false }, - { type: 'separator' }, checkUpdatesMenuItem(appMenuLabels(getUiLang())), aboutMenuItem(appMenuLabels(getUiLang())), ], @@ -4728,7 +6805,7 @@ export function createDocsWindow(openPath?: string): BrowserWindow { height: 900, minWidth: 720, minHeight: 550, - title: 'GenOffice Docs', + title: 'HermesOffice Docs', // Word-like custom title bar (document name centered, quick-access buttons) ...(process.platform === 'darwin' ? { titleBarStyle: 'hiddenInset' as const } @@ -5009,11 +7086,28 @@ export function startDocsStandalone(): void { installContextMenu(app, () => contextMenuLabels(getUiLang())) // dev runs must not share the packaged app's userData (recent files, AI settings) // or its single-instance lock — otherwise `npm run dev` silently quits whenever - // the installed GenOffice Docs is open and forwards its argv there instead. + // the installed HermesOffice Docs is open and forwards its argv there instead. // AI_OFFICE_USER_DATA: E2E/screenshot runs isolate userData (and the // single-instance lock) so parallel automation sessions don't evict each other if (process.env.AI_OFFICE_USER_DATA) app.setPath('userData', process.env.AI_OFFICE_USER_DATA) - else if (isDev) app.setPath('userData', join(app.getPath('appData'), 'GenOffice Docs Dev')) + else if (isDev) { + // "HermesOffice Docs Dev" carried a space, against the XDG one-token + // convention (hermesoffice#1817); move the old profile once, as the shell does. + const appData = app.getPath('appData') + const devDir = join(appData, 'HermesOffice-Docs-Dev') + const oldDevDir = join(appData, 'HermesOffice Docs Dev') + let userData = devDir + if (existsSync(oldDevDir) && !existsSync(devDir)) { + try { + renameSync(oldDevDir, devDir) + } catch { + // another dev instance holds the directory: keep using the old one so + // the profile is not lost and the move is retried on the next launch + userData = oldDevDir + } + } + app.setPath('userData', userData) + } const hasSingleInstanceLock = app.requestSingleInstanceLock() if (!hasSingleInstanceLock) { @@ -5027,7 +7121,7 @@ export function startDocsStandalone(): void { }) app.on('second-instance', (_event, argv) => { - openExternalDocx(findDocxPath(argv)) + openExternalDocx(findOpenableDocPath(argv)) mainWindow?.show() mainWindow?.focus() }) @@ -5041,7 +7135,7 @@ export function startDocsStandalone(): void { setUiLang(normalizeLang(process.env.HERMESOFFICE_LANG ?? app.getLocale())) // packaged builds get the Dock icon from icon.icns; dev shows Electron's default if (isDev && process.platform === 'darwin') { - app.dock?.setIcon(join(app.getAppPath(), 'build/icon-mac.png')) + app.dock?.setIcon(join(app.getAppPath(), 'build/icon.png')) } buildDocsMenu() createDocsWindow() diff --git a/apps/docs/src/main/docx-encryption.ts b/apps/docs/src/main/docx-encryption.ts index 6c9b504fa..7462882c0 100644 --- a/apps/docs/src/main/docx-encryption.ts +++ b/apps/docs/src/main/docx-encryption.ts @@ -10,52 +10,15 @@ * AES-256/SHA-512), and both open either — so we decrypt both and re-encrypt * with Agile, matching Word 2013+ defaults. */ -import officeCrypto from 'officecrypto-tool' - -const CFB_MAGIC = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]) -const ENCRYPTED_STREAM_UTF16 = Buffer.from('EncryptedPackage', 'utf16le') - -/** CFB (OLE2) container magic — for a .docx path this means "encrypted" (plain docx is a zip) */ -export function isCfbFile(bytes: Buffer): boolean { - return bytes.length >= 8 && bytes.subarray(0, 8).equals(CFB_MAGIC) -} - -/** ECMA-376 encrypted OOXML: CFB magic + an EncryptedPackage stream in the directory. */ -export function isEncryptedDocx(bytes: Buffer): boolean { - return isCfbFile(bytes) && bytes.includes(ENCRYPTED_STREAM_UTF16) -} - -export type DecryptFailReason = 'wrong-password' | 'unsupported' - -export class DocxDecryptError extends Error { - readonly reason: DecryptFailReason - constructor(reason: DecryptFailReason, message: string) { - super(message) - this.reason = reason - } -} - -/** - * Decrypt an encrypted docx into plain zip bytes. Throws DocxDecryptError with - * reason 'wrong-password' (verifier mismatch — reprompt) or 'unsupported' - * (exotic scheme, e.g. WPS account encryption or Extensible encryption). - */ -export async function decryptDocx(bytes: Buffer, password: string): Promise { - try { - return await officeCrypto.decrypt(bytes, { password }) - } catch (err) { - const message = String((err as Error)?.message ?? err) - if (message.includes('password is incorrect')) { - throw new DocxDecryptError('wrong-password', message) - } - throw new DocxDecryptError('unsupported', message) - } -} - -/** Re-encrypt plain docx zip bytes with ECMA-376 Agile (Word 2013+ default). */ -export function encryptDocx(bytes: Buffer, password: string): Buffer { - return officeCrypto.encrypt(bytes, { password }) -} +import { + decryptOoxml as decryptDocx, + encryptOoxml as encryptDocx, + isEncryptedOoxml as isEncryptedDocx, + OoxmlDecryptError as DocxDecryptError, + type OoxmlDecryptFailReason as DecryptFailReason, +} from '@hermesoffice/electron-utils' + +export { decryptDocx, encryptDocx, isEncryptedDocx, DocxDecryptError, type DecryptFailReason } // ── In-memory password state, keyed per renderer + file path. // Never persisted: a relaunch re-prompts, exactly like Word/WPS. ── diff --git a/apps/docs/src/main/lazy-media-inline.ts b/apps/docs/src/main/lazy-media-inline.ts new file mode 100644 index 000000000..881b0fc99 --- /dev/null +++ b/apps/docs/src/main/lazy-media-inline.ts @@ -0,0 +1,26 @@ +import { LAZY_MEDIA_SCHEME } from '@hermesoffice/docx-engine/lazy-media' + +/** + * Exported HTML leaves the app: every lazily served picture it references + * must travel as bytes. Distinct URLs are read once; one the source cannot + * serve any more keeps its reference (the export still opens, that picture + * shows as missing, as it would in the editor). + */ +export async function inlineLazyMediaInHtml( + html: string, + read: (url: string) => Promise<{ body: Uint8Array; mime: string } | null>, +): Promise { + // part names may carry apostrophes (encodeURIComponent keeps them): the URL + // runs to the closing double quote, whitespace or a tag bracket + const re = new RegExp(`${LAZY_MEDIA_SCHEME}://[^"\\s<>]+`, 'g') + const urls = [...new Set(html.match(re) ?? [])] + if (urls.length === 0) return html + const dataUrls = new Map() + for (const url of urls) { + const media = await read(url) + if (media) { + dataUrls.set(url, `data:${media.mime};base64,${Buffer.from(media.body).toString('base64')}`) + } + } + return html.replace(re, (url) => dataUrls.get(url) ?? url) +} diff --git a/apps/docs/src/main/lazy-media.ts b/apps/docs/src/main/lazy-media.ts index 05dfa833b..d194f07fb 100644 --- a/apps/docs/src/main/lazy-media.ts +++ b/apps/docs/src/main/lazy-media.ts @@ -4,6 +4,7 @@ import { open, stat } from 'node:fs/promises' import { inflateRawSync } from 'node:zlib' import { protocol } from 'electron' import { DOCX_MEDIA_SCHEME_PRIVILEGE } from '@hermesoffice/electron-utils' +import { takeHandoff } from './byte-handoff' import { LAZY_MEDIA_SCHEME, isLazyMediaPart, @@ -20,8 +21,11 @@ import { type ZipFile, } from '@hermesoffice/docx-engine/zip-splice' -/** documents carrying at least this much browser-decodable media open lazily */ -const LAZY_MEDIA_MIN_BYTES = 16 * 1024 * 1024 +/** documents carrying at least this much browser-decodable media open lazily: + * below it the pictures ride along as data URLs (a copy in the model, the DOM + * and the undo history each) — cheap enough for a screenshot or two, not for + * a photo-heavy report */ +const LAZY_MEDIA_MIN_BYTES = 1024 * 1024 interface Source { path: string @@ -111,6 +115,15 @@ export async function adoptLazyMediaHashes( } } +/** the file behind these pictures moved on disk: keep serving them from the new path */ +export function moveLazyMediaSource(oldPath: string, newPath: string): void { + for (const source of sources.values()) { + if (source.path !== oldPath) continue + source.path = newPath + source.table = undefined + } +} + export function forgetLazyMediaOwner(wcId: number): void { for (const [hash, source] of sources) { source.owners.delete(wcId) @@ -233,6 +246,16 @@ export function registerLazyMediaProtocol(): void { } if (protocol.isProtocolHandled(LAZY_MEDIA_SCHEME)) return protocol.handle(LAZY_MEDIA_SCHEME, async (request) => { + const handed = takeHandoff(request.url) + if (handed) { + return new Response(new Uint8Array(handed), { + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': String(handed.byteLength), + 'Access-Control-Allow-Origin': '*', + }, + }) + } const media = await readLazyMedia(request.url) if (!media) return new Response(null, { status: 404 }) return new Response(new Uint8Array(media.body), { headers: { 'Content-Type': media.mime } }) diff --git a/apps/docs/src/main/merge-data-source.ts b/apps/docs/src/main/merge-data-source.ts new file mode 100644 index 000000000..68644f8ec --- /dev/null +++ b/apps/docs/src/main/merge-data-source.ts @@ -0,0 +1,74 @@ +import { readFile } from 'node:fs/promises' +import { basename, extname } from 'node:path' +import { mergeDataFromRows, type MergeRecord } from '@hermesoffice/docx-engine' +import { decodeCsvBuffer, parseCsv } from '@hermesoffice/xlsx-gateway/gateway/csv-import' +import { readSheetRows } from '@hermesoffice/xlsx-gateway/gateway/xlsx-gateway' + +/** + * The data source of a mail merge: the local spreadsheet the records come + * from. The file is read here, in the main process, because that is where the + * file system is; the renderer only ever sees the records. + * + * The parsing itself belongs to xlsx-gateway (the CSV reader Sheets opens a + * .csv with, the workbook reader), and the record shape to the docx engine — + * so a list that opens correctly in Sheets opens correctly here. + * hermesoffice#2097. + */ + +/** A data source the mail merge reads: the field names and the records. */ +export interface MergeDataSource { + /** absolute path of the file the records came from */ + path: string + /** the file's name, for the UI */ + name: string + /** the column names, in file order */ + fields: string[] + /** one entry per record */ + records: MergeRecord[] +} + +/** the file extensions a data source may have */ +export const MERGE_DATA_SOURCE_EXTENSIONS = ['xlsx', 'csv'] as const + +/** + * Read a local .xlsx or .csv as a mail merge data source. + * + * The first sheet of a workbook and the first row of a CSV name the fields; + * the rows below them are the records. + */ +export async function readMergeDataSource(path: string): Promise { + const extension = extname(path).toLowerCase() + const bytes = await readFile(path) + const rows = + extension === '.csv' + ? parseCsv(decodeCsvBuffer(bytes)) + : extension === '.xlsx' + ? await readSheetRows(bytes) + : null + if (!rows) throw new Error(`unsupported data source: ${extension || path}`) + const data = mergeDataFromRows(namedHeaders(rows)) + return { + path, + name: basename(path), + fields: [...data.fields], + records: [...data.records], + } +} + +/** + * A header cell that is empty names no field, and two cells with the same + * name would fight over one value: the second becomes "Name2", the way Word + * names them. + */ +function namedHeaders(rows: readonly (readonly string[])[]): readonly (readonly string[])[] { + const header = rows[0] + if (!header) return [] + const seen = new Map() + const names = header.map((raw, index) => { + const name = raw.trim() || `Column${index + 1}` + const count = seen.get(name) ?? 0 + seen.set(name, count + 1) + return count === 0 ? name : `${name}${count + 1}` + }) + return [names, ...rows.slice(1)] +} diff --git a/apps/docs/src/main/merge-output.ts b/apps/docs/src/main/merge-output.ts new file mode 100644 index 000000000..de12ce7f5 --- /dev/null +++ b/apps/docs/src/main/merge-output.ts @@ -0,0 +1,35 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +/** + * Where a merged document is written. + * + * The renderer picks the name (it comes from the record), so the main process + * keeps only its last part: a name must not carry a folder into the directory + * the user chose, and two records of the same name must not overwrite each + * other. hermesoffice#2097. + */ + +/** the name a merged document is written under, as a .docx file name */ +export function mergeOutputFileName(name: string): string { + const base = name + .split(/[\\/]/) + .pop()! + .replace(/[\\/:*?"<>|]/g, '_') + .trim() + const safe = base === '' || base === '.' || base === '..' ? 'document' : base + return safe.toLowerCase().endsWith('.docx') ? safe : `${safe}.docx` +} + +/** a free path inside dir for that name: name.docx, name-2.docx, name-3.docx… */ +export function mergeOutputPath(dir: string, name: string): string { + const fileName = mergeOutputFileName(name) + const dot = fileName.lastIndexOf('.') + const base = dot > 0 ? fileName.slice(0, dot) : fileName + const ext = dot > 0 ? fileName.slice(dot) : '' + let candidate = join(dir, fileName) + for (let i = 2; existsSync(candidate); i += 1) { + candidate = join(dir, `${base}-${i}${ext}`) + } + return candidate +} diff --git a/apps/docs/src/main/pdf-links.ts b/apps/docs/src/main/pdf-links.ts new file mode 100644 index 000000000..d209e942e --- /dev/null +++ b/apps/docs/src/main/pdf-links.ts @@ -0,0 +1,59 @@ +/** + * GoTo link annotations for exported PDFs. Chromium only emits an internal + * link when the fragment resolves to an element with a matching `id` inside + * the same print job; the paginated preview prints clones without ids and in + * page chunks, so the renderer measures the links and they are stamped here + * after the (merged) document exists. Electron-free for unit tests. + */ +import { PDFArray, PDFName, type PDFDocument } from 'pdf-lib' +import type { PdfInternalLink } from '../shared/ipc' + +const MAX_LINKS = 20_000 + +const frac = (v: unknown): v is number => + typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1 + +const pageIndex = (v: unknown): v is number => + typeof v === 'number' && Number.isInteger(v) && v >= 0 + +/** renderer-supplied links filtered to well-formed, in-range entries */ +export function sanitizePdfLinks(links: unknown, pageCount: number): PdfInternalLink[] { + if (!Array.isArray(links)) return [] + const out: PdfInternalLink[] = [] + for (const l of links as Array>) { + if (out.length >= MAX_LINKS) break + if (!l || typeof l !== 'object') continue + const { page, x, y, w, h, toPage, toY } = l + if (!pageIndex(page) || !pageIndex(toPage) || page >= pageCount || toPage >= pageCount) continue + if (!frac(x) || !frac(y) || !frac(w) || !frac(h) || !frac(toY)) continue + if (w <= 0 || h <= 0 || x + w > 1.0001 || y + h > 1.0001) continue + out.push({ page, x, y, w, h, toPage, toY }) + } + return out +} + +export function addPdfInternalLinks(pdf: PDFDocument, links: PdfInternalLink[]): number { + const pages = pdf.getPages() + let added = 0 + for (const l of sanitizePdfLinks(links, pages.length)) { + const page = pages[l.page] + const target = pages[l.toPage] + const pw = page.getWidth() + const ph = page.getHeight() + const annot = pdf.context.obj({ + Type: 'Annot', + Subtype: 'Link', + Rect: [l.x * pw, ph - (l.y + l.h) * ph, (l.x + l.w) * pw, ph - l.y * ph], + Border: [0, 0, 0], + Dest: [target.ref, 'XYZ', null, target.getHeight() * (1 - l.toY), null], + }) + let annots = page.node.lookup(PDFName.of('Annots')) + if (!(annots instanceof PDFArray)) { + annots = pdf.context.obj([]) + page.node.set(PDFName.of('Annots'), annots) + } + ;(annots as PDFArray).push(pdf.context.register(annot)) + added += 1 + } + return added +} diff --git a/apps/docs/src/main/print-args.ts b/apps/docs/src/main/print-args.ts new file mode 100644 index 000000000..ba582254b --- /dev/null +++ b/apps/docs/src/main/print-args.ts @@ -0,0 +1,52 @@ +/** + * Pure validators for the PDF print/export IPC args (docs:print-pdf-buffer, + * docs:print, docs:save-merged-pdf). Renderer-supplied numbers reach Chromium + * printToPDF verbatim, so they are range-checked here; the handlers stay thin. + * Electron-free so unit tests can import this module directly. + */ + +/** printable page dimension in twips: 0.1in (Word's floor) .. 50in */ +export function validPrintDim(v: unknown): v is number { + return typeof v === 'number' && Number.isFinite(v) && v >= 144 && v <= 72000 +} + +/** print scale factor: 0.1 .. 5 (undefined = Chromium default) */ +export function validPrintScale(scale: unknown): boolean { + return ( + scale === undefined || + (typeof scale === 'number' && Number.isFinite(scale) && scale >= 0.1 && scale <= 5) + ) +} + +/** combined geometry guard shared by the printToPDF IPC handlers (docs:export-pdf, + * docs:print-pdf-buffer): page width/height in twips plus optional scale */ +export function validPrintGeometry( + pageWidthTwips: unknown, + pageHeightTwips: unknown, + scale?: unknown, +): boolean { + return validPrintDim(pageWidthTwips) && validPrintDim(pageHeightTwips) && validPrintScale(scale) +} + +/** finite positive scale for the printToPDF/print option objects (Infinity fails `> 0` checks) */ +export function printScaleOption(scale: unknown): { scale: number } | Record { + return typeof scale === 'number' && Number.isFinite(scale) && scale > 0 && scale !== 1 + ? { scale } + : {} +} + +/** the document's own paper size for the CUPS print route: the renderer reads + * it off the pagination preview's first sheet (twips), and printToPDF bakes + * the pages at that size instead of Chromium's Letter default. Absent or + * malformed = no pageSize, never a wrong size. */ +export function validPrintPaper( + paper: unknown, +): { widthTwips: number; heightTwips: number } | null { + if (typeof paper !== 'object' || paper === null) return null + const p = paper as Record + if (!validPrintDim(p.widthTwips) || !validPrintDim(p.heightTwips)) return null + return { widthTwips: p.widthTwips, heightTwips: p.heightTwips } +} + +export { printJobOptions } from '@hermesoffice/electron-utils/print-job' +export type { PrintDuplex, PrintJobOptions } from '@hermesoffice/electron-utils/print-job' diff --git a/apps/docs/src/main/starred-files.ts b/apps/docs/src/main/starred-files.ts new file mode 100644 index 000000000..d10a8ac2c --- /dev/null +++ b/apps/docs/src/main/starred-files.ts @@ -0,0 +1,134 @@ +import { existsSync, readFileSync } from 'node:fs' +import { writeJsonAtomic } from '@hermesoffice/electron-utils/atomic-write' + +/** + * The starred.json store behind the home screen's favorites. The path is the + * key of every entry; a path appears at most once. Shell's home IPC and the + * docs menu both read through docs-main's wrappers around these helpers. + * + * On-disk shape (since starred groups): { version: 1, items: StarredItem[] }. + * The legacy shape was a flat string[]; it is migrated on read — every entry + * keeps its star but lands in no group — and the next write persists the + * versioned shape. There is no separate groups list: a group exists while at + * least one entry carries its name and disappears with its last member. + */ + +/** one starred file; `group` is the user-named group it belongs to (absent = ungrouped) */ +export interface StarredItem { + path: string + group?: string +} + +/** starred.json shape since groups landed */ +export interface StarredStore { + version: 1 + items: StarredItem[] +} + +function readJson(path: string): unknown { + try { + if (existsSync(path)) return JSON.parse(readFileSync(path, 'utf-8')) + } catch { + /* corrupted state file: fall back to an empty store */ + } + return null +} + +/** one entry of either on-disk shape: a bare legacy path or an item object */ +function parseStarredItem(entry: unknown): StarredItem | null { + if (typeof entry === 'string') return { path: entry } + if (!entry || typeof entry !== 'object' || typeof (entry as StarredItem).path !== 'string') { + return null + } + const { path, group } = entry as StarredItem + return typeof group === 'string' && group.trim() ? { path, group: group.trim() } : { path } +} + +/** Parse either on-disk shape into entries. Unknown shapes (corrupt file) and + * entries degrade to dropped/ungrouped rather than throwing: a broken + * starred.json must not take the whole home screen down. */ +export function parseStarredItems(raw: unknown): StarredItem[] { + let entries: unknown[] | null = null + if (Array.isArray(raw)) { + // legacy flat list: bare paths, and object entries degrade to ungrouped + entries = raw + } else if (raw && typeof raw === 'object' && Array.isArray((raw as { items?: unknown }).items)) { + entries = (raw as { items: unknown[] }).items + } + if (!entries) return [] + return entries.flatMap((entry) => { + const item = parseStarredItem(entry) + return item ? [item] : [] + }) +} + +export function readStarredItems(filePath: string): StarredItem[] { + return parseStarredItems(readJson(filePath)) +} + +export function writeStarredItems(filePath: string, items: StarredItem[]): void { + const store: StarredStore = { version: 1, items } + writeJsonAtomic(filePath, store) +} + +/** star / unstar one path (unstar drops its group with it; re-starring starts ungrouped) */ +export function toggleStarredItem(items: StarredItem[], filePath: string): StarredItem[] { + return items.some((item) => item.path === filePath) + ? items.filter((item) => item.path !== filePath) + : [...items, { path: filePath }] +} + +/** remove paths; null when nothing matched so callers skip the write */ +export function dropStarredItems(items: StarredItem[], filePaths: string[]): StarredItem[] | null { + const drop = new Set(filePaths) + if (drop.size === 0) return null + const next = items.filter((item) => !drop.has(item.path)) + return next.length !== items.length ? next : null +} + +/** assign each path to `group` (null/'' clears the group); null when no entry changed */ +export function assignStarredGroup( + items: StarredItem[], + filePaths: string[], + group: string | null, +): StarredItem[] | null { + const targets = new Set(filePaths) + if (targets.size === 0) return null + const name = group?.trim() ?? '' + let changed = false + const next = items.map((item) => { + if (!targets.has(item.path)) return item + if ((item.group ?? '') === name) return item + changed = true + return name ? { path: item.path, group: name } : { path: item.path } + }) + return changed ? next : null +} + +/** re-key one entry after a disk rename, keeping its group; null when absent */ +export function renameStarredItem( + items: StarredItem[], + oldPath: string, + newPath: string, +): StarredItem[] | null { + if (!items.some((item) => item.path === oldPath)) return null + return items.map((item) => (item.path === oldPath ? { ...item, path: newPath } : item)) +} + +/** group names in first-seen order (drives the Starred view's filter pills) */ +export function starredGroupNames(items: StarredItem[]): string[] { + const names: string[] = [] + for (const item of items) { + if (item.group && !names.includes(item.group)) names.push(item.group) + } + return names +} + +/** path → group for the grouped entries only (ungrouped paths are absent) */ +export function starredGroupMap(items: StarredItem[]): Map { + const map = new Map() + for (const item of items) { + if (item.group) map.set(item.path, item.group) + } + return map +} diff --git a/apps/docs/src/main/updater.ts b/apps/docs/src/main/updater.ts index 3950b202f..610ddc43a 100644 --- a/apps/docs/src/main/updater.ts +++ b/apps/docs/src/main/updater.ts @@ -36,6 +36,22 @@ const tUpd = createI18n({ updInstall: 'Restart & Install', updLater: 'Remind me later', }, + vi: { + updTitle: 'Cập nhật phần mềm', + updHeadline: 'Đã có phiên bản mới', + updDesc: + 'Bản cập nhật này bao gồm các cải tiến hiệu suất và sửa lỗi. Chúng tôi khuyên bạn nên cập nhật ngay bây giờ.', + updInstall: 'Khởi động lại & Cài đặt', + updLater: 'Nhắc tôi sau', + }, + tr: { + updTitle: 'Yazılım Güncellemesi', + updHeadline: 'Yeni bir sürüm var', + updDesc: + 'Bu güncelleme performans iyileştirmeleri ve hata düzeltmeleri içerir. Şimdi güncellemenizi öneririz.', + updInstall: 'Yeniden Başlat ve Yükle', + updLater: 'Daha sonra hatırlat', + }, ja: { updTitle: 'ソフトウェアアップデート', updHeadline: '新しいバージョンがあります', diff --git a/apps/docs/src/preload/index.ts b/apps/docs/src/preload/index.ts index b994bd651..e912ddf4d 100644 --- a/apps/docs/src/preload/index.ts +++ b/apps/docs/src/preload/index.ts @@ -1,6 +1,6 @@ import { contextBridge, ipcRenderer, webUtils } from 'electron' import type { IpcRendererEvent } from 'electron' -import { VIEW_IMAGE_CHANNEL } from '../shared/ipc' +import { VIEW_IMAGE_CHANNEL, type MarkdownImage, type PdfInternalLink } from '../shared/ipc' import type { AiPanelPrefs } from '@hermesoffice/ui' import type { AiChatRequest, @@ -10,14 +10,20 @@ import type { DesktopApi, MenuCommand, AutoSaveDefault, + PrintJobOptions, + PrintPaper, + ContextMenuRequest, + DocTheme, UiTheme, ZoteroRendererRequest, + MergeOutputRequest, } from '../shared/ipc' import type { ProjectApi } from '@hermesoffice/project-store' import { installDropOpenBridge } from '@hermesoffice/electron-utils/drop-open' const api: DesktopApi = { getLanguage: () => ipcRenderer.invoke('app:get-language'), + getSystemLocale: () => ipcRenderer.invoke('docs:system-locale'), onLanguageChanged: (handler) => { const listener = ( _event: IpcRendererEvent, @@ -32,12 +38,29 @@ const api: DesktopApi = { ipcRenderer.on('app:theme-changed', listener) return () => ipcRenderer.removeListener('app:theme-changed', listener) }, + getDocumentTheme: async () => { + const result: unknown = await ipcRenderer.invoke('app:get-document-theme') + return result === 'dark' || result === 'light' ? result : 'follow' + }, + onDocumentThemeChanged: (handler) => { + const listener = (_event: IpcRendererEvent, theme: DocTheme) => handler(theme) + ipcRenderer.on('app:document-theme-changed', listener) + return () => ipcRenderer.removeListener('app:document-theme-changed', listener) + }, getAutoSaveDefault: () => ipcRenderer.invoke('app:get-auto-save-default'), onAutoSaveDefaultChanged: (handler) => { const listener = (_event: IpcRendererEvent, value: AutoSaveDefault) => handler(value) ipcRenderer.on('app:auto-save-default-changed', listener) return () => ipcRenderer.removeListener('app:auto-save-default-changed', listener) }, + getAuthorName: () => ipcRenderer.invoke('app:get-author-name'), + onAuthorNameChanged: (handler) => { + const listener = (_event: IpcRendererEvent, name: unknown) => { + if (typeof name === 'string') handler(name) + } + ipcRenderer.on('app:author-name-changed', listener) + return () => ipcRenderer.removeListener('app:author-name-changed', listener) + }, getAiPanelPrefs: () => ipcRenderer.invoke('app:get-ai-panel-prefs'), setAiPanelPrefs: (patch) => ipcRenderer.invoke('app:set-ai-panel-prefs', patch), onAiPanelPrefsChanged: (handler) => { @@ -59,6 +82,7 @@ const api: DesktopApi = { respondToZotero: (response) => ipcRenderer.send('zotero:response', response), openDocx: () => ipcRenderer.invoke('docs:open'), openDocxPath: (path: string) => ipcRenderer.invoke('docs:open-path', path), + confirmDocumentReplace: () => ipcRenderer.invoke('docs:confirm-document-replace'), convertAltChunkHtml: (html: string) => ipcRenderer.invoke('docs:altchunk-html-to-docx', html), openDocxDecrypt: (path: string, password: string) => ipcRenderer.invoke('docs:open-decrypt', path, password), @@ -102,6 +126,20 @@ const api: DesktopApi = { }, respellKick: () => ipcRenderer.invoke('docs:respell-kick'), spellDiag: (line: string) => ipcRenderer.send('docs:spell-diag', line), + armContextMenu: () => ipcRenderer.send('docs:context-menu-arm'), + claimContextMenu: (seq: number) => { + ipcRenderer.sendSync('docs:context-menu-claim', seq) + }, + onContextMenuRequest: (handler) => { + const listener = (_event: IpcRendererEvent, request: ContextMenuRequest) => handler(request) + ipcRenderer.on('docs:context-menu', listener) + return () => ipcRenderer.removeListener('docs:context-menu', listener) + }, + spellAddWord: (word: string) => ipcRenderer.invoke('docs:spell-add-word', word), + spellIgnoreWord: (word: string) => ipcRenderer.invoke('docs:spell-ignore-word', word), + spellReplace: (word: string) => ipcRenderer.invoke('docs:spell-replace', word), + spellLanguages: () => ipcRenderer.invoke('docs:spell-languages'), + spellSetLanguages: (langs: string[]) => ipcRenderer.invoke('docs:spell-set-languages', langs), saveDocxAs: (defaultName: string, data: ArrayBuffer, sourcePath?: string | null) => ipcRenderer.invoke('docs:save-as', defaultName, data, sourcePath ?? null), saveDocxNew: (defaultName: string, data: ArrayBuffer) => @@ -118,14 +156,20 @@ const api: DesktopApi = { signalMcpReady: () => ipcRenderer.send('docs:mcp-ready'), getRecentFiles: () => ipcRenderer.invoke('docs:recent'), pickImage: () => ipcRenderer.invoke('docs:pick-image'), + pickMergeDataSource: () => ipcRenderer.invoke('docs:merge-data-source'), + writeMergeOutput: (request: MergeOutputRequest) => + ipcRenderer.invoke('docs:merge-output', request), fontMetrics: (family: string) => ipcRenderer.invoke('docs:font-metrics', family), - print: (scale?: number) => ipcRenderer.invoke('docs:print', scale), + print: (scale?: number, job?: PrintJobOptions, paper?: PrintPaper) => + ipcRenderer.invoke('docs:print', scale, job, paper), + listPrinters: () => ipcRenderer.invoke('docs:printers'), exportPdf: ( defaultName: string, pageWidthTwips: number, pageHeightTwips: number, outPath?: string, scale?: number, + links?: PdfInternalLink[], ) => ipcRenderer.invoke( 'docs:export-pdf', @@ -134,13 +178,24 @@ const api: DesktopApi = { pageHeightTwips, outPath, scale, + links, ), exportHtml: (defaultName: string, html: string, outPath?: string) => ipcRenderer.invoke('docs:export-html', defaultName, html, outPath), + exportMarkdown: ( + defaultName: string, + markdown: string, + images: MarkdownImage[], + outPath?: string, + ) => ipcRenderer.invoke('docs:export-markdown', defaultName, markdown, images, outPath), printPdfBuffer: (pageWidthTwips: number, pageHeightTwips: number, scale?: number) => ipcRenderer.invoke('docs:print-pdf-buffer', pageWidthTwips, pageHeightTwips, scale), - saveMergedPdf: (defaultName: string, base64Parts: string[], outPath?: string) => - ipcRenderer.invoke('docs:save-merged-pdf', defaultName, base64Parts, outPath), + saveMergedPdf: ( + defaultName: string, + base64Parts: string[], + outPath?: string, + links?: PdfInternalLink[], + ) => ipcRenderer.invoke('docs:save-merged-pdf', defaultName, base64Parts, outPath, links), pickExportImagesTarget: () => ipcRenderer.invoke('docs:pick-export-images-target'), takeExportPdf: (pdfPath: string) => ipcRenderer.invoke('docs:take-export-pdf', pdfPath), writeExportImage: (dir: string, fileName: string, pngBase64: string) => @@ -153,8 +208,18 @@ const api: DesktopApi = { }, getAiSettings: () => ipcRenderer.invoke('ai:get-settings'), setAiSettings: (settings: AiSettings) => ipcRenderer.invoke('ai:set-settings', settings), + onAiSettingsChanged: (handler) => { + const listener = () => handler() + ipcRenderer.on('ai:settings-changed', listener) + return () => ipcRenderer.removeListener('ai:settings-changed', listener) + }, + openAiModelSettings: () => ipcRenderer.invoke('ai:open-model-settings'), + openSkillsSettings: () => ipcRenderer.invoke('ai:open-skills-settings'), aiChat: (request: AiChatRequest) => ipcRenderer.invoke('ai:chat', request), aiStream: (request: AiStreamRequest) => ipcRenderer.invoke('ai:stream', request), + skillsList: () => ipcRenderer.invoke('ai:skills-list'), + skillText: (name: string) => ipcRenderer.invoke('ai:skill-text', name), + runCli: (args: string[]) => ipcRenderer.invoke('ai:run-cli', args), aiStreamCancel: (requestId: string) => ipcRenderer.invoke('ai:stream-cancel', requestId), aiGskStatus: (withEmail?: boolean) => ipcRenderer.invoke('ai:gsk-status', withEmail), aiGskLogin: () => ipcRenderer.invoke('ai:gsk-login'), @@ -178,8 +243,12 @@ const api: DesktopApi = { readAttachment: (path: string, offset: number, maxChars: number) => ipcRenderer.invoke('files:read', path, offset, maxChars), readAttachmentImage: (path: string) => ipcRenderer.invoke('files:read-image', path), + kbList: () => ipcRenderer.invoke('kb:list'), + kbSearch: (query: { q: string; limit?: number }) => ipcRenderer.invoke('kb:search', query), + kbRead: (path: string, offset: number) => ipcRenderer.invoke('kb:read', { path, offset }), getPathForFile: (file: File) => webUtils.getPathForFile(file), openNewTab: (openPath?: string | null) => ipcRenderer.invoke('win:new', openPath ?? null), + openSourcePath: (path: string) => ipcRenderer.invoke('home:open-path', path), listDocsTabs: () => ipcRenderer.invoke('win:list'), focusDocsTab: (id: string) => ipcRenderer.invoke('win:focus', id), onAiStream: (handler: (chunk: AiStreamChunk) => void) => { @@ -222,9 +291,32 @@ const projectApi: ProjectApi = { appendChat: (args) => ipcRenderer.invoke('project:appendChat', args), loadChat: (args) => ipcRenderer.invoke('project:loadChat', args), rebindChat: (args) => ipcRenderer.invoke('project:rebindChat', args), + listChats: (args) => ipcRenderer.invoke('project:listChats', args), + deleteChat: (args) => ipcRenderer.invoke('project:deleteChat', args), +} + +/** + * Model file naming: the transport half of `suggestFileName`. + * + * Inlined rather than imported — a preload entry must stay a single-file bundle, + * or the sandbox drops every global it exposed. The `ai:` channel is registered + * app-wide by the shell rather than by this app, the same arrangement the + * `project:*` bridge below already depends on. + * + * `content` crosses this bridge verbatim and the main process cannot see what + * this editor withheld, so what the caller hands over is exactly what the model + * sees. A caller that passed raw document text would undo its own redactions. + * + * The global is `aiNamingApi`, not `aiOffice`: `aiOffice` is the shell's home + * API, and a partial one in an editor window would shadow it there. + */ +const aiNamingApi = { + suggestFileName: (input: { content: string }) => + ipcRenderer.invoke('ai:suggest-file-name', input), } contextBridge.exposeInMainWorld('desktop', api) +contextBridge.exposeInMainWorld('aiNamingApi', aiNamingApi) contextBridge.exposeInMainWorld('projectApi', projectApi) // open documents dragged from the OS onto this tab as a new shell tab diff --git a/apps/docs/src/renderer/App.tsx b/apps/docs/src/renderer/App.tsx index 28d4f277f..a94ae8a74 100644 --- a/apps/docs/src/renderer/App.tsx +++ b/apps/docs/src/renderer/App.tsx @@ -1,6 +1,8 @@ import { scriptFontHtml } from './editor/script-fonts' -import { DOC_CSS_COMMITTED_EVENT } from './editor/cjk-punct-shrink' +import { carryDocSeen } from './ai/tools' +import { DOC_CSS_COMMITTED_EVENT, WRAP_WIDTHS_COMMITTED_EVENT } from './editor/cjk-punct-shrink' import { justifyShrinkPluginKey } from './editor/justify-shrink' +import { tabReflowMeta } from './editor/decoration-extensions' import { useCallback, useEffect, @@ -15,15 +17,20 @@ import { EditorContent, useEditor } from '@tiptap/react' import type { Editor } from '@tiptap/core' import { handleDocsControl, type ControlRequest } from './control' import { DOMParser as PmDOMParser, type Mark as PmMark, Slice as PmSlice } from '@tiptap/pm/model' -import { NodeSelection, type Transaction } from '@tiptap/pm/state' +import { NodeSelection, TextSelection, type Command, type Transaction } from '@tiptap/pm/state' import { Dropdown, ImageViewer, - ToastHost, - appConfirmDialog, + aiPanelInitiallyOpen, + rememberAiPanelOpen, + createZoomWheelClassifier, useAutoSavePref, + useAiSidebarEnabled, } from '@hermesoffice/ui' -import { wordRangeAtCaret } from './editor/comments' +import { commentAnchors, commentIdsAt, wordRangeAtCaret } from './editor/comments' +import { setFieldInstr, toggleAllFieldCodes, type FieldRange } from './editor/field-codes' +import { linkTarget } from './editor/link-actions' +import { FieldDialog } from './components/FieldDialog' import { markdownPasteHtml } from './editor/markdown-paste' import { pasteTextSlice, singleCellPasteText } from './editor/paste-text' import { @@ -54,6 +61,7 @@ import { BLANK_ORDERED_NUM_ID, DEFAULT_SECTION, applyPageNumType, + customLevelFromNumberingLevel, applySectionSettings, applySectionStartType, applyTitlePg, @@ -61,64 +69,133 @@ import { type Block, type CommentInfo, type CustomNumberingLevel, + type NumberingDef, type DocProtection, type WriteProtection, + mergeFieldName, nextNoteId, + placeholderText, type HeaderFooter, + type HfPictureEdit, type HfImage, type NoteInfo, + type TextboxDisplay, + type ParsedDocFull, type SectionInfo, type SectionSettings, type SourceInfo, pendingHeadingLevel, pictureWatermarkPreviewImage, + textWatermarkPreviewImage, + previewFontSettings, + type StyleInfo, type StyleUpsert, type DefaultFonts, - previewFontSettings, type ThemeColors, type ThemeFonts, type PictureWatermarkSpec, + type Watermark, type WatermarkSpec, + type CommentImage, } from '@hermesoffice/docx-engine' -import type { AiDocContent, AiSettings, OpenDocxResult } from '../shared/ipc' +import type { + AiDocContent, + AiSettings, + MenuCommand, + MergeDataSource, + OpenDocxResult, +} from '../shared/ipc' import { AI_PROVIDERS } from '../shared/ipc' +import { + buildMailMergeFiles, + type MailMergeRequest, + type MailMergeResponse, +} from './mail-merge/output' import { ZoteroDocumentController } from './zotero/controller' -import { AiPanel, AI_REVISION_AUTHOR } from './ai/AiPanel' +import { AiPanel, aiRevisionAuthor } from './ai/AiPanel' import type { AiCommentsAccess, AiDocExtras, AiHeaderFooterAccess } from './ai/tools' import type { AiStyleInfo } from './ai/style-ops' import { applyResolvedPageSetup, describeSection, type AiPageSetupAccess } from './ai/page-setup' import { patchPendingSectPr, sectionIndexAtBlock } from './ai/pending-sections' import { protectedNoteMarkBlock, type AiNotesAccess } from './ai/note-ops' import { applyHfText, hfEditText } from './editor/hf-text' -import { textColorValue } from './editor/text-color' -import { textOutlineCssValue } from './editor/text-outline' +import { + insertHfField, + insertHfInstrField, + insertHfPicture, + mountHfEditor, + type HfEditorHandle, +} from './editor/hf-editor' +import type { HfAction, HfEditingInfo } from './components/ribbon-hf-tab' +import { + hfCommitTarget, + hfLinked, + hfTextCommit, + ownHf, + resolveHf, + withHfLink, + withHfPictureEdit, + type HfSectionState, +} from './hf-sections' +import { installHfFloatEdit, tagHfFloatEl } from './editor/hf-float-edit' +import { hfFooterLine, hfLayoutResolved, hfPhantomSpec, hfWithPhantom } from './hf-phantom' +import { hfVariantHeightsPx, sectionHfHeights } from './hf-heights' +import { makeGapNotesEl, measureNoteHeightDom, noteRowMetrics } from './editor/note-dom' import { AiAskPopover } from './components/AiAskPopover' import { EDIT_QUEUE_MAX, selectionForAnchor, type DocsEditQueueItem } from './ai/edit-queue' import { addQueueAnchor, clearQueueAnchors, removeQueueAnchors } from './editor/ai-queue-anchors' -import { asianCharCount, countWords, nonAsianWordCount } from './word-count' +import { + asianCharCount, + countWords, + documentTextForWordCount, + nonAsianWordCount, + selectedWordCount, +} from './word-count' import { CommentsPanel } from './components/CommentsPanel' import { EquationModal } from './components/EquationModal' import { HeaderFooterArea } from './components/HeaderFooterArea' import { PageFootnotes, PageEndnotes } from './components/PageNoteAreas' -import { noteMarkText, type NoteKind } from './note-format' +import type { NoteKind } from './note-format' import { PaginationPreview } from './components/PaginationPreview' +import { imageParaPaginationMeta, paraPaginationMeta, tableWidowOff } from './editor/para-flags' +import { + comparePlanToBlocks, + planByElement, + planDiffHistogram, + planPropsOf, + type PlanOf, + type PlanDiff, +} from './pagination-plan' import { PrintDialog } from './components/PrintDialog' import { + hfVariantOf, + hfVariantReservedPx, appendEndnotesBlock, appendFloatSpillBlock, assignSections, bumpLineSampleFontEpoch, + carryStreamedSamples, endnotesAnchorY, - effectiveHfRefs, createLineRectsCache, anchorElement, lineStartAnchor, liveSections, nextLineAnchor, measureBlocks, + alignJoinedParagraphs, docGridPitchPt, type LineAnchor, - hfVariantOf, + hfPartWidthPx, + buildPageCuts, + headingPagesOf, + pageCutSignature, + applyGapLedger, + pagePaperEdges, + pageReaderShadow, + type FlowCarry, + LEAD_ROW_TOL_PX, + outerTableRows, + type PageCut, pageNumbers, sliceWithLineSplit, type SliceOutputs, @@ -127,6 +204,7 @@ import { type BlockMeta, type PageNoteItem, pageAt, + pageOfY, singleCutCell, columnLayoutSpecs, widthPassGate, @@ -136,29 +214,36 @@ import { verticalTextSpecs, blockInlineExtraPx, sectionVertical, + sectionPageStartSpecs, + wrapWidthSig, sectionTopMarginSpecs, sectionWidthSpecs, + mirrorMarginSpecs, paperWidthPx, pageLeftPx, sectionGridPitchPt, sectionGridPitchSpecs, docCharSpacePt, sectionCharSpaceSpecs, + singleByteCharSpacePt, type ColumnBlockPlacement, sectionBidi, sectionColGeom, sectionColumns, - sectionFirstPages, sectionGeoms, sectionPageBox, canvasContentTopPx, effectiveTopPx, effectiveBottomPx, + singleVariantGeomOf, formatPageNumber, + sectionPageCounts, visiblePageCount, type SectionGeom, type SectionHfHeights, + isResumePage, type PageSlice, + type PassResume, } from './pagination' import { GAP_BAND, @@ -168,8 +253,13 @@ import { floatFlowChangedMeta, insideFloatTable, setFloatVShifts, + floatCarriesOf, + floatShiftsOf, setOversizeClips, setPageGaps, + stampGapFlowTops, + canvasZoomOf, + rowFillsSig, setRowFills, syncAnchorBands, syncCutOverlays, @@ -178,35 +268,52 @@ import { syncPageSheets, clampCellBoxTops, clampCellImageTops, + pinCellBoxesToPage, + pinPageRelXPictures, + pinParagraphRelVPictures, pageBorderStyleOf, type PageGapSpec, + type GapMetrics, LayoutBatch, + pageFramesFromGaps, + gapLedgerOf, + PAGE_TURN_SEL, + type PageFrame, } from './editor/pagination-gaps' +import { syncWrapObstacles } from './editor/wrap-obstacles' +import { newObstacleShadow, obstaclePlanDiffs } from './editor/obstacle-plan' +import { MultipageGrid, snapshotCanvas, type CanvasSnapshot } from './components/MultipageGrid' +import { layoutMultipage, multipageColumns, multipageZoomForColumns } from './multipage' import { setColumnLayout } from './editor/column-layout' +import { pageMargins } from './page-margins' import { syncLineNumbers } from './editor/line-numbers' +import { formattingMarksPluginKey, sectionBreakMap } from './editor/formatting-marks' import { - MARKUP_AREA_W, + COMMENT_AREA_W, clearMarginAnnotations, syncMarginAnnotations, + type MarginHandlers, } from './editor/margin-annotations' import { bumpHfProbeFontEpoch, + hfHasPageField, hfHasVisibleContent, - hfReservedHeightPx, + hfPageNumberOnly, + hfWithoutPageMarks, hfStripGeom, + hfBodyObstacles, makeGapHfEl, + hfImageHangsOnPara, makeHfFloatImgEl, type HfFloatBox, } from './editor/hf-dom' import { - cssFontFamily, estimateFootnoteHeight, resolveNoteStyle, noteRunStyle, - hfHeaderGeom, footnoteLineHeightPx, - noteLineHeightPx, FOOTNOTE_SEPARATOR_H, + takeSettledMetricFaces, textHasCjk, } from './line-metrics' import { saveUntilPersisted } from './save-until-persisted' @@ -214,10 +321,45 @@ import { SPELLCHECK_KEY, spellcheckEnabled } from './spellcheck-pref' import { cachedByDoc } from './doc-cache' import { useShallowStable, useStableCallbacks } from './use-stable' import { FindPanel } from './components/FindPanel' -import { Ribbon } from './components/Ribbon' +import { ZoomDialog } from './components/ZoomDialog' +import { TrackChangesOptionsDialog } from './components/TrackChangesOptionsDialog' +import { + loadTrackChangesOptions, + movesByAuthor, + saveTrackChangesOptions, + trackChangesOptionsCss, + trackChangesOptionsState, + type TrackChangesOptions, +} from './editor/track-changes-options' +import type { PageLayout } from './editor/goto' +import { Ribbon, type ConvertFormat } from './components/Ribbon' +import { + AdjustListIndentsDialog, + DefineBulletDialog, + DefineMultilevelDialog, + DefineNumberFormatDialog, + SetNumberingValueDialog, + type ListDialogKind, +} from './components/ListDialogs' +import { + documentListPresets, + numberPresetLevels, + recentListPresets, + rememberListPreset, +} from './list-presets' import { computeFormatState } from './components/ribbon-format-state' -import { IconRedo, IconSave, IconUndo } from './components/icons' import { + IconOutlineView, + IconPrintLayout, + IconReadMode, + IconRedo, + IconSave, + IconUndo, + IconWebLayout, +} from './components/icons' +import { ToastHost } from '@hermesoffice/ui' +import { + imageSizeOf, AI_REWRITE_ACK_KEY, LinkInsertModal, TableInsertModal, @@ -227,7 +369,6 @@ import { insertImageViaDialog, insertPageBreakAt, setParaAttrs, - type InkPenSettings, type RevisionDisplayMode, type ViewMode, } from './components/ribbon-tabs' @@ -238,9 +379,33 @@ import { ParagraphDialog, type ContextMenuState, } from './components/ContextMenu' +import { RedactDialog } from './editor/RedactDialog' +import { + CellMarginsDialog, + DeleteCellsDialog, + InsertCellsDialog, + SplitCellsDialog, + type TableDialogKind, +} from './components/TableDialogs' +import { TablePropertiesDialog } from './components/TablePropertiesDialog' +import { showToast } from '@hermesoffice/ui' +import { setTableAutoFit, toggleRepeatHeaderRows } from './editor/table-properties' +import { distributeSelectedColumns } from './editor/table-sizing' +import { + deleteTableColumn, + distributeRowsEvenly, + editableOrToast, + ensureCaretInTable, + insertRowsOrColumns, + selectTablePart, + splitTableAtSelection, +} from './editor/table-ops' +import { deleteRow, deleteTable, mergeCells } from '@tiptap/pm/tables' import { PromptModal } from './components/PromptModal' import { ShortcutsDialog } from './components/ShortcutsDialog' import { WordCountDialog, type DocStats } from './components/WordCountDialog' +import { AutoCorrectDialog } from './components/AutoCorrectDialog' +import { PreferencesDialog } from './components/PreferencesDialog' import { applyCase, nextCaseMode, selectionText } from './editor/case-transform' import { stepHangingIndent, stepParagraphIndent } from './editor/indent' import { PasswordDialog } from './components/PasswordDialog' @@ -251,51 +416,117 @@ import { setActiveSubEditor, subscribeSubEditorState, } from './editor/active-editor' +import { installSelectionBar } from './editor/selection-bar' import type { CompareEntry } from './editor/compare' import { collectHeadings } from './editor/headings' -import { applyTocPageDisplays } from './editor/toc-refresh' +import { applyTocPageDisplays, backfillTocPageDom } from './editor/toc-refresh' import { setSelectionAlign } from './editor/direction' import { editorExtensions, findFloatImageAt, + runPictureAtEvent, resolvedCommentsPluginKey, revisionDisplayState, } from './editor/extensions' -import { setDkColor } from './editor/dark-page' -import { useUiThemeIsDark } from './ui-theme' +import { resolveDarkPage, writeDarkPagePref } from './dark-page-pref' +import { useDocThemeIsDark, useUiThemeIsDark } from './ui-theme' import { type InkAnnotation, type InkTool } from './editor/ink' +import { loadInkPresets, saveInkPresets, type InkPreset } from './editor/ink-presets' import { InkOverlay } from './components/InkOverlay' -import { collectRevisions, gotoRevision, type TrackChangesStorage } from './editor/revisions' +import { + TRACK_IGNORE, + collectRevisions, + selectRevision, + type RevisionRange, + type TrackChangesStorage, +} from './editor/revisions' +import { + DEFAULT_REVISION_FILTER, + authorInkIndex, + buildReviewEntries, + filterRevisions, + revisionAuthorCss, + revisionAuthors, + revisionsInSelection, + type ReviewEntry, + type RevisionFilter, + type RevisionScope, +} from './editor/revision-filters' +import { ReviewingPane } from './components/ReviewingPane' +import { + collectHfRevisions, + collectNoteRevisions, + collectTextboxRevisions, + liveStoryRevision, + ownHfSlots, + resolveHfValue, + resolveNotes, + resolveTextboxes, + type StoryRevision, +} from './editor/story-revisions' +import { + BALLOON_MODE_KEY, + balloonKindOf, + balloonRevKinds, + loadBalloonMode, + markupPrintsIn, + noteRunsInView, + revisionHiddenIn, + type BalloonMode, +} from './editor/revision-view' +import { setInlineComments } from './editor/balloon-markers' import { NavPane } from './components/NavPane' +import { loadLeftPane, saveLeftPane, type LeftPane } from './components/pane-width' +import { + commentsView, + iconClickTarget, + loadCommentsMode, + saveCommentsMode, + type CommentsMode, +} from './components/comments-mode' +import { StylesPane } from './components/StylesPane' import { Ruler } from './components/Ruler' -import { docBodyFont, docLineFactor, docThemeCss, docStyleCss } from './doc-style-css' -import { isDocDirty } from './doc-dirty' +import { docBodyFont, docHasCjk, docLineFactor, docStyleCss, docThemeCss } from './doc-style-css' +import { isDocDirty, runGuardedCandidate, runGuardedDocumentAction } from './doc-dirty' import { EMPTY_HF_VARIANTS, - hfFromPart, restingHfAreaVariant, type DocState, type HfVariantKey, type HfVariantsState, type HfView, + EMPTY_PENDING_NUMBERING, + pendingNumberingDirty, type PendingNumbering, } from './doc-state' import { applyAiDocContent as applyAiDocContentImpl, exportPdf as exportPdfImpl, exportHtml as exportHtmlImpl, + exportMarkdown as exportMarkdownImpl, exportImages as exportImagesImpl, loadFile as loadFileImpl, newFile as newFileImpl, printDoc as printDocImpl, save as saveImpl, + suggestedSaveAsName, writeRecoveryCopy as writeRecoveryCopyImpl, type FileActionContext, type PendingPdfExport, - currentDocGeneration, } from './file-actions' +import { BlockIndex } from './pagination-index' import { isPhasedContentPending } from './phased-content' +import { sameSectionInfos } from './pagination-sections' +import { + blocksKeepSlicing, + firstChangedTopLevelIndex, + singleInlineEditIndex, + type FastPassBlock, +} from './editor/pagination-fast-path' +import { FollowUpChain } from './pagination-loop' +import { tabLayoutDiag } from './editor/decoration-extensions' +import { layoutHealth } from './editor/layout-health' /** min gap between whole-document pagination passes while a phased open streams its tail */ const STREAMING_PASS_GAP_MS = 1500 @@ -305,14 +536,31 @@ const STREAMING_PASS_GAP_MAX_MS = 15_000 /** after an edit, the pass waits this many times its own last duration (300 ms floor) */ const EDIT_PASS_DUTY = 3 const EDIT_PASS_DEBOUNCE_MAX_MS = 2000 +/** a pass re-running for its own late-applied widths / suppression: nobody is + * typing, so it only waits for the previous pass's DOM writes to commit */ +const FOLLOW_UP_PASS_DELAY_MS = 100 const WORD_COUNT_THROTTLE_MS = 400 /** consecutive follow-up passes a pass may schedule for itself */ -const MAX_FOLLOW_UP_PASSES = 6 import { runHeadlessDocumentExport } from './headless-export' import { installMcpBridge } from './mcp-bridge' +import { + clampDocsZoom, + DOCS_ZOOM_MAX, + DOCS_ZOOM_MIN, + nextDocsZoom, + stepDocsZoom, + type ZoomFitMode, +} from './wheel-zoom' import { allocateListNumId as allocateListNumIdImpl, + addPictureBullet, + changeListLevel as changeListLevelImpl, continueNumbering as continueNumberingImpl, + currentListTarget as currentListTargetImpl, + currentListValue, + editListLevel as editListLevelImpl, + hasPreviousList, + setNumberingValue as setNumberingValueImpl, createCustomListDef as createCustomListDefImpl, restartNumbering as restartNumberingImpl, type NumberingContext, @@ -322,11 +570,16 @@ import { addCommentAt as addCommentAtImpl, cancelNewComment as cancelNewCommentImpl, clearInks as clearInksImpl, + commentThreadAtCaret as commentThreadAtCaretImpl, compareWithFile as compareWithFileImpl, + deleteAllComments as deleteAllCommentsImpl, deleteComment as deleteCommentImpl, + deleteCommentAtCaret as deleteCommentAtCaretImpl, deleteNote as deleteNoteImpl, editComment as editCommentImpl, + gotoComment as gotoCommentImpl, handleRevision as handleRevisionImpl, + handleRevisionRange as handleRevisionRangeImpl, removeInks as removeInksImpl, replyToComment as replyToCommentImpl, resolveComment as resolveCommentImpl, @@ -336,6 +589,7 @@ import { type NotePrompt, type ReviewContext, } from './review-actions' +import { authorName, onAuthorNameChanged } from './author-name' const IS_MAC = navigator.platform.toLowerCase().includes('mac') @@ -352,8 +606,8 @@ const EMPTY_BLOCKS: Block[] = [] // O(doc) derivations cached by PM doc reference: caret moves and unrelated // state updates reuse the last result instead of re-walking the whole document -const wordCountOfDoc = cachedByDoc((d) => countWords(d.textContent)) -const revisionCountOfDoc = cachedByDoc((d) => collectRevisions(d).length) +const wordCountOfDoc = cachedByDoc((d) => countWords(documentTextForWordCount(d))) +const revisionsOfDoc = cachedByDoc((d) => collectRevisions(d)) /** * Document position of a measured line-start DOM anchor. posAtDOM works from the DOM @@ -471,123 +725,6 @@ function blockNoteScanRuns(b: Block): NonNullable { return out } -/** Note entry content: superscript number + styled runs (shared by the canvas gap area and the hidden height measurer) */ -function appendNoteRuns( - row: HTMLElement, - it: Omit, - kind: NoteKind, -): void { - if (!it.noRefMark) { - const sup = document.createElement('sup') - sup.textContent = noteMarkText(kind, it.no) - row.append(sup) - } - if (it.richParas) { - it.richParas.forEach((para, pi) => { - if (pi > 0) row.append(document.createElement('br')) - for (const run of para) { - const span = document.createElement('span') - span.textContent = run.text - if (run.bold) span.style.fontWeight = '600' - if (run.italic) span.style.fontStyle = 'italic' - const deco = [run.underline && 'underline', run.strike && 'line-through'].filter(Boolean) - if (deco.length > 0) span.style.textDecoration = deco.join(' ') - if (run.color) { - span.style.color = textColorValue(run.color) - // dark-page twin; the authored color stays the declaration - if (run.color !== 'auto') setDkColor(span, run.color) - } - if (run.sizeHalfPoints) span.style.fontSize = `${run.sizeHalfPoints / 2}pt` - if (run.fontAscii) span.style.fontFamily = cssFontFamily(run.fontAscii) - if (run.textOutline) span.style.webkitTextStroke = textOutlineCssValue(run.textOutline) - if (run.caps === 'all') span.style.textTransform = 'uppercase' - else if (run.caps === 'small') span.style.fontVariantCaps = 'small-caps' - row.append(span) - } - }) - } else { - const span = document.createElement('span') - span.textContent = it.text - row.append(span) - } -} - -/** - * Hidden DOM measurement of one note entry at the renderers' exact styles: the - * char-width estimate undercounts complex scripts (prod100r4/022 Arabic notes - * overprinted each other), so reservation and drawing share the DOM wrap truth. - */ -let noteMeasureHost: HTMLDivElement | null = null -function measureNoteHeightDom( - entry: Pick, - kind: NoteKind, - widthPx: number, - lineHeightPx: number, - fontSizePt: number, - fontFamily?: string, -): number | null { - if (typeof document === 'undefined' || !document.body || widthPx <= 0) return null - if (!noteMeasureHost || !noteMeasureHost.isConnected) { - noteMeasureHost = document.createElement('div') - noteMeasureHost.style.cssText = - 'position:absolute;left:-99999px;top:0;visibility:hidden;pointer-events:none;text-align:left;text-indent:0;' - document.body.appendChild(noteMeasureHost) - } - noteMeasureHost.style.fontFamily = - fontFamily ?? "Calibri, 'DengXian', 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif" - noteMeasureHost.style.width = `${widthPx}px` - noteMeasureHost.style.fontSize = `${fontSizePt}pt` - noteMeasureHost.style.lineHeight = `${lineHeightPx}px` - const row = document.createElement('div') - appendNoteRuns(row, entry, kind) - // the renderers' sup style (CSS classes don't reach the detached host) - const sup = row.querySelector('sup') - if (sup) { - sup.style.fontSize = '0.65em' - sup.style.marginRight = '4px' - } - noteMeasureHost.replaceChildren(row) - const h = row.getBoundingClientRect().height - noteMeasureHost.replaceChildren() - return h > 0 ? h : null -} - -/** 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[], - leftPx: number, - widthPx: number, - heightPx: number, - lineHeightPx: number, - onEdit: (id: string) => void, -): HTMLElement { - const wrap = document.createElement('div') - wrap.className = 'page-gap-notes' - wrap.style.left = `${leftPx}px` - // paper x: block gaps start at the paper edge, mid-paragraph gaps at their page's - // edge — alignGapHfStrips re-anchors from the actual gap origin - wrap.dataset.paperX = leftPx.toFixed(1) - wrap.style.width = `${widthPx}px` - wrap.style.height = `${heightPx}px` - // same line height as the reservation model; min-height keeps rows at the - // reserved size while letting long entries overflow instead of clipping - wrap.style.lineHeight = `${lineHeightPx}px` - for (const it of items) { - const row = document.createElement('div') - row.className = 'page-gap-note' - row.title = t('appDblclickEditFootnote') - row.style.minHeight = `${it.height}px` - // per-note resolved style: the entry's line boxes must match its reservation - if (it.lineHeightPx) row.style.lineHeight = `${it.lineHeightPx}px` - if (it.fontSizePt) row.style.fontSize = `${it.fontSizePt}pt` - if (it.fontFamily) row.style.fontFamily = it.fontFamily - appendNoteRuns(row, it, 'footnote') - row.addEventListener('dblclick', () => onEdit(it.id)) - wrap.appendChild(row) - } - return wrap -} - const DEFAULT_SETTINGS: AiSettings = { provider: 'anthropic', providers: Object.fromEntries( @@ -598,7 +735,55 @@ const DEFAULT_SETTINGS: AiSettings = { ) as AiSettings['providers'], } +/** Table menu items that are plain editor commands (the dialogs and Distribute Rows are handled inline) */ +const TABLE_MENU_COMMANDS: Partial Command>> = { + 'table-insert-rows-above': (w) => insertRowsOrColumns(1, 'above', w), + 'table-insert-rows-below': (w) => insertRowsOrColumns(1, 'below', w), + 'table-insert-cols-left': (w) => insertRowsOrColumns(1, 'left', w), + 'table-insert-cols-right': (w) => insertRowsOrColumns(1, 'right', w), + 'table-delete-table': () => deleteTable, + 'table-delete-columns': () => deleteTableColumn, + 'table-delete-rows': () => deleteRow, + 'table-select-table': () => selectTablePart('table'), + 'table-select-column': () => selectTablePart('column'), + 'table-select-row': () => selectTablePart('row'), + 'table-select-cell': () => selectTablePart('cell'), + 'table-merge-cells': () => mergeCells, + 'table-split-table': () => splitTableAtSelection(), + 'table-autofit-contents': (w) => setTableAutoFit('contents', w), + 'table-autofit-window': (w) => setTableAutoFit('window', w), + 'table-autofit-fixed': (w) => setTableAutoFit('fixed', w), + 'table-distribute-columns': (w) => distributeSelectedColumns(w), + 'table-repeat-header': () => toggleRepeatHeaderRows(), +} + /** heading level after a define_style patch: null outlineLevel drops heading status, a number sets it, absent keeps the current */ +/** An encrypted file is not a document yet: the replace guard waits until the + * password prompt has decrypted one (submitDocPwd), so a cancelled or wrong + * password never saves or discards the document that is still open. */ +function replacesDocument(result: NonNullable): boolean { + return !('needsPassword' in result) +} + +const warnedPlanClasses = new Set() + +/** I9 shadow: plan vs scrape for the measured blocks; disagreements are reported, never applied */ +function shadowPlanDiff( + planOf: PlanOf, + blocks: BlockBox[], + metaOf: (docxIndex: number) => BlockMeta | undefined, +): PlanDiff[] { + const diffs = comparePlanToBlocks(blocks, planOf, metaOf) + if (import.meta.env.DEV) { + for (const d of diffs) { + if (warnedPlanClasses.has(d.cls)) continue + warnedPlanClasses.add(d.cls) + console.warn(`[pagination] plan/scrape disagree on ${d.cls}`, d) + } + } + return diffs +} + export function App() { // subscribe to language switches for re-render; strings all go through module-level t, so memoized callbacks never capture stale closures const { lang } = useI18n() @@ -628,8 +813,18 @@ export function App() { } | null>(null) const [_recent, setRecent] = useState([]) const [settings, setSettings] = useState(DEFAULT_SETTINGS) - const [showAi, setShowAi] = useState(() => localStorage.getItem('aidocs.showAi') !== '0') + const [showAi, setShowAi] = useState(() => aiPanelInitiallyOpen('aidocs.showAi')) + // Settings › "show the AI sidebar" off hides the panel and its rail the way Word + // treats the Copilot pane: an explicit Ask-AI action (ribbon, context menu, + // shortcut, preset) still opens the panel for this document, and closing it + // returns to hidden. The dock stays mounted either way, so panel state and + // in-flight runs survive; the hook closes it when the switch itself goes off. + const aiSidebarEnabled = useAiSidebarEnabled(setShowAi) const [spellcheck, setSpellcheck] = useState(spellcheckEnabled) + const [largeDocSpellOff, setLargeDocSpellOff] = useState(false) + const spellcheckActive = spellcheck && !largeDocSpellOff + const spellcheckActiveRef = useRef(spellcheckActive) + spellcheckActiveRef.current = spellcheckActive /** Increments on every open/new document: AiPanel remounts by key to reset the conversation and history (save path changes don't bump it, so the session continues) */ const [aiPanelKey, setAiPanelKey] = useState(0) const [ribbonTabRequest, setRibbonTabRequest] = useState<{ tab: string; nonce: number } | null>( @@ -638,18 +833,41 @@ export function App() { const [status, setStatus] = useState('') const [zoom, setZoom] = useState(100) const scrollContainerRef = useRef(null) - // Word-style dark page (editor/dark-page.ts): on by default in the dark theme, - // View ▸ Dark Mode flips it for the session (Word's Switch Modes); a theme - // switch drops the override and follows the new theme again + /** bumps after every pagination pass: the canvas' page gaps are in place */ + const [layoutRev, setLayoutRev] = useState(0) + /** Multiple Pages grid: page index under the live editor, null while single-column */ + const multipageActiveRef = useRef(null) + const mpFramesRef = useRef([]) + // Word-style dark page (editor/dark-page.ts): the shell's document-page-theme + // setting (hermesoffice#1811) decides which theme the page follows; a light + // page theme never shows a dark page, in the dark one it is on by default + // until the user switches it off, and that choice sticks (dark-page-pref.ts) const themeDark = useUiThemeIsDark() - const [darkPage, setDarkPage] = useState(themeDark) - useEffect(() => setDarkPage(themeDark), [themeDark]) + const docThemeDark = useDocThemeIsDark() + const [darkPage, setDarkPage] = useState(() => resolveDarkPage(docThemeDark)) + useEffect(() => { + setDarkPage(resolveDarkPage(docThemeDark)) + }, [docThemeDark]) + // for toggle-by-one in the menu path, whose closure is not re-created per render + const darkPageRef = useRef(darkPage) + darkPageRef.current = darkPage + /** View ▸ Dark Mode (menu and ribbon): remember the choice, then apply it */ + const updateDarkPage = useCallback( + (next: boolean) => { + if (docThemeDark) writeDarkPagePref(next) + setDarkPage(next) + }, + [docThemeDark], + ) const [section, setSection] = useState(null) /** All sections (readSections): pagination/preview use per-section geometry; layout edits apply to the cursor's section */ const [sections, setSections] = useState([]) const [sectionDirty, setSectionDirty] = useState(false) /** Index of the cursor's section (maintained by selectionUpdate) */ const [activeSection, setActiveSection] = useState(0) + // displayed number of the caret's page: a mirrored document's ruler follows its parity + const [caretPageNo, setCaretPageNo] = useState(1) + const locateCaretPageRef = useRef<() => void>(() => {}) /** Indexes of edited non-final sections (their section-break paragraphs' sectPr is rewritten on save) */ const [sectionsDirty, setSectionsDirty] = useState([]) /** Start type pending write to the trailing sectPr after inserting a continuous section break */ @@ -666,158 +884,90 @@ export function App() { const [titlePgDirty, setTitlePgDirty] = useState(false) const [evenOddHf, setEvenOddHf] = useState(false) const [evenOddHfDirty, setEvenOddHfDirty] = useState(false) - 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 [mirrorMargins, setMirrorMargins] = useState(false) + const [mirrorMarginsDirty, setMirrorMarginsDirty] = useState(false) 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) + /** SECTIONPAGES of the first and last pages (the canvas's own header and footer strips) */ + const [edgeSectionPages, setEdgeSectionPages] = useState<{ first: number; last: 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) const [pgNumDirtySections, setPgNumDirtySections] = useState([]) /** Pending numbering definitions to append (saved via SaveOptions.numbering; restart numbering / new list definitions) */ - const [pendingNumbering, setPendingNumbering] = useState({ - newDefs: [], - restartNums: [], - }) - const numberingDirty = - pendingNumbering.newDefs.length > 0 || pendingNumbering.restartNums.length > 0 + const [pendingNumbering, setPendingNumbering] = + useState(EMPTY_PENDING_NUMBERING) + const numberingDirty = pendingNumberingDirty(pendingNumbering) /** Header/footer edits for non-final sections (key = `${lastBlockIndex}:${kind}`), saved via SaveOptions.sectionHf */ const [sectionHfEdits, setSectionHfEdits] = useState>({}) - /** The canvas header/footer area always views/edits the first section (multi-section docs: later sections inherit its refs) */ - const hfSectionClamped = 0 - const multiHf = sections.length > 1 - const effRefsAll = useMemo(() => effectiveHfRefs(sections), [sections]) - const effHfView: HfView = - (hfView === 'first' && !titlePg) || (hfView === 'even' && !evenOddHf) ? 'default' : hfView - /** Multi-section: the selected section's effective header/footer (local edits > referenced part > final section falls back to global edit state) */ - const sectionHfValue = (kind: 'header' | 'footer'): HeaderFooter | null => { - const idx = hfSectionClamped - const sec = sections[idx] - const globalState = kind === 'header' ? header : footer - if (!sec) return globalState - const refVariants = effRefsAll[idx]?.[kind] - const fromPart = () => { - const rId = refVariants?.default - return rId ? hfFromPart(doc?.parsed.hfParts?.[rId]) : null - } - if (idx === sections.length - 1) { - // final section: reuse the global edit state (save writes the final section's sectPr reference) - const dirty = kind === 'header' ? headerDirty : footerDirty - return dirty ? globalState : (fromPart() ?? globalState) - } - return sectionHfEdits[`${sec.lastBlockIndex}:${kind}`] ?? fromPart() + /** Link to Previous switched on for `${sectionIdx}:${kind}`: the section's own default reference goes on save */ + const [hfLinks, setHfLinks] = useState>({}) + /** the strip whose editor is open (drives the contextual Header & Footer tab) */ + const [hfEditing, setHfEditing] = useState<{ + kind: 'header' | 'footer' + section: number + variant: HfView + /** absolute page index of the strip (formatted numbers repeat across restarted sections) */ + pageIdx: number + /** section whose part the strip showed when the editor opened */ + owner: number + } | null>(null) + const hfEditingRef = useRef(hfEditing) + hfEditingRef.current = hfEditing + /** an edge strip commits to the variant its editor opened with, not the view a toggle switched to meanwhile */ + const edgeHfTarget = (kind: 'header' | 'footer', view: HfView) => { + const open = hfEditingRef.current + return open?.kind === kind + ? { variant: open.variant, owner: open.owner } + : { variant: view, owner: undefined } } - /** 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 */ + /** bumps on document changes so derived section state follows deleted break paragraphs */ + const [docVersion, setDocVersion] = useState(0) + const hfEditorRef = useRef(null) + const [hfAreaEditRequest, setHfAreaEditRequest] = useState<{ + kind: 'header' | 'footer' + nonce: number + } | null>(null) + const multiHf = sections.length > 1 + /** Variant an on-canvas edge area shows/edits: like Word, it follows its page — 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 } : {}), - }) + restingHfAreaVariant(kind, { + titlePg, + evenOddHf, + pageCount: pageInfo.total, + ...(lastPageNo ? { lastPageNo: lastPageNo.num } : {}), + }) const headerAreaView = areaView('header') const footerAreaView = areaView('footer') - const shownHeader = - headerAreaView === 'first' - ? hfVariants.headerFirst - : headerAreaView === 'even' - ? hfVariants.headerEven - : multiHf - ? sectionHfValue('header') - : header - const shownFooter = - footerAreaView === 'first' - ? hfVariants.footerFirst - : footerAreaView === 'even' - ? hfVariants.footerEven - : multiHf - ? sectionHfValue('footer') - : footer - /** images of the part in the current view (logos etc., display-only; the save path keeps their bytes untouched) */ - const hfImagesOf = (kind: 'header' | 'footer') => { - const parsed = doc?.parsed - if (!parsed) return undefined - const raw = (() => { - const view = kind === 'header' ? headerAreaView : footerAreaView - if (view === 'first') { - return (kind === 'header' ? parsed.headerFirst : parsed.footerFirst)?.images - } - if (view === 'even') { - return (kind === 'header' ? parsed.headerEven : parsed.footerEven)?.images - } - if (multiHf) { - const rId = effRefsAll[hfSectionClamped]?.[kind]?.default - const fromPart = rId ? parsed.hfParts?.[rId]?.images : undefined - if (fromPart?.length) return fromPart - } - return (kind === 'header' ? parsed.headerImages : parsed.footerImages) ?? undefined - })() - // floating shapes (watermarks) must not stack into the strip (mirrors PaginationPreview) - return raw?.filter((img) => !img.floating) - } + /** the AI header/footer tool edits the strip the matching edge area shows */ const commitHf = (kind: 'header' | 'footer', next: HeaderFooter, viewOverride?: HfView) => { - const view = viewOverride ?? (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 (view === 'default' && multiHf && hfSectionClamped < sections.length - 1) { - const sec = sections[hfSectionClamped] - setSectionHfEdits((m) => ({ ...m, [`${sec.lastBlockIndex}:${kind}`]: next })) - return - } - if (view === 'default') { - if (kind === 'header') { - setHeader(next) - setHeaderDirty(true) - } else { - setFooter(next) - setFooterDirty(true) - } - return - } - const key: HfVariantKey = - view === 'first' - ? kind === 'header' - ? 'headerFirst' - : 'footerFirst' - : kind === 'header' - ? 'headerEven' - : 'footerEven' - setHfVariants((v) => ({ ...v, [key]: next })) - setHfVariantsDirty((d) => (d.includes(key) ? d : [...d, key])) + commitHfAt( + kind === 'header' ? 0 : lastSectionIdx, + kind, + viewOverride ?? (kind === 'header' ? headerAreaView : footerAreaView), + next, + ) } - /** "Different first page" toggle, shared by the ribbon checkbox and the on-page chip */ + /** "Different first page" toggle (ribbon checkbox); page 1's header/footer become the first-page variant, as in Word */ const toggleTitlePg = (on: boolean) => { + hfEditorRef.current?.exit() setTitlePg(on) setTitlePgDirty(true) - setHfView(on ? 'first' : 'default') setStatus(on ? t('appTitlePgOn') : t('appTitlePgOff')) } - /** Unsaved section header/footer overrides for the pagination preview (default variant): local edits > document parts */ - const sectionHfOverride = useCallback( - (si: number, kind: 'header' | 'footer'): HeaderFooter | null => { - const sec = sections[si] - if (!sec) return null - if (si === sections.length - 1) { - const dirty = kind === 'header' ? headerDirty : footerDirty - return dirty ? (kind === 'header' ? header : footer) : null - } - return sectionHfEdits[`${sec.lastBlockIndex}:${kind}`] ?? null - }, - [sections, sectionHfEdits, header, footer, headerDirty, footerDirty], - ) const [showMarks, setShowMarks] = useState(false) const [showRuler, setShowRuler] = useState(false) - const [showNav, setShowNav] = useState(() => localStorage.getItem('aidocs.showNav') === '1') - const closeNav = useCallback(() => setShowNav(false), []) + const [leftPane, setLeftPane] = useState(loadLeftPane) + const showNav = leftPane === 'nav' + const setShowNav = useCallback((on: boolean) => setLeftPane(on ? 'nav' : null), []) + const closeNav = useCallback(() => setLeftPane(null), []) + // Review → Reviewing Pane: the outermost right-edge panel (Word's vertical pane) + const [showReviewPane, setShowReviewPane] = useState(false) const [viewMode, setViewMode] = useState('print') const [readMode, setReadMode] = useState(false) const [showGrid, setShowGrid] = useState(false) @@ -828,12 +978,45 @@ export function App() { const [imageDragOver, setImageDragOver] = useState(false) const [findFocusInput, setFindFocusInput] = useState(0) const [findFocusReplace, setFindFocusReplace] = useState(0) + const [findFocusGoTo, setFindFocusGoTo] = useState(0) + // last pagination pass, read by the Go To tab (page / section / line targets) + const pageLayoutRef = useRef(null) + /** live section list of the last pass (header obstacles of the next pass) */ + const lastSecListRef = useRef(null) + const cutsNow = useCallback(() => pageLayoutRef.current?.cuts, []) + /** runs the debounced pagination pass now (set by the scheduler effect) */ + const flushPaginationRef = useRef<() => void>(() => {}) + /** the current pass's layout, after any pending pass: readers that need + * the document as it stands (TOC numbers, the preview snapshot) */ + const layoutNow = useCallback((): PageLayout | null => { + flushPaginationRef.current() + return pageLayoutRef.current + }, []) const [showShortcuts, setShowShortcuts] = useState(false) + const [showAutoCorrect, setShowAutoCorrect] = useState(false) + const [showPreferences, setShowPreferences] = useState(false) const ribbonActionsRef = useRef<{ stepFontSize?: (dir: 1 | -1) => void nudgeFontSize?: (dir: 1 | -1) => void }>({}) - const [showComments, setShowComments] = useState(false) + // Show Comments (Word's top-right toggle): Contextual cards beside the page or the List panel + const [showComments, setShowComments] = useState(true) + const [commentsMode, setCommentsModeState] = useState(loadCommentsMode) + const setCommentsMode = useCallback((mode: CommentsMode) => { + saveCommentsMode(mode) + setCommentsModeState(mode) + setShowComments(true) + }, []) + // thread popped up from its anchor icon while Show Comments is off + const [commentPopup, setCommentPopup] = useState(null) + // Contextual mode opens the panel for a composer or a focus the margin cannot host; it stays until closed + const [commentPanelOpen, setCommentPanelOpen] = useState(false) + const [showStylesPane, setShowStylesPane] = useState(false) + const [commentFocus, setCommentFocus] = useState<{ + id: string + nonce: number + reply?: boolean + } | null>(null) /** Style definitions pending write-back (key = styleId), saved via SaveOptions.styleUpserts */ const [defaultFonts, setDefaultFonts] = useState() const fontSettingsVersionRef = useRef(0) @@ -857,24 +1040,49 @@ export function App() { // the pending picture watermark as the header image it will parse back as, // sized against the last section's margin box like the save path const pendingWatermarkImage = useMemo(() => { - if (!watermarkDirty || !watermarkPicture) return null - const s = sections[sections.length - 1]?.settings ?? section - const box = s - ? { - widthPt: (s.pageWidth - s.marginLeft - s.marginRight) / 20, - heightPt: (s.pageHeight - s.marginTop - s.marginBottom) / 20, - } - : null - return pictureWatermarkPreviewImage(watermarkPicture, box) - }, [watermarkDirty, watermarkPicture, sections, section]) + if (!watermarkDirty) return null + if (watermarkPicture) { + const s = sections[sections.length - 1]?.settings ?? section + const box = s + ? { + widthPt: (s.pageWidth - s.marginLeft - s.marginRight) / 20, + heightPt: (s.pageHeight - s.marginTop - s.marginBottom) / 20, + } + : null + return pictureWatermarkPreviewImage(watermarkPicture, box) + } + if (watermark) return textWatermarkPreviewImage({ text: watermark, ...(watermarkStyle ?? {}) }) + return null + }, [watermarkDirty, watermarkPicture, watermark, watermarkStyle, sections, section]) + /** Design tab / AI set_watermark: pending stores the save path drains */ + const applyWatermark = useCallback((spec: Watermark | null) => { + const wm = typeof spec === 'string' ? { text: spec } : spec + if (wm && 'image' in wm) { + setWatermark(null) + setWatermarkStyle(null) + setWatermarkPicture(wm) + } else if (wm) { + const { text, ...style } = wm + setWatermark(text) + setWatermarkStyle(Object.keys(style).length > 0 ? style : null) + setWatermarkPicture(null) + } else { + setWatermark(null) + setWatermarkStyle(null) + setWatermarkPicture(null) + } + setWatermarkDirty(true) + }, []) const [inkAnnotations, setInkAnnotations] = useState([]) const [inksDirty, setInksDirty] = useState(false) const [inkTool, setInkTool] = useState('select') - const [inkPen, setInkPen] = useState({ color: 'C00000', width: 2 }) - const [inkHighlighter, setInkHighlighter] = useState({ - color: 'FFFF00', - width: 10, - }) + const [inkPresets, setInkPresetsState] = useState(loadInkPresets) + const [inkActiveId, setInkActiveId] = useState(() => inkPresets[0].id) + const setInkPresets = useCallback((next: InkPreset[]) => { + setInkPresetsState(next) + saveInkPresets(next) + }, []) + const inkActive = inkPresets.find((p) => p.id === inkActiveId) ?? inkPresets[0] const [footnotes, setFootnotes] = useState([]) /** Footnote ids already shown in canvas page gaps (the end-of-document list skips them to avoid duplication) */ const [gapNoteIds, setGapNoteIds] = useState>(new Set()) @@ -897,13 +1105,55 @@ export function App() { const [themeColorsDirty, setThemeColorsDirty] = useState(false) const [commentComposing, setCommentComposing] = useState(false) const [trackChanges, setTrackChanges] = useState(false) + // only the document toggle (ribbon / status bar / shortcut) maps to w:trackRevisions + const [trackRevisionsDirty, setTrackRevisionsDirty] = useState(false) + const setTrackChangesByUser = useCallback((on: boolean) => { + setTrackChanges(on) + setTrackRevisionsDirty(true) + }, []) + const toggleTrackChangesByUser = useCallback(() => { + setTrackChanges((v) => !v) + setTrackRevisionsDirty(true) + }, []) const [revisionDisplay, setRevisionDisplay] = useState('all') + /** the user picked a Markup view this session: opening another document keeps it */ + const revisionDisplayRef = useRef(revisionDisplay) + revisionDisplayRef.current = revisionDisplay + const [revisionFilter, setRevisionFilter] = useState(DEFAULT_REVISION_FILTER) + const revisionFilterRef = useRef(revisionFilter) + revisionFilterRef.current = revisionFilter + /** Show Markup → Balloons; an application preference like Word's */ + const [balloonMode, setBalloonMode] = useState(loadBalloonMode) + useEffect(() => { + localStorage.setItem(BALLOON_MODE_KEY, balloonMode) + }, [balloonMode]) + /** Track Changes Options (Word's Advanced Options): screen markup styling + recorder switches */ + const [tcOptions, setTcOptions] = useState(loadTrackChangesOptions) + const [showTcOptions, setShowTcOptions] = useState(false) + const tcOptionsStyle = useMemo(() => trackChangesOptionsCss(tcOptions), [tcOptions]) // the original view restores old formatting via decorations: sync the mode and trigger one repaint + const balloonsActive = markupPrintsIn(revisionDisplay, viewMode) + const commentCardsPossible = + balloonsActive && balloonMode !== 'inline' && revisionDisplay !== 'simple' + const commentsUi = commentsView( + showComments, + commentsMode, + commentCardsPossible, + commentPanelOpen, + ) + useEffect(() => { + if (commentComposing) setCommentPanelOpen(true) + }, [commentComposing]) + useEffect(() => { + if (commentFocus && !commentCardsPossible) setCommentPanelOpen(true) + }, [commentFocus, commentCardsPossible]) useEffect(() => { revisionDisplayState.mode = revisionDisplay + revisionDisplayState.balloons = balloonMode + revisionDisplayState.balloonsActive = balloonsActive if (editor) editor.view.dispatch(editor.state.tr.setMeta('addToHistory', false)) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [revisionDisplay]) + }, [revisionDisplay, balloonMode, balloonsActive]) // section breaks on tracked-deleted paragraph marks don't break in Word's // markup views (only the Original view restores them) const delSectBreaks = useMemo( @@ -955,15 +1205,26 @@ export function App() { // so a value computed in the render pass that introduced new CSS is replaced // by a post-commit measure instead of going stale const [hfMeasureEpoch, setHfMeasureEpoch] = useState(0) + // docCss already carries the parse-time factor: the live override only + // exists once the body's CJK-ness differs from what parsing saw. Keying the + // commit event on it (not on liveDocCjk) spares the shrink measurers a + // second whole-document pass when the first live recompute agrees. + const parsedDocCjk = useMemo(() => (doc ? docHasCjk(doc.parsed) : false), [doc]) + const liveLineFactor = + doc && liveDocCjk != null && liveDocCjk !== parsedDocCjk + ? docLineFactor(doc.parsed, liveDocCjk) + : null useEffect(() => { setHfMeasureEpoch((e) => e + 1) // measurement views that read computed alignment/spacing re-run once the // stylesheet is in the DOM (setContent measured against the previous one) document.dispatchEvent(new Event(DOC_CSS_COMMITTED_EVENT)) - }, [docCss, liveDocCjk, themeFonts, themeColors]) + }, [docCss, liveLineFactor, themeFonts, themeColors, revisionDisplay]) const [stats, setStats] = useState(null) const [showLinkModal, setShowLinkModal] = useState(false) + const [fieldEdit, setFieldEdit] = useState(null) const [showTableModal, setShowTableModal] = useState(false) + const [showZoomDialog, setShowZoomDialog] = useState(false) const [showEquationModal, setShowEquationModal] = useState(false) const [eqEditTarget, setEqEditTarget] = useState<{ pos: number @@ -971,11 +1232,54 @@ export function App() { kind: 'inline' | 'block' } | null>(null) const [ctxMenu, setCtxMenu] = useState(null) + const ctxSeq = useRef(0) + useEffect(() => { + window.desktop.armContextMenu?.() + return window.desktop.onContextMenuRequest?.((req) => { + setCtxMenu((prev) => + prev && prev.seq === req.seq + ? { + ...prev, + spell: req.misspelledWord + ? { word: req.misspelledWord, suggestions: req.suggestions } + : null, + } + : prev, + ) + }) + }, []) const [viewImage, setViewImage] = useState(null) useEffect(() => window.desktop.onViewImage?.((src) => setViewImage(src)), []) const saveImageAs = useCallback((src: string) => void window.desktop.saveImageAs(src), []) const [showFontDialog, setShowFontDialog] = useState(false) const [showParaDialog, setShowParaDialog] = useState(false) + /** label the reader gives a span they are withholding from the model */ + const [redactTarget, setRedactTarget] = useState<{ + from: number + to: number + seed: string + } | null>(null) + /** Word's table dialogs, shared by the ribbon, the right-click menu and the Table menu */ + const [tableDialog, setTableDialog] = useState(null) + const [tableGridlines, setTableGridlines] = useState( + () => localStorage.getItem('aidocs.tableGridlines') === '1', + ) + const toggleTableGridlines = useCallback( + () => + setTableGridlines((on) => { + localStorage.setItem('aidocs.tableGridlines', on ? '0' : '1') + return !on + }), + [], + ) + // section content width the table commands / dialogs fit the grid into (same formula as the ribbon) + const tableSection = sections[activeSection]?.settings ?? section + const tableSectionWidthPx = tableSection + ? Math.max( + 1, + (tableSection.pageWidth - tableSection.marginLeft - tableSection.marginRight) / 15, + ) + : 624 const [, forceRender] = useReducer((x: number) => x + 1, 0) const dirtyRef = useRef(false) // serializes save(): overlapping saves (Cmd+S vs autosave timer vs blur) would @@ -992,8 +1296,10 @@ export function App() { extensions: editorExtensions, content: { type: 'doc', content: [{ type: 'docParagraph' }] }, editorProps: { - // Word checks spelling as you type by default; user toggle in Review → Spelling - attributes: { class: 'doc-page', spellcheck: spellcheckEnabled() ? 'true' : 'false' }, + // Word checks spelling as you type by default; user toggle in Review → Spelling. + // useEditor re-applies these options on every render, so the value must + // be the effective one or a re-render undoes the large-document override + attributes: { class: 'doc-page', spellcheck: spellcheckActive ? 'true' : 'false' }, // Word/web HTML cleanup: strip mso comments and , unwrap
  • x

  • // (docListItem is an inline container; block-level p would shatter the list) transformPastedHTML: cleanPastedHtml, @@ -1239,6 +1545,254 @@ export function App() { forceRender() }, }) + useEffect(() => { + saveTrackChangesOptions(tcOptions) + trackChangesOptionsState.current = tcOptions + if (!editor) return + editor.storage.trackChanges.trackMoves = tcOptions.trackMoves + editor.storage.trackChanges.trackFormatting = tcOptions.trackFormatting + editor.view.dispatch(editor.state.tr.setMeta('addToHistory', false)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tcOptions, editor]) + + // every header/footer consumer (strips, gaps, heights, preview, AI, save) reads + // through one resolver, so pending edits, Link to Previous and inheritance agree + // the section list as Word sees it right now: a deleted break paragraph merges its + // section into the next, pending breaks are already in `sections`. Derived from the + // document itself (not the paint-time measurement), so a strip edit lands in the right + // slot even before the canvas has re-measured + const hfSectionsMemoRef = useRef(null) + const hfSections = useMemo(() => { + if (!editor || sections.length <= 1) return sections + const present: Array<{ docxIndex: number }> = [] + editor.state.doc.forEach((node) => { + const di = node.attrs?.docxIndex + if (typeof di === 'number') present.push({ docxIndex: di }) + }) + const next = liveSections(sections, present as BlockBox[], undefined, delSectBreaks) + // every transaction re-runs this memo, and a streaming open appends a + // chunk per frame: a fresh array per chunk re-ran the pagination effect + // (a synchronous whole-document pass each) for every chunk. Unchanged + // content keeps the previous identity. + const prev = hfSectionsMemoRef.current + if (prev && sameSectionInfos(prev, next)) return prev + hfSectionsMemoRef.current = next + return next + // docVersion: the doc identity is what changes, and it is not a React value + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editor, sections, delSectBreaks, docVersion]) + const hfState = useMemo( + () => ({ + sections: hfSections, + edits: sectionHfEdits, + links: hfLinks, + docStrips: { header, footer, variants: hfVariants }, + parsed: doc?.parsed ?? null, + }), + [hfSections, sectionHfEdits, hfLinks, header, footer, hfVariants, doc], + ) + const hfResolveAt = useCallback( + (si: number, kind: 'header' | 'footer', variant: HfView) => + resolveHf(hfState, si, kind, variant), + [hfState], + ) + const hfValueAt = useCallback( + (si: number, kind: 'header' | 'footer', variant: HfView) => + resolveHf(hfState, si, kind, variant).value, + [hfState], + ) + const hfLinkedAt = (si: number, kind: 'header' | 'footer', variant: HfView) => + hfLinked(hfState, si, kind, variant) + const lastSectionIdx = Math.max(0, hfSections.length - 1) + const shownHeader = hfValueAt(0, 'header', headerAreaView) + const shownFooter = hfValueAt(lastSectionIdx, 'footer', footerAreaView) + /** images of the strip an edge area shows (logos etc., display-only; floating shapes render separately) */ + const hfImagesOf = (kind: 'header' | 'footer') => + hfResolveAt( + kind === 'header' ? 0 : lastSectionIdx, + kind, + kind === 'header' ? headerAreaView : footerAreaView, + ).images?.filter((img) => !img.floating) + /** + * Write an edit made on section `si`'s strip to the part that owns it. `ownerAtOpen` + * (the owner when the editor opened) refuses a write whose owner moved to an earlier + * section meanwhile: Link to Previous under an open editor must not overwrite the + * strip it reconnects to. + */ + const commitHfAt = ( + si: number, + kind: 'header' | 'footer', + variant: HfView, + next: HeaderFooter, + ownerAtOpen?: number, + ): boolean => { + const target = hfCommitTarget(hfState, si, kind, variant, ownerAtOpen) + if (!target) return false + // functional: two commits in one turn (Remove Page Numbers on both strips, an + // editor exit racing an AI set) must both land + setSectionHfEdits((m) => ({ ...m, [target.key]: hfTextCommit(m[target.key], next) })) + return true + } + /** a floating header picture moved or resized on section `si`'s strip + * (hf-float-edit); false when the strip has no part to take it */ + const commitHfPictureAt = ( + si: number, + kind: 'header' | 'footer', + variant: HfView, + drawing: number, + edit: HfPictureEdit, + ): boolean => { + if (!hfCommitTarget(hfState, si, kind, variant)) return false + setSectionHfEdits( + (m) => + withHfPictureEdit({ ...hfState, edits: m }, si, kind, variant, drawing, edit)?.edits ?? m, + ) + return true + } + /** pending changes outside the body, refreshed with the revision badge (see storyRevisions memo) */ + const storyRevisionsRef = useRef([]) + const hfStateRef = useRef(hfState) + hfStateRef.current = hfState + // live mirrors: an AI turn applies several decisions between renders + const footnotesLiveRef = useRef(footnotes) + footnotesLiveRef.current = footnotes + const endnotesLiveRef = useRef(endnotes) + endnotesLiveRef.current = endnotes + /** accept / reject changes in notes, header / footer parts and textboxes; the count applied */ + const applyStoryRevisions = ( + targets: StoryRevision[], + mode: 'accept' | 'reject', + all = false, + ): number => { + const live = storyRevisionsRef.current + const picked = targets + .map((t) => liveStoryRevision(live, t)) + .filter((r): r is StoryRevision => !!r) + if (picked.length === 0 && !all) return 0 + const locs = (kind: StoryRevision['story']['kind']) => + picked.filter((r) => r.story.kind === kind).map((r) => r.story) + const nextNotes = { footnote: footnotesLiveRef.current, endnote: endnotesLiveRef.current } + for (const kind of ['footnote', 'endnote'] as const) { + const mine = locs(kind) + if (mine.length === 0 && !all) continue + // Accept All also clears what the models do not show (paragraph marks): every entry replays + nextNotes[kind] = all + ? resolveNotes(nextNotes[kind], mine, mode, true).map((n) => + n.revisionsResolved?.some((r) => !r.filter) + ? n + : { ...n, revisionsResolved: [...(n.revisionsResolved ?? []), { mode }] }, + ) + : resolveNotes(nextNotes[kind], mine, mode) + ;(kind === 'footnote' ? setFootnotes : setEndnotes)(nextNotes[kind]) + ;(kind === 'footnote' ? footnotesLiveRef : endnotesLiveRef).current = nextNotes[kind] + setNotesDirty(true) + } + let state = hfStateRef.current + const slots = new Map() + for (const r of picked) { + if (!r.story.hf) continue + const key = `${r.story.hf.section}:${r.story.hf.kind}:${r.story.hf.variant}` + slots.set(key, [...(slots.get(key) ?? []), r]) + } + const hfTargets = all + ? ownHfSlots(state).map((slot) => ({ + ...slot, + revs: slots.get(`${slot.si}:${slot.kind}:${slot.variant}`) ?? [], + })) + : [...slots.values()].map((revs) => { + const { section: si, kind, variant } = revs[0].story.hf! + return { si, kind, variant, value: ownHf(state, si, kind, variant)?.value, revs } + }) + for (const slot of hfTargets) { + const { si, kind, variant, revs } = slot + let value = slot.value + if (!value) continue + let pending = Object.values(state.edits).includes(value) + // a strip editor open on this part would commit its pre-resolution runs on + // exit: drain what was typed into the value first, then drop the editor + const open = hfEditingRef.current + const handle = hfEditorRef.current + if (handle && open && open.kind === kind && open.owner === si) { + const typed = handle.changed() + handle.discard() + if (typed) { + value = typed + pending = true + } + } + const next = resolveHfValue( + value, + revs.map((r) => r.story), + mode, + pending, + all, + ) + const target = hfCommitTarget(state, si, kind, variant) + if (!target) continue + commitHfAt(si, kind, variant, next) + state = { ...state, edits: { ...state.edits, [target.key]: next } } + hfStateRef.current = state + } + if (editor) { + const tr = editor.state.tr.setMeta(TRACK_IGNORE, true) + const byPos = new Map() + for (const r of picked) { + if (r.story.textbox) + byPos.set(r.story.textbox.pos, [...(byPos.get(r.story.textbox.pos) ?? []), r]) + } + for (const [pos, revs] of byPos) { + const node = tr.doc.nodeAt(pos) + const boxes = node?.attrs?.textboxes as TextboxDisplay[] | null | undefined + if (!node || !boxes) continue + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + textboxes: resolveTextboxes( + boxes, + revs.map((r) => r.story), + mode, + ), + }) + } + if (tr.docChanged) editor.view.dispatch(tr) + // the list a follow-up in the same turn reads must already show the outcome + storyRevisionsRef.current = [ + ...collectHfRevisions(state), + ...collectTextboxRevisions(editor.state.doc), + ...collectNoteRevisions(editor.state.doc, 'footnote', nextNotes.footnote), + ...collectNoteRevisions(editor.state.doc, 'endnote', nextNotes.endnote), + ] + } + dirtyRef.current = true + return picked.length + } + const setHfLink = (si: number, kind: 'header' | 'footer', variant: HfView, on: boolean) => { + if ( + on && + !window.confirm(t(kind === 'header' ? 'appHfLinkConfirmHeader' : 'appHfLinkConfirmFooter')) + ) + return + // the open editor holds the strip being discarded: it must not commit it anywhere + if (on) hfEditorRef.current?.discard() + if (!withHfLink(hfState, si, kind, variant, on)) return + setSectionHfEdits( + (m) => withHfLink({ ...hfState, edits: m }, si, kind, variant, on)?.edits ?? m, + ) + setHfLinks((l) => withHfLink({ ...hfState, links: l }, si, kind, variant, on)?.links ?? l) + } + const setHfDistance = (si: number, kind: 'header' | 'footer', twips: number) => { + // `si` indexes the live list; the saved list (what the save rewrites) is matched by its break paragraph + const live = hfSections[si] + const saved = live ? sections.findIndex((s) => s.lastBlockIndex === live.lastBlockIndex) : -1 + const cur = (saved >= 0 ? sections[saved].settings : undefined) ?? section + if (!cur) return + const next = { ...cur, [kind === 'header' ? 'headerDist' : 'footerDist']: twips } + if (saved >= 0) + setSections((prev) => prev.map((s, i) => (i === saved ? { ...s, settings: next } : s))) + if (sections.length <= 1 || saved < 0 || saved === sections.length - 1) { + setSection(next) + setSectionDirty(true) + } else setSectionsDirty((d) => (d.includes(saved) ? d : [...d, saved])) + } // textbox sub-editors: re-render the ribbon on focus/selection changes and // mark the document dirty when their content changes @@ -1253,19 +1807,25 @@ export function App() { useEffect(() => { void window.desktop.getRecentFiles().then(setRecent) - void window.desktop.getAiSettings().then(setSettings) + const loadSettings = () => void window.desktop.getAiSettings().then(setSettings) + loadSettings() + return window.desktop.onAiSettingsChanged?.(loadSettings) }, []) useEffect(() => { - localStorage.setItem('aidocs.showAi', showAi ? '1' : '0') + rememberAiPanelOpen('aidocs.showAi', showAi) }, [showAi]) useEffect(() => { - localStorage.setItem('aidocs.showNav', showNav ? '1' : '0') - }, [showNav]) + saveLeftPane(leftPane) + }, [leftPane]) - const spellcheckWasOn = useRef(spellcheck) + const spellcheckWasOn = useRef(spellcheckActive) const respellKickBusy = useRef(false) + // bumped by the context menu after Add to Dictionary / a language change: + // the session checker is updated but existing squiggles only refresh on typing + const [respellNonce, setRespellNonce] = useState(0) + const respellNonceSeen = useRef(0) // spell-diag trace: squiggle losses in the field are intermittent and // platform-bound, so the toggle/kick lifecycle leaves a line in // userData/spell-diag.log for support to collect (never breaks the app) @@ -1280,7 +1840,7 @@ export function App() { let cancelRespellKick = () => {} localStorage.setItem(SPELLCHECK_KEY, spellcheck ? '1' : '0') spellDiag( - `toggle spellcheck=${spellcheck} platform=${navigator.platform} composing=${editor.view.composing}`, + `toggle spellcheck=${spellcheckActive} platform=${navigator.platform} composing=${editor.view.composing}`, ) // setOptions (not a direct DOM write): ProseMirror re-applies editorProps // attributes on every updateState, so only the props route sticks @@ -1289,7 +1849,7 @@ export function App() { ...editor.options.editorProps, attributes: { ...(editor.options.editorProps.attributes as Record), - spellcheck: spellcheck ? 'true' : 'false', + spellcheck: spellcheckActive ? 'true' : 'false', }, }, }) @@ -1306,7 +1866,8 @@ export function App() { // transaction, no history entry, no dirty flag) and a capture-phase key // shield keeps its keymap and other key handlers out of the round trip — // the same shield also tells us when the keystroke has actually landed. - if (spellcheck && !spellcheckWasOn.current) { + const respellRequested = respellNonce !== respellNonceSeen.current + if (spellcheckActive && (!spellcheckWasOn.current || respellRequested)) { let attempts = 0 // cancel a pending retry/rAF if the effect re-runs (another off/on) or the // component unmounts — two overlapping kick chains would each pause the DOM @@ -1324,7 +1885,7 @@ export function App() { const view = editor.view const dom = view.dom if (!dom.isConnected) return spellDiag('kick abandoned: editor gone') - if (!spellcheckEnabled()) return spellDiag('kick canceled: toggled off again') + if (!spellcheckActiveRef.current) return spellDiag('kick canceled: toggled off again') // a live IME composition (French dead keys, CJK input) or an in-flight // kick used to make the re-enable return silently, so existing typos // were never re-marked (one "the toggle worked only once" path). @@ -1379,8 +1940,8 @@ export function App() { if (e.type === 'keydown' && !kickDown) pass = kickDown = true else if (e.type === 'keypress' && !kickPress) pass = kickPress = true } - if (pass) sawKick = true - else e.preventDefault() + if (pass && e.type === 'keypress') sawKick = true + else if (!pass) e.preventDefault() e.stopPropagation() } document.addEventListener('keydown', shield, true) @@ -1426,14 +1987,15 @@ export function App() { while (!sawKick && Date.now() < deadline) { await new Promise((r) => setTimeout(r, 30)) } - // Linux/mac Hunspell respells in the keystroke's wake; the Windows - // OS spellchecker samples the text asynchronously with throttling, - // and a scrub 0.12s after the keystroke erased the mutation before - // the service ever saw it (squiggles only - // returned when repeated toggles happened to straddle a sampling - // window). Let the typed state live long enough to be sampled. - if (sawKick && /win/i.test(navigator.platform)) { - await new Promise((r) => setTimeout(r, 900)) + // Blink respells from an idle task and drops the answer if the text + // changed meanwhile: scrub behind the task and the checker's reply + if (sawKick) { + await new Promise((r) => { + if (typeof requestIdleCallback === 'function') + requestIdleCallback(() => r(), { timeout: 400 }) + else setTimeout(r, 250) + }) + await new Promise((r) => setTimeout(r, /win/i.test(navigator.platform) ? 900 : 150)) } scrub() } finally { @@ -1471,25 +2033,30 @@ export function App() { } rafId = requestAnimationFrame(runKick) } - spellcheckWasOn.current = spellcheck + spellcheckWasOn.current = spellcheckActive + respellNonceSeen.current = respellNonce return () => cancelRespellKick() - }, [editor, spellcheck]) + }, [editor, spellcheck, spellcheckActive, respellNonce]) - // Pinch-to-zoom: Chromium delivers trackpad pinch as a wheel event - // with ctrlKey set. Also support ⌘+scroll. Must be non-passive to preventDefault. + // Ctrl/⌘+wheel zoom: Chromium delivers a trackpad pinch as ctrlKey wheel + // events with small fractional deltas (kept continuous), while a mouse notch + // steps ten percentage points. Must be non-passive to preventDefault. // The viewport point under the cursor is stashed for the zoom layout effect // below so the content there stays put (public #238); set only when the zoom // actually changes, else a clamped tick would leave a stale anchor behind. const zoomAnchorRef = useRef<{ vx: number; vy: number } | null>(null) useEffect(() => { + const zoomWheel = createZoomWheelClassifier() const onWheel = (e: WheelEvent) => { if (!e.ctrlKey && !e.metaKey) return if (!(e.target as HTMLElement | null)?.closest?.('.editor-scroll')) return e.preventDefault() + const intent = zoomWheel.feed(e, e.timeStamp) + if (!intent) return const rect = scrollContainerRef.current?.getBoundingClientRect() const anchor = rect ? { vx: e.clientX - rect.left, vy: e.clientY - rect.top } : null setZoom((z) => { - const next = Math.min(200, Math.max(50, z - e.deltaY * 0.6)) + const next = nextDocsZoom(z, intent, e.deltaY) if (next !== z) zoomAnchorRef.current = anchor return next }) @@ -1507,34 +2074,6 @@ export function App() { // shrink the overflow and clamp them before this effect gets to read them. const scrollPosRef = useRef({ left: 0, top: 0 }) const prevZoomRef = useRef(zoom) - useLayoutEffect(() => { - const prevZoom = prevZoomRef.current - prevZoomRef.current = zoom - const anchor = zoomAnchorRef.current - zoomAnchorRef.current = null - const container = scrollContainerRef.current - const page = container?.querySelector('.doc-zoom') - if (!container || !page || prevZoom === zoom) return - - const ratio = zoom / prevZoom - const vx = anchor ? anchor.vx : container.clientWidth / 2 - const vy = anchor ? anchor.vy : container.clientHeight / 2 - const cs = getComputedStyle(container) - const padLeft = parseFloat(cs.paddingLeft) || 0 - const innerWidth = container.clientWidth - padLeft - (parseFloat(cs.paddingRight) || 0) - const containerRect = container.getBoundingClientRect() - const pageRect = page.getBoundingClientRect() - const pageLeft = pageRect.left - containerRect.left + container.scrollLeft - const pageTop = pageRect.top - containerRect.top + container.scrollTop - const prevPageLeft = padLeft + Math.max(0, (innerWidth - pageRect.width / ratio) / 2) - const prev = scrollPosRef.current - - const docX = Math.max(0, prev.left + vx - prevPageLeft) - const docY = Math.max(0, prev.top + vy - pageTop) - container.scrollLeft = pageLeft + docX * ratio - vx - container.scrollTop = pageTop + docY * ratio - vy - scrollPosRef.current = { left: container.scrollLeft, top: container.scrollTop } - }, [zoom]) // ---- protection enforcement (Review > Protect Document) ---- const editRestriction = protection?.enforced ? protection.edit : null @@ -1557,24 +2096,28 @@ export function App() { }, [trackChangesForced, trackChanges]) // Read Mode / Protect Document: the document becomes read-only; Esc leaves Read Mode. - // A phased open is read-only too: edits while the tail streams could be - // interleaved with (or serialized without) the not-yet-appended blocks. + // A phased open stays editable (the append boundary is guarded by + // StreamingTailGuardExtension; saves wait for the full content). useEffect(() => { if (!editor) return - editor.setEditable(!readMode && !isProtected && !docLoading) + editor.setEditable(!readMode && !isProtected) if (!readMode) return const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setReadMode(false) } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [editor, readMode, isProtected, docLoading]) + }, [editor, readMode, isProtected]) // Track Changes: the recorder plugin reads its toggle from extension storage useEffect(() => { if (!editor) return const storage = editor.storage.trackChanges as TrackChangesStorage storage.enabled = trackChanges + storage.author = authorName() + return onAuthorNameChanged(() => { + storage.author = authorName() + }) }, [editor, trackChanges]) // forms protection locks the body but the form's own checkboxes stay @@ -1585,6 +2128,14 @@ export function App() { !writeLocked && editRestriction === 'forms' && !readMode && !docLoading }, [editor, writeLocked, editRestriction, readMode, docLoading]) + // outline view (View ▸ Outline View) folds: the plugin draws the ⊕/⊖ handles + // and hides folded heading rows only while the view mode is outline; leaving + // the view clears the collapsed set (view state, not document state) + useEffect(() => { + if (!editor) return + editor.commands.setOutlineViewActive(viewMode === 'outline') + }, [editor, viewMode]) + // window title follows the document, so the OS window list and Switch Window show file names useEffect(() => { document.title = doc ? doc.fileName : 'HermesOffice Docs' @@ -1668,11 +2219,19 @@ export function App() { const [pendingExportTick, setPendingExportTick] = useState(0) // Print dialog (Word-style preview + range); the pagination preview is its print source const [showPrintDialog, setShowPrintDialog] = useState(false) + const showPrintDialogRef = useRef(false) + showPrintDialogRef.current = showPrintDialog + // Chromium is capturing the live .pv-page sheets: Escape / Close must not unmount them + const [exportLocksPreview, setExportLocksPreview] = useState(false) // the dialog auto-opened the pagination preview: close it again with the dialog const printAutoOpenedPreviewRef = useRef(false) /** App state bundle for the extracted file actions (file-actions.ts); refreshed every render. */ const fileCtxRef = useRef(null as unknown as FileActionContext) + // style-pane previews patch the parsed styles; a font-settle rebuild must keep them + const liveStylesRef = useRef<{ parsed: ParsedDocFull; styles: Map } | null>( + null, + ) fileCtxRef.current = { editor, doc, @@ -1690,6 +2249,8 @@ export function App() { setAiPanelKey, setDocCss, setDocLoading, + setReadMode, + setLargeDocSpellOff, setShowPagePreview, section, sectionDirty, @@ -1719,6 +2280,9 @@ export function App() { setHfVariantsDirty, sectionHfEdits, setSectionHfEdits, + pageCount: pageInfo.total, + hfLinks, + setHfLinks, titlePg, titlePgDirty, evenOddHf, @@ -1727,11 +2291,10 @@ export function App() { setTitlePgDirty, setEvenOddHf, setEvenOddHfDirty, - // opening a document resets to the resting per-page variant selection - setHfView: (v: HfView) => { - setHfViewState(v) - setHfViewTouched(false) - }, + mirrorMargins, + mirrorMarginsDirty, + setMirrorMargins, + setMirrorMarginsDirty, pgNumEdit, pgNumDirtySections, setPgNumEdit, @@ -1753,6 +2316,8 @@ export function App() { setComments, setCommentsDirty, setShowComments, + setCommentPanelOpen, + setCommentPopup, setCommentComposing, watermark, watermarkDirty, @@ -1789,7 +2354,10 @@ export function App() { setThemeFontsDirty, setThemeColors, setThemeColorsDirty, + trackChanges, setTrackChanges, + trackRevisionsDirty, + setTrackRevisionsDirty, protection, protectionDirty, setProtection, @@ -1818,6 +2386,14 @@ export function App() { if (outcome === 'failed' && !fileCtxRef.current.doc) await newFileImpl(fileCtxRef.current) return outcome }, []) + const resetFile = useCallback(() => newFileImpl(fileCtxRef.current), []) + const commitOpenedFile = useCallback( + async (result: OpenDocxResult) => { + if (!result) return + await loadFile(result) + }, + [loadFile], + ) // file renamed externally (renamed in the shell Home list) → sync the save path and title-bar file name (content unchanged) useEffect( @@ -1839,7 +2415,7 @@ export function App() { useEffect(() => { if (!editor) return const unsubscribe = window.desktop.onOpenDocx((result) => { - void loadFile(result) + void commitOpenedFile(result) }) // With no pending file the window lands directly in the editor on a blank document // (the AI panel carries the generate-from-prompt flow). StrictMode runs the mount @@ -1860,9 +2436,9 @@ export function App() { // line explaining why (github.com/genspark-ai/hermesoffice issue #102). // 'password': the prompt is up; its cancel path lands on blank instead. const outcome = pending ? await loadFile(pending) : 'canceled' - if (outcome === 'canceled') await newFile() + if (outcome === 'canceled') await resetFile() if (aiContent && !pending) { - // fileCtxRef refreshes per render: wait until newFile's setDoc landed + // fileCtxRef refreshes per render: wait until resetFile's setDoc landed for (let i = 0; i < 100 && !fileCtxRef.current.doc; i++) { await new Promise((resolve) => setTimeout(resolve, 20)) } @@ -1873,27 +2449,37 @@ export function App() { .catch(() => { if (bootHandledRef.current) return bootHandledRef.current = true - void newFile().catch(() => {}) + void resetFile().catch(() => {}) }) return unsubscribe - // newFile depends on editor (already in deps); we capture it by closure - // rather than listing it to avoid a forward-reference TypeScript error - // (newFile is declared after this effect in source order). - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [editor, loadFile]) + }, [commitOpenedFile, editor, loadFile, resetFile]) - const openFile = useCallback(async () => { - await loadFile(await window.desktop.openDocx()) - }, [loadFile]) + const openFile = useCallback( + () => + runGuardedCandidate( + () => window.desktop.confirmDocumentReplace(), + () => window.desktop.openDocx(), + commitOpenedFile, + replacesDocument, + ), + [commitOpenedFile], + ) /** new document from the built-in blank template (AI can then generate into it) */ - const newFile = useCallback(() => newFileImpl(fileCtxRef.current), []) + const newFile = useCallback( + () => runGuardedDocumentAction(() => window.desktop.confirmDocumentReplace(), resetFile), + [resetFile], + ) const openRecent = useCallback( - async (path: string) => { - await loadFile(await window.desktop.openDocxPath(path)) - }, - [loadFile], + (path: string) => + runGuardedCandidate( + () => window.desktop.confirmDocumentReplace(), + () => window.desktop.openDocxPath(path), + commitOpenedFile, + replacesDocument, + ), + [commitOpenedFile], ) /** decrypt-and-open retry loop for the password prompt (wrong password stays in the dialog) */ @@ -1902,8 +2488,15 @@ export function App() { setDocPwdPrompt({ ...docPwdPrompt, busy: true, errorKey: '' }) const res = await window.desktop.openDocxDecrypt(docPwdPrompt.path, docPwdPrompt.value) if (res.ok) { + // Open deferred the replace guard for the encrypted file (see + // replacesDocument); it runs once, here, now that a real document exists. + // Declining keeps both the prompt and the current document. + if (!(await window.desktop.confirmDocumentReplace())) { + setDocPwdPrompt((current) => (current ? { ...current, busy: false } : current)) + return + } setDocPwdPrompt(null) - await loadFile(res.result) + await commitOpenedFile(res.result) return } setDocPwdPrompt({ @@ -1917,7 +2510,7 @@ export function App() { const cancelDocPwd = () => { setDocPwdPrompt(null) // canceling a boot-time open leaves no document: land on blank, not "Opening…" - if (!fileCtxRef.current.doc) void newFile() + if (!fileCtxRef.current.doc) void resetFile() } /** apply the diff the Protect Document dialog produced (undefined field = unchanged) */ @@ -1973,6 +2566,68 @@ export function App() { [], ) + // ---- Mailings: the data source and the merge ---- + + /** the recipient list the user picked; null until they pick one */ + const [mergeSource, setMergeSource] = useState(null) + + /** open the file dialog and read the recipient list the user chose */ + const pickMergeDataSource = useCallback(async () => { + const result = await window.desktop.pickMergeDataSource() + if (!result) return + if (!result.ok) { + setStatus(result.error) + return + } + setMergeSource(result.source) + setStatus( + t('appMergeSourceLoaded', { + name: result.source.name, + n: result.source.records.length, + }), + ) + }, []) + + /** build the merged documents and write them where the user says */ + const mergeMailings = useCallback( + async (request: MailMergeRequest): Promise => { + try { + const files = await buildMailMergeFiles(fileCtxRef.current, request) + if (files.length === 0) return { ok: false, error: t('appMergeNothingToWrite') } + const written = await window.desktop.writeMergeOutput({ + mode: request.mode, + files: files.map((file) => ({ + name: file.name, + data: file.bytes.buffer.slice( + file.bytes.byteOffset, + file.bytes.byteOffset + file.bytes.byteLength, + ) as ArrayBuffer, + })), + }) + if (written.ok) { + setStatus(t('appMergeWritten', { n: written.count ?? files.length })) + } + return written + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } + }, + [], + ) + + /** + * The reader asked the model for a name on a document that already has one. + * + * The stem is a proposal for the Save As dialog, never a rename: the dialog + * is where the reader confirms, and moving a file that may be open elsewhere + * or synced is not ours to do quietly. + */ + const nameWithAi = useCallback(async () => { + const proposed = await suggestedSaveAsName(fileCtxRef.current) + if (!proposed) return + await saveImpl(fileCtxRef.current, true, false, proposed) + }, []) + // inserting a section break needs one save for the new section to take effect; the // flag is consumed in the render after state commit, guaranteeing the save closure // sees the latest sectionsDirty/trailingStartType @@ -2126,6 +2781,15 @@ export function App() { setPendingNumbering, numIdFloorRef, setStatus, + linkStyle: (styleId, numId, ilvl) => + setStyleUpserts((prev) => ({ + ...prev, + [styleId]: { + ...(prev[styleId] ?? { styleId }), + styleId, + pPr: { ...(prev[styleId]?.pPr ?? {}), numPr: { numId, ilvl } }, + }, + })), } const createCustomListDef = useCallback( @@ -2139,6 +2803,78 @@ export function App() { const restartNumbering = useCallback(() => restartNumberingImpl(numberingCtxRef.current), []) const continueNumbering = useCallback(() => continueNumberingImpl(numberingCtxRef.current), []) + /** which list dialog is open (ribbon galleries and the right-click menu open them) */ + const [listDialog, setListDialog] = useState(null) + const [listRecentsEpoch, setListRecentsEpoch] = useState(0) + const recentLists = useMemo( + () => ({ + bullets: recentListPresets('bullets'), + numbers: recentListPresets('numbers'), + multi: recentListPresets('multi'), + }), + // recomputed after every pick + // eslint-disable-next-line react-hooks/exhaustive-deps + [listRecentsEpoch], + ) + const documentLists = useMemo(() => { + if (!doc) return undefined + const defs = new Map(doc.parsed.numbering) + for (const d of pendingNumbering.newDefs) { + const overlay = ( + editor?.storage.listNumbering as { defs?: Map } | undefined + )?.defs?.get(d.numId) + if (overlay) defs.set(d.numId, overlay) + } + return documentListPresets(defs.values()) + }, [doc, pendingNumbering.newDefs, editor]) + const applyDefinedList = useCallback( + (levels: CustomNumberingLevel[], kind: 'bullets' | 'numbers' | 'multi') => { + if (!editor) return + const numId = createCustomListDefImpl(numberingCtxRef.current, levels) + if (!numId) return + rememberListPreset(kind, levels) + setListRecentsEpoch((n) => n + 1) + const ilvl = editor.isActive('docListItem') + ? Number(editor.getAttributes('docListItem').ilvl) || 0 + : 0 + editor + .chain() + .focus() + .setNode('docListItem', { + kind: levels[0]?.numFmt === 'bullet' ? 'bullet' : 'ordered', + numId, + ilvl, + }) + .run() + setListDialog(null) + }, + [editor], + ) + const changeListLevel = useCallback( + (ilvl: number) => { + if (editor) changeListLevelImpl(editor, ilvl) + }, + [editor], + ) + const listTarget = listDialog ? currentListTargetImpl(numberingCtxRef.current) : null + + // typed "- " / "1. " lists get real numbering definitions, like the ribbon buttons + useEffect(() => { + if (!editor) return + const storage = editor.storage.autocorrect + storage.hooks = { + bulletNumId: () => allocateListNumId('bullet'), + numberedNumId: (numFmt, pattern, start) => { + const levels = numberPresetLevels(numFmt, pattern) + if (start > 1) levels[0] = { ...levels[0], start } + return createCustomListDef(levels) + }, + } + return () => { + storage.hooks = null + } + }, [editor, allocateListNumId, createCustomListDef]) + // ---- Inline fields: insertion and F9 update (cached results recomputed locally) ---- const fieldValue = useCallback( @@ -2158,6 +2894,11 @@ export function App() { return String(pageInfo.current) case 'FILENAME': return doc?.fileName ?? '' + case 'MERGEFIELD': { + // a merge field shows its name in guillemets until the merge fills it + const name = mergeFieldName(instr) + return name ? placeholderText(name) : '' + } default: return '' } @@ -2270,7 +3011,11 @@ export function App() { const numId = idOf[kind] if (numId) tr = tr.setNodeMarkup(item.pos, undefined, { ...item.attrs, numId }) } - if (tr.docChanged) editor.view.dispatch(tr) + if (tr.docChanged) { + const before = editor.state.doc + editor.view.dispatch(tr) + carryDocSeen(editor, before) + } } editor.on('update', fill) return () => { @@ -2319,7 +3064,11 @@ export function App() { (outPath?: string) => exportHtmlImpl(fileCtxRef.current, outPath), [], ) - const exportImages = useCallback(() => exportImagesImpl(fileCtxRef.current), []) + const exportMarkdown = useCallback( + (outPath?: string) => exportMarkdownImpl(fileCtxRef.current, outPath), + [], + ) + const exportImages = useCallback(() => exportImagesImpl(fileCtxRef.current), []) const printDoc = useCallback(() => printDocImpl(fileCtxRef.current), []) // for real-device verification: trigger export directly via CDP (same as __pageDebug) @@ -2328,6 +3077,9 @@ export function App() { ;(window as unknown as Record).__exportImages = exportImages ;(window as unknown as Record).__openPagePreview = () => setShowPagePreview(true) + ;(window as unknown as Record).__setRevisionDisplay = ( + mode: RevisionDisplayMode, + ) => setRevisionDisplay(mode) }, [exportPdf, exportImages]) // Headless export mode (--headless-export): this renderer lives in a hidden @@ -2349,11 +3101,11 @@ export function App() { const doc = fileCtxRef.current.doc return { opened: typeof doc?.filePath === 'string', failed: doc?.filePath === null } }, - target.format === 'html' ? exportHtml : exportPdf, + target.format === 'html' ? exportHtml : target.format === 'md' ? exportMarkdown : exportPdf, ) window.desktop.headlessExportDone(report) })() - }, [exportPdf, exportHtml]) + }, [exportPdf, exportHtml, exportMarkdown]) useEffect(() => { const pending = pendingMixedExportRef.current @@ -2394,7 +3146,12 @@ export function App() { setStatus(t('appExportPdfFailed', { error: 'pagination preview produced no pages' })) return } - void exportPdf(pending.outPath).then(pending.resolve, () => pending.resolve(false)) + setExportLocksPreview(true) + const ok = await exportPdf(pending.outPath).catch(() => false) + setExportLocksPreview(false) + // a Print the user started meanwhile owns the preview now + if (pending.closePreview && !showPrintDialogRef.current) setShowPagePreview(false) + pending.resolve(ok) }, 1200) return () => window.clearTimeout(timer) }, [showPagePreview, pendingExportTick, exportPdf]) @@ -2435,9 +3192,18 @@ export function App() { setCommentsDirty, setCommentComposing, setShowComments, + setCommentFocus, setInkAnnotations, setInksDirty, setCompareResult, + get revisionFilter() { + return revisionFilterRef.current + }, + showRevision: (target) => showRevision(target), + storyRevisions: () => storyRevisionsRef.current, + applyStoryRevisions: (targets, mode, all) => applyStoryRevisions(targets, mode, all), + currentStoryRevision: () => + storyRevisionsRef.current.find((r) => r.story.id === lastStoryRef.current) ?? null, } const insertNote = useCallback((kind: 'footnote' | 'endnote') => { @@ -2464,8 +3230,38 @@ export function App() { const cancelNewComment = useCallback(() => cancelNewCommentImpl(reviewCtxRef.current), []) const startNewComment = useCallback(() => startNewCommentImpl(reviewCtxRef.current), []) + /** + * Capture the range now, while the menu still knows what was selected: the + * dialog takes seconds to answer and a click elsewhere would otherwise move + * the span the reader asked to withhold. + */ + const startRedaction = useCallback(() => { + if (!editor) return + const { from, to } = editor.state.selection + if (from === to) return + setRedactTarget({ + from, + to, + seed: editor.state.doc.textBetween(from, to, ' ').trim().slice(0, 24), + }) + }, [editor]) + + /** + * Stop withholding whatever the selection carries. + * + * The command reads the selection itself and clears the mark across it, so + * the same selection the menu decided on is the one that gets cleaned: the + * menu closes before this runs, but closing a menu does not move a caret. + * No dialog, because there is nothing to ask — the words were never replaced, + * so there is no label to revise and nothing to confirm. + */ + const clearRedaction = useCallback(() => { + if (!editor) return + editor.commands.unsetRedaction() + }, [editor]) + const submitNewComment = useCallback( - (text: string) => submitNewCommentImpl(reviewCtxRef.current, text), + (text: string, image?: CommentImage) => submitNewCommentImpl(reviewCtxRef.current, text, image), [], ) const replyToComment = useCallback( @@ -2482,10 +3278,86 @@ export function App() { ) const deleteComment = useCallback((id: string) => deleteCommentImpl(reviewCtxRef.current, id), []) const handleRevision = useCallback( - (action: 'accept' | 'reject', all: boolean) => - handleRevisionImpl(reviewCtxRef.current, action, all), + (action: 'accept' | 'reject', scope: RevisionScope) => + handleRevisionImpl(reviewCtxRef.current, action, scope), + [], + ) + const handleRevisionRange = useCallback( + (action: 'accept' | 'reject', target: RevisionRange) => + handleRevisionRangeImpl(reviewCtxRef.current, action, target), [], ) + // Word: navigating onto a change hidden by the current view shows All + // Markup; the view switch must commit before the scroll can find the text + const showRevision = useCallback( + (target: RevisionRange) => { + if (!editor) return + const show = target.story + ? () => showStoryRevisionRef.current(target as StoryRevision) + : () => { + setActiveStoryId(null) + selectRevision(editor, target) + } + if (revisionHiddenIn(revisionDisplayRef.current, target)) { + setRevisionDisplay('all') + window.setTimeout(show, 0) + } else show() + }, + [editor], + ) + /** the change outside the body Next / Previous last opened: the pane highlights it, Accept Next takes it */ + const [activeStoryId, setActiveStoryIdState] = useState(null) + const lastStoryRef = useRef(null) + const setActiveStoryId = (id: string | null) => { + lastStoryRef.current = id + setActiveStoryIdState(id) + } + // Word opens the story the change lives in: the note's reference is selected and + // its entry flashed, the header / footer strip opens for editing, a textbox's + // anchor paragraph is selected (the box is drawn beside it) + const showStoryRevision = (target: StoryRevision) => { + if (!editor) return + const { story } = target + if (story.hf) { + const { section: si, kind } = story.hf + // the edge areas are the first section's first-page header and the last + // section's last-page footer; any other strip sits in a page gap + const last = Math.max(0, hfStateRef.current.sections.length - 1) + const edge = kind === 'header' ? si === 0 : si === last + const strip = edge + ? null + : document.querySelector( + `.page-gap-hf.page-hf-${kind}[data-hf-section="${si}"]`, + ) + if (strip) startGapHfEditRef.current(strip) + else if (edge) openHfEditorRef.current(kind) + setActiveStoryId(story.id) + return + } + if (target.from >= 0 && target.from <= editor.state.doc.content.size) { + const tr = editor.state.tr + tr.setSelection(TextSelection.near(tr.doc.resolve(target.from), 1)) + tr.scrollIntoView() + tr.setMeta(TRACK_IGNORE, true) + editor.view.dispatch(tr) + editor.view.focus() + } + // after the dispatch: the selection listener clears the cursor on body moves + setActiveStoryId(story.id) + if (story.noteId) { + window.setTimeout(() => { + const row = document.querySelector( + `.page-gap-note[data-note-id="${CSS.escape(story.noteId!)}"]`, + ) + if (!row) return + row.scrollIntoView({ block: 'nearest' }) + row.classList.add('rev-target') + window.setTimeout(() => row.classList.remove('rev-target'), 1500) + }, 50) + } + } + const showStoryRevisionRef = useRef(showStoryRevision) + showStoryRevisionRef.current = showStoryRevision const addInk = useCallback( (annotation: InkAnnotation) => addInkImpl(reviewCtxRef.current, annotation), [], @@ -2494,14 +3366,39 @@ export function App() { const clearInks = useCallback(() => clearInksImpl(reviewCtxRef.current), []) const compareWithFile = useCallback(() => compareWithFileImpl(reviewCtxRef.current), []) - const revisionCount = editor && doc ? revisionCountOfDoc(editor.state.doc) : 0 - + // the ribbon's revision badge walks every block: paced with the word count below + const [revisionRanges, setRevisionRanges] = useState([]) // open comment threads add the markup column right of the paper: width-fit // zoom must count it or the canvas overflows the pane - const markupExtra = useMemo( - () => (comments.some((c) => !c.parentId && !c.done) ? MARKUP_AREA_W : 0), - [comments], - ) + // (Simple Markup collapses threads to glyphs inside the page margin: no column) + // body revision balloons keep the column even while the Comments panel hosts the threads + const markupExtra = useMemo(() => { + if (revisionDisplay === 'simple') return 0 + const commentCards = commentsUi.cards && comments.some((c) => !c.parentId && !c.done) + const revKinds = balloonsActive ? balloonRevKinds(balloonMode) : null + const revBalloons = + !!revKinds && + revisionRanges.some((r) => { + const k = balloonKindOf(r.kind) + return k != null && revKinds.has(k) + }) + return (revBalloons ? tcOptions.balloonWidth : 0) + (commentCards ? COMMENT_AREA_W : 0) + }, [ + comments, + revisionRanges, + commentsUi.cards, + revisionDisplay, + balloonMode, + balloonsActive, + tcOptions.balloonWidth, + ]) + // inline comment tags ([initials N]) read the thread list through the decoration state + useEffect(() => { + setInlineComments(comments) + if (editor && balloonMode === 'inline') + editor.view.dispatch(editor.state.tr.setMeta('addToHistory', false)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [comments]) /** Uncapped width-fit ratio (%) from the scroller's measured size */ const rawFitWidth = useCallback(() => { @@ -2512,30 +3409,58 @@ export function App() { }, [section, markupExtra]) /** Last auto-fit: if current zoom still equals its value → "fit mode", re-fit on size changes */ - const lastFitRef = useRef<{ mode: 'width' | 'page'; value: number } | null>(null) + const lastFitRef = useRef<{ mode: ZoomFitMode; value: number } | null>(null) const zoomLiveRef = useRef(zoom) useEffect(() => { zoomLiveRef.current = zoom }, [zoom]) - /** Word's page-width / whole-page zoom: compute the actual zoom ratio from the current window size */ - const zoomFit = useCallback( - (mode: 'width' | 'page') => { - if (!section) return + /** Word's page-width / text-width / whole-page zoom: the zoom ratio for the current window size */ + const fitZoomFor = useCallback( + (mode: ZoomFitMode): number | null => { + if (!section) return null const scroller = document.querySelector('.editor-scroll') - if (!scroller) return + if (!scroller) return null const pad = 48 const wFit = ((scroller.clientWidth - pad) / (twipsToPx(section.pageWidth) + markupExtra)) * 100 const hFit = ((scroller.clientHeight - pad) / twipsToPx(section.pageHeight)) * 100 + const textW = twipsToPx(section.pageWidth - section.marginLeft - section.marginRight) + const tFit = ((scroller.clientWidth - pad) / (textW + markupExtra)) * 100 + if (mode === 'multi') { + // Word: a whole page tall and as many across as fit, never fewer than two + const slotW = twipsToPx(section.pageWidth) + markupExtra + const byHeight = clampDocsZoom(Math.floor(hFit)) + if (multipageColumns(scroller.clientWidth, slotW, byHeight) >= 2) return byHeight + return clampDocsZoom(Math.floor(multipageZoomForColumns(scroller.clientWidth, slotW, 2))) + } // whole page = the entire page visible, so it must fit both dimensions; // floor, not round: rounding up would push the page past the pane edge - const next = mode === 'width' ? wFit : Math.min(wFit, hFit) - const applied = Math.min(200, Math.max(50, Math.floor(next))) + const next = mode === 'width' ? wFit : mode === 'text' ? tFit : Math.min(wFit, hFit) + return clampDocsZoom(Math.floor(next)) + }, + [section, markupExtra], + ) + // Word: Multiple Pages turns the side-by-side arrangement on and it stays + // through later zooming (a manual zoom-out alone keeps the single column); + // One Page / Page Width / Text Width turn it off + const [multipageMode, setMultipageMode] = useState(false) + /** fit without touching the arrangement: the resize follow-up / overflow clamp below */ + const refitZoom = useCallback( + (mode: ZoomFitMode) => { + const applied = fitZoomFor(mode) + if (applied == null) return lastFitRef.current = { mode, value: applied } setZoom(applied) }, - [section, markupExtra], + [fitZoomFor], + ) + const zoomFit = useCallback( + (mode: ZoomFitMode) => { + setMultipageMode(mode === 'multi') + refitZoom(mode) + }, + [refitZoom], ) // On scroller size changes (window/AI-dock/nav-pane toggles): follow with a @@ -2546,22 +3471,27 @@ export function App() { useEffect(() => { const el = document.querySelector('.editor-scroll') if (!doc || !el) return + let lastWidth = -1 const ro = new ResizeObserver(() => { const raw = rawFitWidth() if (raw == null) return + const widthChanged = el.clientWidth !== lastWidth + lastWidth = el.clientWidth const lf = lastFitRef.current if (lf != null && Math.abs(zoomLiveRef.current - lf.value) <= 0.5) { - zoomFit(lf.mode) // fit mode: follow the container in the user's chosen fit + refitZoom(lf.mode) // fit mode: follow the container in the user's chosen fit return } // The overflow test uses the uncapped ratio: a manual zoom that still - // fits (or is smaller than fit) is the user's choice and must be kept - if (zoomLiveRef.current <= raw + 0.5) return - zoomFit('width') + // fits (or is smaller than fit) is the user's choice and must be kept. + // A height-only change (a classic horizontal scrollbar appearing for the + // zoomed-in page) must not clamp a zoom the user just chose. + if (!widthChanged || zoomLiveRef.current <= raw + 0.5) return + refitZoom('width') }) ro.observe(el) return () => ro.disconnect() - }, [doc, zoomFit, rawFitWidth]) + }, [doc, refitZoom, rawFitWidth]) // markup column appearing/disappearing changes the canvas width without a // pane resize: apply the same follow/clamp rules as the ResizeObserver above @@ -2570,9 +3500,9 @@ export function App() { const raw = rawFitWidth() if (raw == null) return const lf = lastFitRef.current - if (lf != null && Math.abs(zoomLiveRef.current - lf.value) <= 0.5) zoomFit(lf.mode) - else if (zoomLiveRef.current > raw + 0.5) zoomFit('width') - }, [doc, markupExtra, rawFitWidth, zoomFit]) + if (lf != null && Math.abs(zoomLiveRef.current - lf.value) <= 0.5) refitZoom(lf.mode) + else if (zoomLiveRef.current > raw + 0.5) refitZoom('width') + }, [doc, markupExtra, rawFitWidth, refitZoom]) // Read Mode opens at 1:1 — the same page size as the Page Preview — and the // user's zoom / fit mode is restored on exit (wheel zoom still works inside). @@ -2582,7 +3512,7 @@ export function App() { // lastFitRef first; zoomLiveRef is synced here for the same reason. const preReadZoomRef = useRef<{ zoom: number - fit: { mode: 'width' | 'page'; value: number } | null + fit: { mode: ZoomFitMode; value: number } | null } | null>(null) useLayoutEffect(() => { if (readMode) { @@ -2631,19 +3561,18 @@ export function App() { kind: NoteKind, ): { height: number; lineHeightPx: number; fontSizePt: number; fontFamily?: string } => { const contentW = twipsToPx(sec.pageWidth - sec.marginLeft - sec.marginRight) - const key = `${kind}|${fn?.id ?? ''}|${no}|${Math.round(contentW)}|${fn?.styleId ?? ''}|${fn?.text ?? ''}` + const key = `${kind}|${fn?.id ?? ''}|${no}|${Math.round(contentW)}|${fn?.styleId ?? ''}|${fn?.text ?? ''}|${revisionDisplay}` let v = cache.get(key) if (!v) { const style = noteStyleOf(fn) - const lineHeightPx = noteLineHeightPx(sec.docGrid, style) - const fontSizePt = style.sizeHalfPoints ? style.sizeHalfPoints / 2 : 10 - const fontFamily = style.fontFamily ? cssFontFamily(style.fontFamily) : undefined + const { lineHeightPx, fontSizePt, fontFamily } = noteRowMetrics(style, sec.docGrid) + const richParas = noteRunsInView(revisionDisplay, fn?.richParas) const height = measureNoteHeightDom( { no, text: fn?.text ?? '', - ...(fn?.richParas ? { richParas: fn.richParas } : {}), + ...(richParas ? { richParas } : {}), ...(fn?.noRefMark ? { noRefMark: true as const } : {}), }, kind, @@ -2652,14 +3581,7 @@ export function App() { fontSizePt, fontFamily, ) ?? - estimateFootnoteHeight( - fn?.text ?? '', - contentW, - sec.docGrid, - undefined, - style, - fn?.richParas, - ) + estimateFootnoteHeight(fn?.text ?? '', contentW, sec.docGrid, undefined, style, richParas) v = { height, lineHeightPx, fontSizePt, ...(fontFamily ? { fontFamily } : {}) } cache.set(key, v) } @@ -2668,14 +3590,31 @@ export function App() { // note-list deps only reset the cache (keys carry the note text, but // formatting-only edits keep it — a fresh map re-measures them) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [footnotes, endnotes, noteStyleOf]) + }, [footnotes, endnotes, noteStyleOf, revisionDisplay]) + + // the numbers the body shows right now: inserting or deleting a note renumbers + // its in-text marks in document order, and the note areas must say the same + const noteMarkNumbers = useMemo(() => { + const out = new Map() + editorRef.current?.state.doc.descendants((node) => { + if (node.type.name !== 'docNoteRef') return true + const key = `${node.attrs.kind}:${node.attrs.id}` + if (!out.has(key)) out.set(key, Number(node.attrs.num)) + return false + }) + return out + // the lists change whenever marks are added or removed + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [footnotes, endnotes, doc]) - // display number of a note: the engine's body-order numbering (numStart / eachSect - // applied); notes added in this session are not in the map and count by list position + // display number of a note: the mark in the body, else the engine's body-order + // numbering (numStart / eachSect applied), else the list position const noteNo = useCallback( (kind: 'footnote' | 'endnote', id: string, index: number): number => - doc?.parsed.noteNumbers?.[`${kind}:${id}`] ?? index + 1, - [doc], + noteMarkNumbers.get(`${kind}:${id}`) ?? + doc?.parsed.noteNumbers?.[`${kind}:${id}`] ?? + index + 1, + [noteMarkNumbers, doc], ) // page-bottom heights (px) reserved for footnote references inside a block, one per @@ -2719,140 +3658,77 @@ export function App() { // w:docGrid charSpace character grid: uniform per-character letter-spacing // delta (pt); mixed docs deliver it per block via sectionCharSpaceSpecs const charSpacePt = useMemo(() => docCharSpacePt(sections), [sections]) + // balanceSingleByteDoubleByteWidth: single-byte characters get half the delta + const balancedGrid = doc?.parsed.balanceDbcsSpacing === true + + // page color: Word lays out one extra blank Normal paragraph under section 1's header + const hfPhantom = useMemo( + () => (doc && pageColor ? hfPhantomSpec(doc.parsed) : null), + [doc, pageColor], + ) + // compatibilityMode < 15: header wrapTopAndBottom bands clamp to the page top (hfStripGeom) + const legacyBands = (doc?.parsed.compatibilityMode ?? 0) < 15 // single-section header/footer push-down: body top = max(marginTop, headerDist + header height) const singleHfPx = useMemo((): SectionHfHeights => { if (!section) return { headerPx: 0, footerPx: 0 } const contentW = twipsToPx(section.pageWidth - section.marginLeft - section.marginRight) - return { - headerPx: hfReservedHeightPx( - 'header', - header, - contentW, - doc?.parsed.headerImages, - hfHeaderGeom(section), - ), - footerPx: hfReservedHeightPx('footer', footer, contentW, doc?.parsed.footerImages), - // live titlePg: the first page draws the live first-page variant (pageHfOf), - // so its reserved heights track the live state, not the parsed parts - ...(titlePg - ? { - firstHeaderPx: hfReservedHeightPx( - 'header', - hfVariants.headerFirst, - contentW, - doc?.parsed.headerFirst?.images, - hfHeaderGeom(section), - ), - firstFooterPx: hfReservedHeightPx( - 'footer', - hfVariants.footerFirst, - contentW, - doc?.parsed.footerFirst?.images, - ), - } - : {}), - } + // live titlePg: the first page draws the live first-page variant (pageHfOf), + // so its reserved heights track the live state, not the parsed parts + return hfVariantHeightsPx( + (kind, variant) => hfLayoutResolved(kind, hfResolveAt(0, kind, variant), hfPhantom), + () => contentW, + hfStripGeom(section, legacyBands), + titlePg, + evenOddHf, + ) // hfMeasureEpoch: re-measure once the doc-scoped styles have committed // eslint-disable-next-line react-hooks/exhaustive-deps - }, [section, header, footer, titlePg, hfVariants, doc, hfMeasureEpoch]) + }, [section, hfResolveAt, hfPhantom, titlePg, evenOddHf, legacyBands, hfMeasureEpoch]) const effTopSingle = section ? effectiveTopPx(section, singleHfPx.headerPx) : 0 const effBottomSingle = section ? effectiveBottomPx(section, singleHfPx.footerPx) : 0 const canvasSection = sections[0]?.settings ?? section const canvasBox = canvasSection ? sectionPageBox(canvasSection) : null - const canvasTop = section ? canvasContentTopPx(sections, section, singleHfPx.headerPx) : 0 + // the canvas's first page draws the variant pageHfOf picks for it (titlePg + // first page, else the even variant when its number is even): its body + // starts under that strip, as the preview slicer already assumes + const firstPageHeaderPx = useMemo( + (): number => + hfVariantReservedPx( + singleHfPx, + hfVariantOf(titlePg, true, evenOddHf, sections[0]?.pageNumberStart ?? 1), + ).headerPx, + [singleHfPx, titlePg, evenOddHf, sections], + ) + const canvasTop = section ? canvasContentTopPx(sections, section, firstPageHeaderPx) : 0 + // trailing paper padding belongs to the last page, which draws the default footer const canvasBottom = canvasSection ? effectiveBottomPx(canvasSection, singleHfPx.footerPx) : 0 - // titlePg: the document's first page renders the first-page header/footer - // variant (pageHfOf), so single-section slicing gives it its own capacity - const singleFirstContentH = useMemo(() => { - if (!section || singleHfPx.firstHeaderPx === undefined) return undefined - return ( - twipsToPx(section.pageHeight) - - effectiveTopPx(section, singleHfPx.firstHeaderPx) - - effectiveBottomPx(section, singleHfPx.firstFooterPx ?? 0) - ) - }, [section, singleHfPx]) + const singleVariantGeom = useMemo( + () => ({ + ...(section ? singleVariantGeomOf(section, singleHfPx) : {}), + ...(sections[0]?.pageNumberStart !== undefined + ? { pageNumberStart: sections[0].pageNumberStart } + : {}), + }), + [section, singleHfPx, sections], + ) - // multi-section: estimated heights of each section's default-variant header/footer (capacity per section, variant differences ignored) + // multi-section: estimated heights of each section's header/footer variants (capacity per section) const hfHeightsOf = useCallback( (secs: SectionInfo[]): SectionHfHeights[] => { - const refs = effectiveHfRefs(secs) - return secs.map((s, i) => { - const set = s.settings - const contentW = twipsToPx(set.pageWidth - set.marginLeft - set.marginRight) - const pick = (kind: 'header' | 'footer'): HeaderFooter | null => { - if (i === secs.length - 1) return kind === 'header' ? header : footer - const edited = sectionHfEdits[`${s.lastBlockIndex}:${kind}`] - if (edited) return edited - const rId = refs[i]?.[kind]?.default - return rId ? hfFromPart(doc?.parsed.hfParts?.[rId]) : null - } - const imagesOf = (kind: 'header' | 'footer') => { - const rId = refs[i]?.[kind]?.default - const fromPart = rId ? doc?.parsed.hfParts?.[rId]?.images : undefined - if (fromPart?.length) return fromPart - if (i === secs.length - 1) { - return ( - (kind === 'header' ? doc?.parsed.headerImages : doc?.parsed.footerImages) ?? undefined - ) - } - return undefined - } - // titlePg first-page variant: the section's first page renders these - // strips (pageHfOf/hfFor), so its slice capacity must match. A lone - // section draws the LIVE first-page state (pageHfOf's secList.length<=1 - // branch), so its capacity tracks the live variant/toggle too. - const liveFirst = secs.length === 1 - const firstOn = liveFirst ? titlePg : s.titlePg - const firstPart = (kind: 'header' | 'footer'): HeaderFooter | null => { - if (liveFirst) return kind === 'header' ? hfVariants.headerFirst : hfVariants.footerFirst - const rId = refs[i]?.[kind]?.first - return rId ? hfFromPart(doc?.parsed.hfParts?.[rId]) : null - } - const firstImagesOf = (kind: 'header' | 'footer') => { - if (liveFirst) { - return ( - (kind === 'header' - ? doc?.parsed.headerFirst?.images - : doc?.parsed.footerFirst?.images) ?? undefined - ) - } - const rId = refs[i]?.[kind]?.first - return rId ? doc?.parsed.hfParts?.[rId]?.images : undefined - } - return { - headerPx: hfReservedHeightPx( - 'header', - pick('header'), - contentW, - imagesOf('header'), - hfHeaderGeom(set), - ), - footerPx: hfReservedHeightPx('footer', pick('footer'), contentW, imagesOf('footer')), - ...(firstOn - ? { - firstHeaderPx: hfReservedHeightPx( - 'header', - firstPart('header'), - contentW, - firstImagesOf('header'), - hfHeaderGeom(set), - ), - firstFooterPx: hfReservedHeightPx( - 'footer', - firstPart('footer'), - contentW, - firstImagesOf('footer'), - ), - } - : {}), - } - }) + // pending section breaks make `secs` differ from the saved list: resolve against them + const liveState: HfSectionState = { ...hfState, sections: secs } + return sectionHfHeights( + secs, + (i, kind, variant) => + hfLayoutResolved(kind, resolveHf(liveState, i, kind, variant), hfPhantom), + { titlePg, evenOddHf, legacyBands }, + ) }, // hfMeasureEpoch: re-measure once the doc-scoped styles have committed // eslint-disable-next-line react-hooks/exhaustive-deps - [doc, header, footer, sectionHfEdits, titlePg, hfVariants, hfMeasureEpoch], + [hfState, hfPhantom, titlePg, evenOddHf, legacyBands, hfMeasureEpoch], ) // paragraphs without w:pStyle take the default paragraph style's constraints (widowControl off in Normal) @@ -2887,34 +3763,51 @@ export function App() { [...xml.matchAll(/ styleKeepNext(m[1])) const modern = (doc?.parsed.compatibilityMode ?? 0) >= 15 if (!flagged && fnExtra === 0 && !modern) return undefined + const cellWidowOff = + modern && + tableWidowOff( + xml, + defaultParaStyle?.display?.widowControl ?? doc?.parsed.docDefaults?.widowControl, + (id) => doc?.parsed.styles.get(id)?.display?.widowControl, + ) return { ...(flagged ? { tableRowFlags: tableRowFlags(xml, styleKeepNext) } : {}), ...(modern ? { modernTableHeaders: true } : {}), + ...(cellWidowOff ? { cellWidowOff: true } : {}), ...(fnExtra > 0 ? { footnoteExtraPx: fnExtra, footnoteBands: fnBands } : {}), } } - const styleDisplay = (b.styleId ? doc?.parsed.styles.get(b.styleId) : defaultParaStyle) - ?.display - const keepNext = b.format?.keepNext ?? styleDisplay?.keepNext - const keepLines = b.format?.keepLines ?? styleDisplay?.keepLines - const breakBefore = b.format?.pageBreakBefore ?? styleDisplay?.pageBreakBefore - const widowOff = (b.format?.widowControl ?? styleDisplay?.widowControl) === false - const noLineNo = b.format?.suppressLineNumbers ?? styleDisplay?.suppressLineNumbers + const styleOf = (id: string | undefined) => + (id ? doc?.parsed.styles.get(id) : defaultParaStyle)?.display + const flags = b.format + ? paraPaginationMeta(b.format) + : imageParaPaginationMeta(b.originalXml, styleOf, doc?.parsed.docDefaults) const fnBands = footnoteBandsOf(b) const fnExtra = fnBands.reduce((s, band) => s + band.heightPx, 0) - if (!keepNext && !keepLines && !breakBefore && !widowOff && !noLineNo && fnExtra === 0) - return undefined + const modern = (doc?.parsed.compatibilityMode ?? 0) >= 15 + if (!flags && fnExtra === 0 && !modern) return undefined return { - ...(keepNext ? { keepNext: true } : {}), - ...(keepLines ? { keepLines: true } : {}), - ...(noLineNo ? { suppressLineNumbers: true } : {}), - ...(breakBefore ? { breakBefore: true } : {}), - ...(widowOff ? { widowControl: false as const } : {}), + ...flags, + ...(modern ? { modernAnchorFloats: true } : {}), ...(fnExtra > 0 ? { footnoteExtraPx: fnExtra, footnoteBands: fnBands } : {}), } }, [doc, blockByDocxIndex, defaultParaStyle, footnoteBandsOf], ) + // shadow BlockPlan inputs (I9): the model-side twin of the measureBlocks scrape + const planProps = useMemo( + () => (doc ? planPropsOf(doc.parsed, sections, revisionDisplay === 'original') : null), + [doc, sections, revisionDisplay], + ) + // the BlockPlan of the current editor state keyed by rendered element; built + // per measure so a transaction between passes cannot leave it stale + const blockPlans = useCallback( + (): PlanOf | undefined => + editor && planProps + ? planByElement(editor.state.doc, editor.view, planProps, floatShiftsOf(editor.state)) + : undefined, + [editor, planProps], + ) // per-page footnote collection: the page of the referencing block → that page's footnote entries (number/text/estimated height) const pageFootnotesOf = useCallback( @@ -2930,7 +3823,7 @@ export function App() { .filter((r) => r.noteRef?.kind === 'footnote') .map((r) => r.noteRef!.id) if (ids.length === 0) continue - const blockPage = pageAt(slices, b.top + 0.5) - 1 + const blockPage = pageOfY(slices, b.top) const sec = sections.find((s) => b.docxIndex! <= s.lastBlockIndex)?.settings ?? section for (let ri = 0; ri < ids.length; ri++) { const id = ids[ri] @@ -2941,14 +3834,15 @@ export function App() { // resolved into noteBands (run order) by applyBlockMeta const band = b.noteBands?.length === ids.length ? b.noteBands[ri] : undefined const page = band - ? pageAt(slices, b.top + (b.spaceBeforePx ?? 0) + band.offset + 0.5) - 1 + ? pageOfY(slices, b.top + (b.spaceBeforePx ?? 0) + band.offset) : blockPage const info = noteRenderInfoOf(fn, noOf.get(id) ?? 0, sec, 'footnote') + const richParas = noteRunsInView(revisionDisplay, fn.richParas) out[page]?.push({ no: noOf.get(id) ?? 0, id, text: fn.text, - ...(fn.richParas ? { richParas: fn.richParas } : {}), + ...(richParas ? { richParas } : {}), ...(fn.noRefMark ? { noRefMark: true as const } : {}), height: info.height, lineHeightPx: info.lineHeightPx, @@ -2959,7 +3853,16 @@ export function App() { } return out }, - [doc, blockByDocxIndex, footnotes, sections, section, noteRenderInfoOf, noteNo], + [ + doc, + blockByDocxIndex, + footnotes, + sections, + section, + noteRenderInfoOf, + noteNo, + revisionDisplay, + ], ) // endnote-area entries (placed together at the document end, shared by pagination preview and page slicing): height measured with the final section's content width @@ -2969,11 +3872,12 @@ export function App() { return endnotes.map((n, i) => { const no = noteNo('endnote', n.id, i) const info = noteRenderInfoOf(n, no, sec, 'endnote') + const richParas = noteRunsInView(revisionDisplay, n.richParas) return { no, id: n.id, text: n.text, - ...(n.richParas ? { richParas: n.richParas } : {}), + ...(richParas ? { richParas } : {}), ...(n.noRefMark ? { noRefMark: true as const } : {}), height: info.height, lineHeightPx: info.lineHeightPx, @@ -2981,7 +3885,7 @@ export function App() { ...(info.fontFamily ? { fontFamily: info.fontFamily } : {}), } }) - }, [endnotes, sections, section, noteRenderInfoOf, noteNo]) + }, [endnotes, sections, section, noteRenderInfoOf, noteNo, revisionDisplay]) // canvas column mode: // - 'uniform': every section shares one equal-width multi-column spec (and is LTR) — @@ -3037,7 +3941,11 @@ export function App() { // column flow (synchronous layout round-trip, no visible flicker) const measureSingleFlow = useCallback( function run(pm: HTMLElement, fn: () => T): T { - if ((colMode === 'none' && !hasVAlign && !hasVertical) || viewMode !== 'print') return fn() + if ( + (colMode === 'none' && !hasVAlign && !hasVertical && !mirrorMargins) || + viewMode !== 'print' + ) + return fn() pm.classList.add('measuring-columns') try { return fn() @@ -3045,7 +3953,7 @@ export function App() { pm.classList.remove('measuring-columns') } }, - [colMode, hasVAlign, hasVertical, viewMode], + [colMode, hasVAlign, hasVertical, mirrorMargins, viewMode], ) // column-flow geometry gate: when the canvas column layout is inactive, measure as full-width single flow; the geometry must drop cols to match @@ -3058,88 +3966,43 @@ export function App() { [colMode, viewMode], ) - // real TOC page-number backfill: compute each heading's (docHeading) page from the current real page slicing. - // Returns page numbers matching docHeadings in document order 1:1; returns null when not computable (page numbers left blank). + // real TOC page-number backfill: each heading's (docHeading) page from the + // current pass's PageCut list. Returns page numbers matching docHeadings in + // document order 1:1; null when not computable (page numbers left blank). const headingPages = useCallback((): number[] | null => { if (!editor || !section) return null - const pm = document.querySelector('.editor-scroll .ProseMirror') as HTMLElement | null - if (!pm) return null - const factor = zoom / 100 - const { mBlocks, slices, secs } = measureSingleFlow(pm, () => { - const origin = pm.getBoundingClientRect().top + canvasTop * factor - const { blocks, totalHeight, sectBreaks } = measureBlocks(pm, origin, factor) - const live = liveSections(sections, blocks, sectBreaks, delSectBreaks) - let s: PageSlice[] - // same row-split mode as the canvas pass, or TOC page numbers drift - const splitOut: SliceOutputs = { rowSplits: [] } - if (live.length > 0) { - assignSections(blocks, live) - s = sliceWithLineSplit( - blocks, - colGeomsFor(sectionGeoms(live, hfHeightsOf(live))), - totalHeight, - factor, - blockMetaOf, - splitOut, - ) - } else { - const contentH = twipsToPx(section.pageHeight) - effTopSingle - effBottomSingle - s = sliceWithLineSplit( - blocks, - [ - { - contentHeight: contentH, - forceBreak: false, - ...(singleFirstContentH !== undefined - ? { firstContentHeight: singleFirstContentH } - : {}), - }, - ], - totalHeight, - factor, - blockMetaOf, - splitOut, - ) - } - return { mBlocks: blocks, slices: s, secs: live } - }) - // 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, editor.storage.listNumbering?.styles)) { - const dom = editor.view.nodeDOM(h.pos) as HTMLElement | null - const top = dom ? byEl.get(dom) : undefined - const idx = top === undefined ? 1 : pageAt(slices, top + 1) - pages.push(nums[Math.min(Math.max(idx, 1), nums.length) - 1] ?? idx) - } + const layout = layoutNow() + if (!layout || layout.cuts.length === 0) return null + const { pages, misses } = headingPagesOf( + layout.blocks, + layout.cuts, + collectHeadings(editor.state.doc, editor.storage.listNumbering?.styles).map( + (h) => editor.view.nodeDOM(h.pos) as HTMLElement | null, + ), + ) + const dbg = (window as unknown as { __pageDebug?: Record }).__pageDebug + if (dbg) dbg.headingPageMisses = ((dbg.headingPageMisses as number) ?? 0) + misses return pages - }, [ - editor, - section, - sections, - delSectBreaks, - zoom, - blockMetaOf, - canvasTop, - effTopSingle, - effBottomSingle, - singleFirstContentH, - hfHeightsOf, - measureSingleFlow, - colGeomsFor, - ]) + }, [editor, section, layoutNow]) // status-bar page number: real page slicing (same algorithm as the pagination preview). Edits remeasure with debounce; scrolling only relocates useEffect(() => { if (!doc || !section) { setPageInfo({ current: 1, total: 1 }) setLastPageNo(null) + setEdgeSectionPages(null) + pageLayoutRef.current = null return } const scroller = document.querySelector('.editor-scroll') if (!scroller) return - const factor = zoom / 100 + // Blink lays text out at the zoomed font size and snaps line boxes to device + // pixels, so block heights read under a 10% (or 88%) canvas zoom differ from + // the 100% layout by up to a pixel per line — enough to move page breaks and + // to park a paragraph in the wrong column of an unequal-width section + // (hermesoffice#1970). Passes measure the canvas at zoom 1 and restore the + // view zoom before the viewport-dependent reads (locate). + let factor = zoom / 100 const mTopPx = canvasTop // shared paper = the widest section's page; narrower pages are centered on it const paperWPx = paperWidthPx(sections, sections[0]?.settings ?? section) @@ -3148,19 +4011,26 @@ export function App() { const contentH = twipsToPx(section.pageHeight) - effTopSingle - effBottomSingle let slices: PageSlice[] = [] let timer: number | null = null - let suppressSig = '' + let rowFillSig = '' // passes a pass schedules for itself (widths / suppression applied late); // a document whose column widths never settle must not paginate forever - let followUps = 0 + const chain = new FollowUpChain() let selfScheduled = false + // set by the cleanup: document.fonts.ready and other promise callbacks of + // this run can still fire after the effect was re-created (zoom change) + // and would schedule a pass of this stale closure next to the live one + let disposed = false let retrigger: string[] = [] - // several reasons in one pass debounce into one follow-up: count passes, not reasons - const followUp = (why: string) => { + // several reasons in one pass debounce into one follow-up; decoration-only + // re-decisions (justify shrink, float flow) arrive after a pass and re-run + // it from the changed block, through the same chain + const followUpAt = (why: string, dirtyIndex: number | null) => { retrigger.push(why) - if (followUps >= MAX_FOLLOW_UP_PASSES) return + if (chain.request(why, dirtyIndex) !== 'run') return selfScheduled = true - onUpdate() + onUpdate(null, dirtyIndex, true) } + const followUp = (why: string) => followUpAt(why, null) let secWidthSig = '' let charSpaceSig = '' const colWidthPass = newWidthPassState() @@ -3171,7 +4041,14 @@ export function App() { const pmRect = pm.getBoundingClientRect() const scrollRect = scroller.getBoundingClientRect() const origin = pmRect.top + mTopPx * factor - const midScreen = Math.min(scrollRect.top + scrollRect.height / 2, pmRect.bottom) + // Multiple Pages: the active page is the one under the live window, probe its + // first content line instead of the viewport middle + const mpIdx = multipageActiveRef.current + const mpFrame = mpIdx == null ? null : mpFramesRef.current[mpIdx] + const wrapTop = pm.closest('.page-wrap')?.getBoundingClientRect().top ?? pmRect.top + const midScreen = mpFrame + ? wrapTop + (mpFrame.top + mTopPx + 1) * factor + : 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 and repeated-header clone rows, which carry // page-repeat-header but not page-gap) @@ -3181,17 +4058,40 @@ export function App() { // only the former maps the midpoint onto the page shown there. Its // repeated-header clones are float content (the carry covers only the // gap bands; the continuation's page starts at the gap's bottom) - let gapAbove = 0 - for (const gap of pm.querySelectorAll( - '.page-gap:not(.page-gap-carry), .page-repeat-header', - )) { - if (gap.classList.contains('page-repeat-header') && insideFloatTable(gap)) continue - const r = gap.getBoundingClientRect() - if (r.top < midScreen) gapAbove += Math.min(r.height, midScreen - r.top) + const fromDom = () => { + let gapAbove = 0 + // mixed-column pages pull their gap (and everything below) up over the + // vacated stacked space: add that back to land in the gapless coordinates + let pullUpAbove = 0 + for (const gap of pm.querySelectorAll( + `${PAGE_TURN_SEL}, .page-repeat-header`, + )) { + if (gap.classList.contains('page-repeat-header') && insideFloatTable(gap)) continue + const r = gap.getBoundingClientRect() + if (r.top < midScreen) { + gapAbove += Math.min(r.height, midScreen - r.top) + pullUpAbove += Math.max(0, -parseFloat(gap.style.marginTop) || 0) + } + } + return pageAt(slices, (midScreen - origin - gapAbove) / factor + pullUpAbove) + } + // the page whose gap box starts at or above the midpoint (inside a gap band + // the page below it is current, where the DOM sum lands too) + const fromCuts = () => { + let page = 1 + for (const e of pagePaperEdges(pageCuts, mTopPx)) { + if (pmRect.top + e.gapTop * factor > midScreen) break + page = e.page + 1 + } + return page } - const midY = (midScreen - origin - gapAbove) / factor + // without gap widgets (web / read mode) the pages follow the flow Y alone + const shadowPage = pageReaderShadow.enabled ? fromDom() : null + const page = pageCuts.some((c) => c.gap) ? fromCuts() : fromDom() + if (shadowPage !== null && shadowPage !== page) + pageReaderShadow.report('locate', shadowPage, page) // visible-page numbering so the status bar and F9 NUMPAGES agree with the gap widgets - const current = visiblePageCount(slices, pageAt(slices, midY)) + const current = visiblePageCount(slices, page) const total = visiblePageCount(slices) setPageInfo((prev) => prev.current === current && prev.total === total ? prev : { current, total }, @@ -3199,11 +4099,203 @@ export function App() { } let lastPassAt = 0 let lastPassMs = 0 + let lastBlocks: BlockBox[] = [] + let lastPre: PageSlice[] = [] + let lastOut: SliceOutputs | null = null + let lastSecSig = '' + /** top-level child count at the last pass: a streaming pass whose only + * trigger is the appended tail (dirty ≥ this) may carry the prefix's + * line/row samples; any user edit below the frontier (dirty < this) + * disables the carry — a same-height edit would otherwise keep stale + * line boxes that the height gate cannot see */ + let lastPassChildCount = 0 + let carriedSamples = 0 + /** first top-level index a transaction touched since the last pass; null once a trigger needs the whole document */ + let dirtyFrom: number | null = null + let resumePasses = 0 + let resumeReject = '' + let resumePage = -1 + let resumeDiag: Record = {} + /** top-level indices edited since the last pass; null once any other trigger disqualified the fast path */ + let fastEdits: Set | null = new Set() + let fastPasses = 0 + let fastReject = '' + let gapBoundaryMismatch = 0 + // I11 shadow: PageCut facts vs what the gap builder re-derives from the DOM + let pageCutMismatch = 0 + const pageCutDiag: string[] = [] + let pageCuts: PageCut[] = [] + let planDiff: PlanDiff[] = [] + const sameBox = (a: BlockBox, b: BlockBox) => + a.el === b.el && + Math.abs(a.top - b.top) < 0.01 && + Math.abs(a.height - b.height) < 0.01 && + a.section === b.section && + a.docxIndex === b.docxIndex && + !!a.floated === !!b.floated && + !!a.emptyPara === !!b.emptyPara && + (a.spaceBeforePx ?? 0) === (b.spaceBeforePx ?? 0) + // pages above the first changed block paginate as before: slice again from + // the page before the one holding that block (keep-with-next and widow + // rules reach back at most one page), provided that page opens on a block top + const resumeFor = ( + blocks: BlockBox[], + secSig: string, + dirty: number | null, + ): PassResume | null => { + const reject = (why: string) => { + resumeReject = why + return null + } + if (dirty === null) return reject('trigger') + if (!editor || !lastOut || lastPre.length < 2 || lastBlocks.length === 0) + return reject('state') + if (secSig !== lastSecSig) return reject('sections') + if (viewMode === 'print' && hasVertical) return reject('layout') + const n = Math.min(blocks.length, lastBlocks.length) + let d = 0 + while (d < n && sameBox(blocks[d], lastBlocks[d])) d++ + const geomDiff = d + const { doc: pmDoc } = editor.state + let trBlock = -1 + if (dirty < pmDoc.childCount) { + let pos = 0 + for (let i = 0; i < dirty; i++) pos += pmDoc.child(i).nodeSize + const el = editor.view.nodeDOM(pos) + const bi = blocks.findIndex((b) => b.el === el) + if (bi < 0) return reject('block') + trBlock = bi + d = Math.min(d, bi) + } + const a = blocks[geomDiff] + const b = lastBlocks[geomDiff] + resumeDiag = { + dirty, + trBlock, + geomDiff, + geomField: + a && b + ? ( + [ + 'el', + 'top', + 'height', + 'section', + 'docxIndex', + 'floated', + 'emptyPara', + 'spaceBeforePx', + ] as const + ).find((f) => + f === 'top' || f === 'height' + ? Math.abs((a[f] ?? 0) - (b[f] ?? 0)) >= 0.01 + : (a[f] ?? null) !== (b[f] ?? null), + ) + : 'length', + counts: [blocks.length, lastBlocks.length], + } + if (d <= 0) return reject('top') + if (d >= blocks.length) d = blocks.length - 1 + const dirtyPage = pageOfY(lastPre, blocks[d].top) + for (let p = dirtyPage - 1; p > 0; p--) { + if (!isResumePage(lastPre, p)) continue + const page = lastPre[p] + let k = -1 + for (let i = 0; i < d; i++) { + if (Math.abs(blocks[i].top - page.start) < 0.5) { + k = i + break + } + } + if (k < 0 || blocks[k].floated) continue + // line samples and row boxes of the blocks above are geometry of the + // unchanged pages: carry them over instead of sampling the DOM again + for (let i = 0; i < k; i++) { + const prev = lastBlocks[i] + if (prev.lineBoxes) blocks[i].lineBoxes = prev.lineBoxes + if (prev.lineLeadPx !== undefined) blocks[i].lineLeadPx = prev.lineLeadPx + if (prev.tableRows) blocks[i].tableRows = prev.tableRows + if (prev.colWraps) blocks[i].colWraps = prev.colWraps + if (prev.oversizeLineH !== undefined) blocks[i].oversizeLineH = prev.oversizeLineH + } + resumePage = p + return { prevSlices: lastPre, page: p, block: k, prevOut: lastOut } + } + return reject('page') + } + // a keystroke that leaves its paragraph the same height moves no page + // boundary: keep the previous slicing instead of re-measuring the document + const tryFastPass = (edited: Set): boolean => { + const reject = (why: string) => { + fastReject = why + return false + } + if (!editor || isPhasedContentPending() || lastBlocks.length === 0) return reject('state') + // column/vAlign placement only translates blocks (heights compare as + // measured); vertical-text blocks are shown as writing-mode boxes + if (viewMode === 'print' && hasVertical) return reject('layout') + const { doc: pmDoc } = editor.state + const items: FastPassBlock[] = [] + let pos = 0 + let i = 0 + for (const idx of [...edited].sort((a, b) => a - b)) { + if (idx >= pmDoc.childCount) return reject('index') + for (; i < idx; i++) pos += pmDoc.child(i).nodeSize + const el = editor.view.nodeDOM(pos) as HTMLElement | null + const prev = el ? lastBlocks.find((b) => b.el === el) : undefined + if (!el || !prev) return reject('block') + if ( + el.classList.contains('doc-para-join-head') || + el.classList.contains('doc-para-join-tail') + ) + return reject('join') + items.push({ el, prev }) + } + return blocksKeepSlicing(items, slices, factor) || reject('geometry') + } const remeasure = () => { + if (disposed) return timer = null lastPassAt = performance.now() + const edited = fastEdits + fastEdits = new Set() + const dirty = dirtyFrom + dirtyFrom = Infinity try { - remeasurePass() + const zoomEl = scroller.querySelector('.doc-zoom') + // the canvas zoom as applied, not this closure's: a pass reaching the + // DOM through a late async callback must never read a zoomed canvas at + // factor 1 (split-row fills then land in zoomed px and rows overlap) + const liveZoom = canvasZoomOf(zoomEl, zoom / 100) + const swapZoom = zoomEl !== null && Math.abs(liveZoom - 1) > 0.0001 + const scrollTop = scroller.scrollTop + const scrollLeft = scroller.scrollLeft + if (swapZoom) { + zoomEl.style.zoom = '1' + factor = 1 + } + let fast = false + try { + fast = Boolean(edited && edited.size > 0 && tryFastPass(edited)) + if (!fast) { + if (!edited) fastReject = 'trigger' + else if (edited.size === 0) fastReject = 'none' + remeasurePass(dirty) + } + } finally { + if (swapZoom) { + zoomEl.style.zoom = String(liveZoom) + factor = liveZoom + scroller.scrollTop = scrollTop + scroller.scrollLeft = scrollLeft + } + } + if (fast) { + fastPasses++ + const dbg = (window as unknown as Record>).__pageDebug + if (dbg) dbg.fastPasses = fastPasses + } + locate() } finally { lastPassMs = performance.now() - lastPassAt // a pass that threw between the add and the pre-setPageGaps remove @@ -3211,18 +4303,75 @@ export function App() { pmEl()?.classList.remove('measuring-natural') } } - const remeasurePass = () => { + const remeasurePass = (dirty: number | null) => { const pm = pmEl() if (!pm) return - followUps = selfScheduled ? followUps + 1 : 0 + if (selfScheduled) chain.beginPass() + else chain.reset() + layoutHealth.beginPass(!selfScheduled) selfScheduled = false retrigger = [] const tStart = performance.now() let tMeasure = 0 let tSlice = 0 + let sliceIters = 0 + let sliceSplit = '' // consecutive anchor-paragraph runs collapse onto their band union before // measurement (layout-affecting, idempotent) - syncAnchorBands(pm, factor) + syncAnchorBands(pm, factor, (doc?.parsed.compatibilityMode ?? 0) >= 15) + // later blocks were read before this pass's writes landed: one more + // pass settles the chain (FollowUpChain refuses a repeating cause) + const planOf = blockPlans() + const obstacleShadow = newObstacleShadow() + pageReaderShadow.enabled = + import.meta.env.DEV || + (window as unknown as { __pageReaderShadow?: boolean }).__pageReaderShadow === true + // the previous pass's block leads: where its gap widgets stand in the DOM + const prevLayout = pageLayoutRef.current + const pageLeads = prevLayout + ? new Map( + prevLayout.cuts.flatMap((c, i) => { + const el = + c.gap?.kind === 'block' && c.lead.kind === 'block' + ? prevLayout.blocks[c.lead.block]?.el + : undefined + return el ? [[el, i] as const] : [] + }), + ) + : undefined + // header drawings reaching into the body of each page of the previous + // pass (its cuts name the variant and section a page shows) + const headerObstacleCache = new Map>() + const headerObstaclesOf = (pageIdx: number) => { + const hit = headerObstacleCache.get(pageIdx) + if (hit) return hit + const cut = prevLayout?.cuts[pageIdx] + const prevSecs = lastSecListRef.current + const si = prevSecs ? Math.min(cut?.section ?? 0, prevSecs.length - 1) : 0 + const set = prevSecs ? prevSecs[si]?.settings : section + let out: ReturnType = [] + if (cut && set) { + const end = prevSecs?.[si]?.lastBlockIndex + const hsi = end === undefined ? -1 : hfSections.findIndex((s) => s.lastBlockIndex === end) + const images = hfResolveAt(hsi >= 0 ? hsi : si, 'header', cut.hf.variant).images + out = hfBodyObstacles( + images, + hfStripGeom(set, legacyBands), + effectiveTopPx(set, cut.hf.headerPx), + ) + } + headerObstacleCache.set(pageIdx, out) + return out + } + const obstaclesChanged = syncWrapObstacles( + pm, + factor, + editor.view, + planOf, + obstacleShadow, + pageLeads, + headerObstaclesOf, + ) // natural-flow measuring state: in-paragraph gap widgets force a line break // at the PREVIOUS pass's cut, so measuring with them in layout re-confirms // that cut even after decoration-only reflows (justify space-shrink) moved @@ -3232,20 +4381,41 @@ export function App() { // setPageGaps so widget building and later display-state reads (strip // alignment, cut overlays, locate) run against the real layout. pm.classList.add('measuring-natural') + // joined paragraphs align against the natural flow (gap widgets hidden) + alignJoinedParagraphs(pm, factor, editor.view) // 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, floats, sectBreaks } = measureBlocks(pm, origin, factor) + const { blocks, totalHeight, floats, sectBreaks } = measureBlocks( + pm, + origin, + factor, + planOf, + true, + floatCarriesOf(editor.state), + ) if (hasVertical) for (const b of blocks) if (b.el && !b.floated) b.inlineExtraPx = blockInlineExtraPx(b.el) + planDiff = planOf + ? [ + ...shadowPlanDiff(planOf, blocks, blockMetaOf), + ...obstaclePlanDiffs(obstacleShadow.diffs, blocks, planOf), + ] + : [] + carriedSamples += carryStreamedSamples(blocks, lastBlocks, { + pending: isPhasedContentPending(), + dirty, + lastPassChildCount, + }) 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, sectBreaks, delSectBreaks) : null + lastSecListRef.current = secList if (secList) assignSections(blocks, secList) // the endnote area takes part in page slicing (placed together at the document end; overflows continue on later pages) const withEndnotes = appendEndnotesBlock( @@ -3257,15 +4427,27 @@ export function App() { // floating boxes below the flow end still need pages to land on; boxes // reaching only into the last page's bottom margin stay there (Word // draws anchored objects over the margin instead of opening a page) + const hfHs = secList ? hfHeightsOf(secList) : null + const geoms: SectionGeom[] = secList + ? colGeomsFor(sectionGeoms(secList, hfHs!, evenOddHf)) + : [{ contentHeight: contentH, forceBreak: false, ...singleVariantGeom }] const lastSec = secList?.[secList.length - 1]?.settings ?? section + const spillOverhangPx = + geoms[geoms.length - 1]?.marginBottomPx ?? (lastSec ? twipsToPx(lastSec.marginBottom) : 0) const flowWithFloats = appendFloatSpillBlock( blocks, withEndnotes?.totalHeight ?? totalHeight, floats, - lastSec ? twipsToPx(lastSec.marginBottom) : 0, + spillOverhangPx, ) const flowH = flowWithFloats ?? withEndnotes?.totalHeight ?? totalHeight - const hfHs = secList ? hfHeightsOf(secList) : null + const secSig = secList + ? secList + .map((s) => `${s.firstBlockIndex}:${s.lastBlockIndex}:${s.startType ?? ''}`) + .join('|') + : '' + const resume = resumeFor(blocks, secSig, dirty) + if (resume) resumePasses++ const t1 = performance.now() // rowSplits enables the per-cell row split; the canvas only needs the // matching row heights (the preview applies the cell shifts) @@ -3273,44 +4455,73 @@ export function App() { rowFills: [], rowSplits: [], floatVShifts: [], + floatPullUps: [], + floatCarries: [], floatFlows: [], floatSplits: [], oversizeClips: [], } - const s = secList - ? sliceWithLineSplit( - blocks, - colGeomsFor(sectionGeoms(secList, hfHs!)), - flowH, - factor, - blockMetaOf, - sliceOut, - ) - : sliceWithLineSplit( - blocks, - [ - { - contentHeight: contentH, - forceBreak: false, - ...(singleFirstContentH !== undefined - ? { firstContentHeight: singleFirstContentH } - : {}), - }, - ], - flowH, - factor, - blockMetaOf, - sliceOut, - ) + let s = sliceWithLineSplit( + blocks, + geoms, + flowH, + factor, + blockMetaOf, + sliceOut, + resume ?? undefined, + ) + const floatTops = floats.map((f) => f.top) + const pullFloats = () => { + floats.forEach((f, i) => (f.top = floatTops[i])) + for (const p of sliceOut.floatPullUps ?? []) + for (const f of floats) + if (!f.pinned && !f.pageRelV && Math.abs(f.anchorTop - p.blockTop) < 0.5) + f.top += p.dyPx + } + pullFloats() + // the spill block was sized from unpulled boxes: a box pulled up to the + // paper edge may no longer need the page it opened + if (flowWithFloats !== null && sliceOut.floatPullUps?.length) { + const spillAt = blocks.findIndex((b) => b.isFloatSpill) + if (spillAt >= 0) blocks.splice(spillAt, 1) + const base = withEndnotes?.totalHeight ?? totalHeight + const again = appendFloatSpillBlock(blocks, base, floats, spillOverhangPx) + if ((again ?? base) < flowH - 0.5) { + s = sliceWithLineSplit(blocks, geoms, again ?? base, factor, blockMetaOf, sliceOut) + pullFloats() + } + } tSlice = performance.now() - t1 + const cuts = buildPageCuts(blocks, s, geoms, hfHs ?? [singleHfPx], { + evenOddHf, + // a lone section follows the live Different First Page toggle (pageHfOf, hfHeightsOf) + titlePgOf: + secList && secList.length > 1 + ? (si) => secList[Math.min(si, secList.length - 1)].titlePg + : () => titlePg, + pageNumberFmtOf: secList + ? (si) => secList[Math.min(si, secList.length - 1)]?.pageNumberFmt + : undefined, + floatSplits: sliceOut.floatSplits, + }) + lastPre = sliceOut.preParity ?? [] + lastOut = sliceOut + lastSecSig = secSig + sliceIters = sliceOut.iterations ?? 0 + sliceSplit = `${Math.round(sliceOut.fillMs ?? 0)}+${Math.round(sliceOut.sliceRunMs ?? 0)}` return { blocks, secList, hfHs, s, + cuts, + geoms, floats, + rowSplits: sliceOut.rowSplits ?? [], rowFills: sliceOut.rowFills ?? [], floatVShifts: sliceOut.floatVShifts ?? [], + floatPullUps: sliceOut.floatPullUps ?? [], + floatCarries: sliceOut.floatCarries ?? [], floatFlows: sliceOut.floatFlows ?? [], floatSplits: sliceOut.floatSplits ?? [], oversizeClips: sliceOut.oversizeClips ?? [], @@ -3323,11 +4534,29 @@ export function App() { floats, rowFills, floatVShifts, + floatPullUps, + floatCarries, floatFlows, floatSplits, oversizeClips, + cuts, } = measured slices = measured.s + pageCuts = cuts + lastBlocks = blocks + lastPassChildCount = editor?.state.doc.childCount ?? lastPassChildCount + pageLayoutRef.current = { + blocks, + slices, + sections: secList ?? [], + cuts, + geoms: measured.geoms, + hfHeights: hfHs ?? [singleHfPx], + rowSplits: measured.rowSplits, + carries: [], + } + if (mirrorMargins) locateCaretPageRef.current() + const blockIndex = new BlockIndex(blocks) // document-end footer shows the last page's displayed number, not the physical count if (slices.length > 0) { const lastIdx = slices.length - 1 @@ -3341,6 +4570,12 @@ export function App() { ) : String(num), }) + const counts = sectionPageCounts(slices) + const first = counts[0] + const last = counts[lastIdx] + setEdgeSectionPages((prev) => + prev?.first === first && prev.last === last ? prev : { first, last }, + ) } // Auto-refresh TOC page numbers from the fresh slicing, like Word's field // update. Gated on dirtyRef — our pagination approximates Word's, so a @@ -3355,7 +4590,7 @@ export function App() { 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 idx = Math.min(Math.max(pageOfY(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) @@ -3363,7 +4598,10 @@ export function App() { const tr = editor.state.tr if (applyTocPageDisplays(editor.state.doc, tr, headings, displays)) { tr.setMeta('addToHistory', false) + const before = editor.state.doc editor.view.dispatch(tr) + // a field refresh is not a user edit: keep the AI freshness baseline + carryDocSeen(editor, before) } } // M4 always-on pagination in the canvas: render page gaps before page-leading blocks @@ -3371,6 +4609,7 @@ export function App() { // slice results are gap-independent and refresh is idempotent) let tGapsBuild: number | undefined let tSetGaps: number | undefined + let tCommit: number | undefined const tGaps0 = performance.now() if (editor) { const gaps: PageGapSpec[] = [] @@ -3378,6 +4617,8 @@ export function App() { // skips the same distance to start beside the last portion) const carryGaps = new Map() const overlayCutAnchors: LineAnchor[] = [] + const overlayPages: number[] = [] + const carries: FlowCarry[] = [] const lineRectsOf = createLineRectsCache() const gapIds = new Set() let firstPageFloats: { els: HTMLElement[]; key: string } | undefined @@ -3387,73 +4628,93 @@ export function App() { // per-page header/footer for the gaps (previous page's footer + next page's // header), variant-selected like the pagination preview (first page / odd-even / // per-section references), with real page numbers for the '#' marker - const nums = secList ? pageNumbers(slices, secList) : slices.map((_, n) => n + 1) - const firsts = sectionFirstPages(slices) - const effRefs = secList ? effectiveHfRefs(secList) : null const parsed = doc.parsed // floating shapes (watermarks) must not stack into the strip (mirrors // PaginationPreview); they render separately as per-page behind-text images + // measured and document-derived section lists agree except mid-transition: + // bridge them by the break paragraph so the strip matches what an edit targets + const resolveLive = (si: number, kind: 'header' | 'footer', variant: HfView) => { + const end = secList?.[si]?.lastBlockIndex + const hsi = + end === undefined ? -1 : hfSections.findIndex((s) => s.lastBlockIndex === end) + return hfResolveAt(hsi >= 0 ? hsi : si, kind, variant) + } const pageHfOf = ( pageIdx: number, kind: 'header' | 'footer', - ): { value: HeaderFooter | null; images?: HfImage[]; floats: HfImage[] } => { - const pageNo = nums[pageIdx] + ): { + value: HeaderFooter | null + images?: HfImage[] + floats: HfImage[] + section: number + variant: HfView + owner: number + } => { + // row-anchored logos render inside the strip (off their anchor row), not on the float layer const split = (imgs?: HfImage[] | null) => ({ - images: imgs?.filter((img) => !img.floating), + images: imgs?.filter((img) => !img.floating || hfImageHangsOnPara(img)), floats: [ ...(kind === 'header' && pendingWatermarkImage ? [pendingWatermarkImage] : []), ...(imgs?.filter( - (img) => img.floating && !(watermarkDirty && (img.wordArt || img.watermark)), + (img) => + img.floating && + !hfImageHangsOnPara(img) && + !(watermarkDirty && (img.wordArt || img.watermark)), ) ?? []), ], }) - if (!secList || secList.length <= 1) { - if (titlePg && pageIdx === 0) { - return kind === 'header' - ? { value: hfVariants.headerFirst, ...split(parsed.headerFirst?.images) } - : { value: hfVariants.footerFirst, ...split(parsed.footerFirst?.images) } - } - if (evenOddHf && pageNo % 2 === 0) { - return kind === 'header' - ? { value: hfVariants.headerEven, ...split(parsed.headerEven?.images) } - : { value: hfVariants.footerEven, ...split(parsed.footerEven?.images) } - } - return kind === 'header' - ? { value: header, ...split(parsed.headerImages) } - : { value: footer, ...split(parsed.footerImages) } - } - const pageSlice = slices[pageIdx] - const sec = secList[Math.min(pageSlice.section, secList.length - 1)] - const refs = effRefs![Math.min(pageSlice.section, effRefs!.length - 1)] - const variant = hfVariantOf(sec.titlePg, firsts[pageIdx], evenOddHf, pageNo) - const ov = variant === 'default' ? sectionHfOverride(pageSlice.section, kind) : null - const rId = refs[kind][variant] + const si = secList ? Math.min(slices[pageIdx].section, secList.length - 1) : 0 + const variant: HfView = cuts[pageIdx].hf.variant + const resolved = resolveLive(si, kind, variant) return { - value: ov ?? hfFromPart(rId ? parsed.hfParts?.[rId] : null), - ...split(rId ? parsed.hfParts?.[rId]?.images : undefined), + value: resolved.value, + ...split(resolved.images), + section: si, + variant, + owner: resolved.owner, } } + /** reserved strip heights of the variant a page shows (capacity and gap band agree) */ + const hfOfPage = (idx: number): { headerPx: number; footerPx: number } => cuts[idx].hf + /** width a strip is laid out at: the declaring section's column (hfPartWidthPx) */ + const stripColumnW = (hf: ReturnType, kind: 'header' | 'footer') => + secList + ? { columnW: hfPartWidthPx(secList, hf.section, kind, hf.variant, hf.owner) } + : {} + // Like Word there is no variant switcher: a header/footer part is edited on a + // page that shows it. The edge areas cover page 1's header and the last page's + // footer; a gap strip for any other (section, variant) pair is kept even while + // the part is empty so it still has a double-click target (an empty even-page + // header, a first-page footer on a multi-page document, ...) + const edgeHeaderHf = slices.length > 0 ? pageHfOf(0, 'header') : null + const edgeFooterHf = slices.length > 0 ? pageHfOf(slices.length - 1, 'footer') : null + const gapStripNeeded = ( + gap: ReturnType, + edge: ReturnType | null, + ) => + hfHasVisibleContent(gap.value, gap.images) || + gap.variant !== edge?.variant || + gap.section !== edge.section /** page geometry a page's floating header images position against */ const floatBoxOf = (pageIdx: number): HfFloatBox => { const s = secList?.[slices[pageIdx].section]?.settings ?? section - const hfH = hfHs?.[slices[pageIdx].section] ?? singleHfPx + const hfH = hfOfPage(pageIdx) + const side = pageMargins(s, cuts[pageIdx].pageNo, mirrorMargins) return { pageW: twipsToPx(s.pageWidth), pageH: twipsToPx(s.pageHeight), - marginLeft: twipsToPx(s.marginLeft), - marginRight: twipsToPx(s.marginRight), + marginLeft: twipsToPx(side.left), + marginRight: twipsToPx(side.right), marginTop: effectiveTopPx(s, hfH.headerPx), marginBottom: effectiveBottomPx(s, hfH.footerPx), headerDist: twipsToPx(s.headerDist ?? 720), sectMarginTop: twipsToPx(s.marginTop), + sectMarginBottom: twipsToPx(s.marginBottom), paperX: pageLeftOf(s), + hostLeft: twipsToPx(s.marginLeft), } } - const pageNoTextOf = (pageIdx: number) => - formatPageNumber( - nums[pageIdx], - secList?.[Math.min(slices[pageIdx].section, secList.length - 1)]?.pageNumberFmt, - ) + const pageNoTextOf = (pageIdx: number) => cuts[pageIdx].display const hfSig = (v: HeaderFooter | null | undefined) => v ? `${v.text}·${v.pageNumber ? 1 : 0}·${v.paras?.length ?? 0}` : '' // identity + placement of floating header images: widget keys must change @@ -3464,17 +4725,20 @@ export function App() { imgs .map( (f) => - `${f.dataUrl.length}:${hashStr(f.dataUrl.slice(0, 1024) + f.dataUrl.slice(-1024))}:${f.posXPx ?? f.posH ?? ''}:${f.posYPx ?? f.posV ?? ''}:${f.posHRel ?? ''}${f.posVRel ?? ''}:${f.widthPx ?? ''}x${f.heightPx ?? ''}${f.washout ? ':w' : ''}${f.rotationDeg ? `:r${f.rotationDeg}` : ''}${f.wordArt ? `:t${f.wordArt.text}` : ''}`, + `${f.dataUrl.length}:${hashStr(f.dataUrl.slice(0, 1024) + f.dataUrl.slice(-1024))}:${f.posXPx ?? f.posH ?? ''}:${f.posYPx ?? f.posV ?? ''}:${f.posHRel ?? ''}${f.posVRel ?? ''}:${f.widthPx ?? ''}x${f.heightPx ?? ''}${f.washout ? ':w' : ''}${f.rotationDeg ? `:r${f.rotationDeg}` : ''}${f.wordArt ? `:t${JSON.stringify(f.wordArt)}` : ''}`, ) .join('|') const visiblePages = visiblePageCount(slices) + const sectionPages = sectionPageCounts(slices) const canvasPaperW = pmRect.width / factor const canvasSet = secList?.[0]?.settings ?? section const canvasContentWPx = canvasSet ? twipsToPx(canvasSet.pageWidth - canvasSet.marginLeft - canvasSet.marginRight) : 0 + // mirrored documents pin the strips too: even pages read the swapped margins const mixedWidths = mixedPaper || + mirrorMargins || (canvasSet != null && (secList ?? []).some( (s) => @@ -3498,23 +4762,26 @@ export function App() { } } // page x of a gap strip's left edge, mirroring sizeGapHf (centered, or on the body's left margin) - const gapStripLeft = (set: SectionSettings, box: { contentWidth: number }) => + const gapStripLeft = ( + set: SectionSettings, + box: { contentWidth: number }, + pageIdx: number, + ) => mixedWidths - ? twipsToPx(set.marginLeft) + ? twipsToPx(pageMargins(set, cuts[pageIdx].pageNo, mirrorMargins).left) : (canvasPaperW - Math.min(box.contentWidth, canvasPaperW)) / 2 + // paper x of a page's body left edge: its section's page offset plus the + // page's own (mirrored) left margin + const bodyLeftOf = (set: SectionSettings, pageIdx: number) => + pageLeftOf(set) + twipsToPx(pageMargins(set, cuts[pageIdx].pageNo, mirrorMargins).left) // strip geometry baked in at creation (sizeGapHf, --hf-ml): the gap key's // mKey carries the NEXT section's side margins only, while the footer strip // is sized and inset by the PREVIOUS section — a left-margin edit on that // section alone must not reuse the old footer widget at its stale inset - const stripGeomSig = (set: typeof canvasSet) => { + const stripGeomSig = (set: typeof canvasSet, pageIdx: number) => { if (!set) return '' const b = sectionPageBox(set) - return [ - pageLeftOf(set) + twipsToPx(set.marginLeft), - b.contentWidth, - b.headerDist, - b.footerDist, - ] + return [bodyLeftOf(set, pageIdx), b.contentWidth, b.headerDist, b.footerDist] .map((v) => v.toFixed(1)) .join(',') } @@ -3530,15 +4797,6 @@ export function App() { // effective margins after header/footer push-down (an over-tall header pushes // the body down); a titlePg section's first page uses the first-page variant's // heights so the gap band matches the slice capacity (firstContentHeight) - const hfOfPage = (idx: number): { headerPx: number; footerPx: number } => { - const h = hfHs?.[slices[idx].section] ?? singleHfPx - return firsts[idx] - ? { - headerPx: h.firstHeaderPx ?? h.headerPx, - footerPx: h.firstFooterPx ?? h.footerPx, - } - : h - } const nextHf = hfOfPage(k + 1) const prevHf = hfOfPage(k) const metrics = { @@ -3549,7 +4807,7 @@ export function App() { // header/footer strip inset on the paper (alignGapHfStrips): survives // the inline/in-cell gaps below overriding marginLeft/Right with the host // block's paper offset (which folds in paragraph indents and cell positions) - sectionMarginLeft: pageLeftOf(nextSec) + twipsToPx(nextSec.marginLeft), + sectionMarginLeft: bodyLeftOf(nextSec, k + 1), sectionMarginRight: twipsToPx(nextSec.marginRight), sectionMarginTop: twipsToPx(nextSec.marginTop), ...(mixedPaper @@ -3557,25 +4815,18 @@ export function App() { : {}), } // a page ended early (explicit break / section break / keepNext) leaves unused - // content height; pad the gap so the canvas paints the full paper height and the - // footer stays at the paper bottom. Uniform multi-column pages span columns × - // height with the browser compressing the flow, skip; mixed-column pages use the - // engine's physical height and pull the gap up over the vacated stacked space. - const prevContentH = - twipsToPx(prevSec.pageHeight) - - effectiveTopPx(prevSec, prevHf.headerPx) - - metrics.marginBottom - const used = slices[k].end - slices[k].start + (slices[k].repeatHeader?.height ?? 0) + // content height (PageCut.padPx); pad the gap so the canvas paints the full paper + // height and the footer stays at the paper bottom. Uniform multi-column pages span + // columns × height with the browser compressing the flow, skip; mixed-column pages + // use the engine's physical height and pull the gap up over the vacated stacked space. + const prevCut = cuts[k] + const cut = cuts[k + 1] + const stacked = !slices[k].regions || colMode === 'mixed' const items = pageNotes[k] ?? [] const fnH = items.length > 0 ? items.reduce((s, n) => s + n.height, 0) + FOOTNOTE_SEPARATOR_H : 0 - const physUsed = slices[k].regions - ? colMode === 'mixed' - ? (slices[k].physHeight ?? used) - : null - : used - const remaining = physUsed === null ? 0 : Math.max(0, prevContentH - physUsed) - const pullUp = physUsed === null ? 0 : Math.max(0, used - physUsed) + const remaining = stacked ? prevCut.padPx : 0 + const pullUp = stacked ? Math.max(0, prevCut.usedPx - prevCut.physUsedPx) : 0 // used excludes the reserved footnote height (footnoteExtraPx inflates capacity // bookkeeping, not DOM coordinates), and the notes area already extends the gap // by fnH: pad covers only the rest of the shortfall @@ -3585,7 +4836,7 @@ export function App() { const gapFooter = pageHfOf(k, 'footer') const gapHeader = pageHfOf(k + 1, 'header') const hfEls: HTMLElement[] = [] - if (hfHasVisibleContent(gapFooter.value, gapFooter.images)) { + if (gapStripNeeded(gapFooter, edgeFooterHf)) { const box = sectionPageBox(prevSec) const el = makeGapHfEl({ kind: 'footer', @@ -3593,120 +4844,153 @@ export function App() { images: gapFooter.images, pageNo: pageNoTextOf(k), pageTotal: visiblePages, - geom: { ...hfStripGeom(prevSec), stripLeft: gapStripLeft(prevSec, box) }, + sectionPages: sectionPages[k], + geom: { + ...hfStripGeom(prevSec, legacyBands), + stripLeft: gapStripLeft(prevSec, box, k), + ...stripColumnW(gapFooter, 'footer'), + }, + }) + Object.assign(el.dataset, { + hfKind: 'footer', + hfSection: String(gapFooter.section), + hfVariant: gapFooter.variant, + hfPageIdx: String(k), + hfPage: pageNoTextOf(k), + hfTotal: String(visiblePages), + hfSectionPages: String(sectionPages[k]), }) el.style.top = 'auto' el.style.bottom = `${GAP_BAND + metrics.marginTop + box.footerDist}px` sizeGapHf(el, box) // the footer belongs to the page ABOVE the gap: its own section's // left margin, not the next section's (alignGapHfStrips) - el.style.setProperty( - '--hf-ml', - `${pageLeftOf(prevSec) + twipsToPx(prevSec.marginLeft)}px`, - ) + el.style.setProperty('--hf-ml', `${bodyLeftOf(prevSec, k)}px`) hfEls.push(el) } - if (hfHasVisibleContent(gapHeader.value, gapHeader.images)) { + if (gapStripNeeded(gapHeader, edgeHeaderHf)) { const box = sectionPageBox(nextSec) - // the strip cannot start below the body top: a top margin under headerDist pins it higher - const headerStripTop = Math.min(box.headerDist, metrics.marginTop) const el = makeGapHfEl({ kind: 'header', value: gapHeader.value ?? { text: '' }, images: gapHeader.images, pageNo: pageNoTextOf(k + 1), pageTotal: visiblePages, + sectionPages: sectionPages[k + 1], geom: { - ...hfStripGeom(nextSec), - headerStripTop, - stripLeft: gapStripLeft(nextSec, box), + ...hfStripGeom(nextSec, legacyBands), + stripLeft: gapStripLeft(nextSec, box, k + 1), + ...stripColumnW(gapHeader, 'header'), }, }) + Object.assign(el.dataset, { + hfKind: 'header', + hfSection: String(gapHeader.section), + hfVariant: gapHeader.variant, + hfPageIdx: String(k + 1), + hfPage: pageNoTextOf(k + 1), + hfTotal: String(visiblePages), + hfSectionPages: String(sectionPages[k + 1]), + }) el.style.bottom = 'auto' - el.style.top = `calc(100% - ${metrics.marginTop - headerStripTop}px)` + // the strip sits at w:headerDist even under a fixed top margin above it: Word lets + // the body overprint the header there (probe 2026-10-30), as the first page draws it + el.style.top = `calc(100% + ${box.headerDist - metrics.marginTop}px)` sizeGapHf(el, box) - el.style.setProperty( - '--hf-ml', - `${pageLeftOf(nextSec) + twipsToPx(nextSec.marginLeft)}px`, - ) + el.style.setProperty('--hf-ml', `${bodyLeftOf(nextSec, k + 1)}px`) hfEls.push(el) } // next page's floating header images (picture watermarks): behind-text, // positioned from that page's origin (the gap's bottom edge is marginTop // above it), like PaginationPreview's per-page watermark layer for (const img of gapHeader.floats) { - hfEls.push(makeHfFloatImgEl(img, floatBoxOf(k + 1), 'gap')) + hfEls.push( + tagHfFloatEl(makeHfFloatImgEl(img, floatBoxOf(k + 1), 'gap'), gapHeader, k + 1), + ) } const hfProps = hfEls.length > 0 ? { hfEls, // key must cover everything baked into the widgets (both pages' - // formatted numbers + total count, both sections' strip geometry), - // or stale PAGE/NUMPAGES / strip insets survive reuse - hfKey: `${pageNoTextOf(k)}·${pageNoTextOf(k + 1)}·${visiblePages}·${hfSig(gapFooter.value)}·${hfSig(gapHeader.value)}·f${floatSig(gapHeader.floats)}·g${mixedWidths ? 1 : 0}:${stripGeomSig(prevSec)}:${stripGeomSig(nextSec)}`, + // formatted numbers + total count, both sections' strip geometry, + // and the (section, variant) each strip edits — a Different First + // Page / Odd & Even toggle can swap the variant behind identical + // text), or stale PAGE/NUMPAGES / strip insets / edit targets + // survive reuse + hfKey: `${pageNoTextOf(k)}·${pageNoTextOf(k + 1)}·${visiblePages}·${sectionPages[k]}/${sectionPages[k + 1]}·${gapFooter.section}${gapFooter.variant[0]}·${gapHeader.section}${gapHeader.variant[0]}·${hfSig(gapFooter.value)}·${hfSig(gapHeader.value)}·f${floatSig(gapHeader.floats)}·g${mixedWidths ? 1 : 0}:${stripGeomSig(prevSec, k)}:${stripGeomSig(nextSec, k + 1)}`, } : {} // previous page's footnotes: rendered into the top of the gap (page-bottom area), with the gap enlarged by the reserved height. // in-table gaps carry no footnote area (absolute positioning inside a table-row is unreliable); footnotes stay in the end-of-document list let notes: HTMLElement | undefined let notesKey: string | undefined - let notesMetrics = metrics + // the page's text area (footnotes excluded): dropped pictures fit above its bottom + const prevContentH = + twipsToPx(prevSec.pageHeight) - + effectiveTopPx(prevSec, prevHf.headerPx) - + metrics.marginBottom + let notesMetrics: GapMetrics = { ...metrics, bodyHeight: prevContentH - fnH } if (items.length > 0) { const contentW = twipsToPx( prevSec.pageWidth - prevSec.marginLeft - prevSec.marginRight, ) + const notesLeft = bodyLeftOf(prevSec, k) notes = makeGapNotesEl( items, - pageLeftOf(prevSec) + twipsToPx(prevSec.marginLeft), + notesLeft, contentW, fnH, footnoteLineHeightPx(prevSec.docGrid), (id) => editNote('footnote', id), ) - notesKey = `${Math.round(fnH)}:${items.map((n) => `${n.no}${n.text}`).join('|')}` - notesMetrics = { ...metrics, marginBottom: metrics.marginBottom + fnH } + notesKey = `${Math.round(fnH)}:${notesLeft.toFixed(1)}:${items.map((n) => `${n.no}${n.text}`).join('|')}` + notesMetrics = { + ...notesMetrics, + marginBottom: metrics.marginBottom + fnH, + bodyMarginBottom: metrics.marginBottom, + } } const markShown = () => items.forEach((n) => gapIds.add(n.id)) - // split floating table: the boundary is one of its row cuts, so the - // page turn is an in-table gap of the float; the anchor paragraph - // below it shares that top and must not take a block gap - const fsplit = floatSplits.find((f) => - f.cutYs.some((y) => Math.abs(y - slice.start) < 0.5), - ) - const fsBlock = fsplit - ? blocks.find( - (bb) => bb.el && bb.floatTable && Math.abs(bb.top - fsplit.blockTop) < 0.5, - ) - : undefined - const i = fsBlock ? -1 : blocks.findIndex((b) => Math.abs(b.top - slice.start) < 0.5) - if (i >= 0 && blocks[i].el) { + // the page lead comes from the PageCut (I11); the DOM only resolves the + // ProseMirror position / element behind it. A lead the DOM cannot honour + // counts as a pageCutMismatch (and the dropped gap as a boundary mismatch) + const lead = cut.lead + const missCut = (why: string) => { + pageCutMismatch++ + if (pageCutDiag.length < 20) pageCutDiag.push(`page ${k + 2}: ${why}`) + } + if (lead.kind === 'block') { + const el = blocks[lead.block]?.el + if (!el) { + missCut('block lead without element') + return + } // footnotes sit at the paper bottom (Word): shift past the padding if (notes && pad > 0) notes.style.top = `${5 + pad}px` gaps.push({ - el: blocks[i].el!, + el, + page: k + 1, boundaryY: slice.start, metrics: pad > 0 - ? { ...notesMetrics, marginBottom: notesMetrics.marginBottom + pad } + ? { + ...notesMetrics, + marginBottom: notesMetrics.marginBottom + pad, + bodyPad: pad, + bodyMarginBottom: metrics.marginBottom, + } : notesMetrics, ...(pullUp > 0.5 ? { pullUp } : {}), - ...(blocks[i].breakBefore || (i > 0 && blocks[i - 1].breakAfter) - ? { suppressLeadMt: true } - : {}), + ...(cut.leadDropsSpaceBefore ? { suppressLeadMt: true } : {}), + ...(cut.leadMtPx !== undefined ? { leadMtPx: cut.leadMtPx } : {}), ...(notes ? { notes, notesKey } : {}), ...hfProps, }) markShown() return } - // mid-paragraph page break (line-level cut point): insert an inline gap at the broken line. In-table cut points are not decorated yet - const b = - fsBlock ?? - blocks.find( - (bb) => bb.el && bb.top < slice.start && slice.start < bb.top + bb.height - 0.5, - ) - if (!b?.el) { + if (lead.kind === 'tail') { // deliberate trailing blank page (document ends with a page break): no // anchor block exists — hang the gap at the document end so the blank // sheet paints (min-height extension below covers its paper height) @@ -3714,10 +4998,16 @@ export function App() { gaps.push({ pos: editor.state.doc.content.size, kind: 'inline', + page: k + 1, boundaryY: slice.start, metrics: pad > 0 - ? { ...notesMetrics, marginBottom: notesMetrics.marginBottom + pad } + ? { + ...notesMetrics, + marginBottom: notesMetrics.marginBottom + pad, + bodyPad: pad, + bodyMarginBottom: metrics.marginBottom, + } : notesMetrics, ...(pullUp > 0.5 ? { pullUp } : {}), ...hfProps, @@ -3726,169 +5016,197 @@ export function App() { } return } - // block-relative Y of the cut (a shifted float's rows start at its --tblp-dy) - const cutOff = - slice.start - b.top - (b.spaceBeforePx ?? 0) - (fsBlock ? fsplit!.dyPx : 0) - if (b.el.querySelector('tr')) { - // in-table cut point: insert an in-table gap row (display:table-row widget) - // before the broken row (next page's first row). Positioning must subtract the - // gap's own height: a tr's DOM offset includes in-table gaps above it, so - // subtract them before comparing with the slice's gapless virtual coordinates - const gapRects = Array.from(b.el.querySelectorAll('.page-gap-inline')).map((g) => - g.getBoundingClientRect(), - ) - 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.classList.contains('page-repeat-header'), - )) { + const b = blocks[lead.block] + if (!b?.el) { + missCut(`${lead.kind} lead without element`) + return + } + const cutOff = lead.lineOffset ?? 0 + if (lead.kind === 'row' || lead.kind === 'cell') { + // the previous pass's gap rows and repeated headers are not rows of the + // measured table (domTableRows): index the same list + const realRows = outerTableRows(b.el) + if (lead.kind === 'row') { + const tr = realRows[lead.row ?? -1] + if (!tr) { + missCut(`row ${lead.row} beyond ${realRows.length} DOM rows`) + return + } + // the I11 shadow of the row sampler: the measured row top must still be + // where the DOM row sits (a tr's offset includes in-table gaps above it) + const gapRects = Array.from(b.el.querySelectorAll('.page-gap-inline')).map((g) => + g.getBoundingClientRect(), + ) 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 <= cutOff + 0.5) cutRow = tr - if (Math.abs(off - cutOff) < 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 - } - // the spanning gap cell must cover exactly the table's column - // grid: a wider colSpan adds phantom columns, which collapses - // colgroup-less fixed-layout tables to ~1px columns (makeGapEl) - const gridCols = Math.max( - 1, - ...Array.from( - tr - .closest('table') - ?.querySelectorAll(':scope > tbody > tr') ?? [], - ) - .filter( + const off = (trTop - b.el.getBoundingClientRect().top - gapsAbove) / factor + const rowTop = (b.tableRows ?? []) + .slice(0, lead.row) + .reduce((s, r) => s + r.height, 0) + if (Math.abs(off - rowTop) >= LEAD_ROW_TOL_PX) + missCut(`row ${lead.row} DOM offset ${off.toFixed(1)} vs ${rowTop.toFixed(1)}`) + // split floating table: the boundary is one of its row cuts, so the + // page turn is an in-table gap of the float; the anchor paragraph + // below it skips the same distance (carryGaps) + const fsplit = floatSplits.find((f) => + f.cutYs.some((y) => Math.abs(y - slice.start) < 0.5), + ) + 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 + let repeatHeaderPx = 0 + 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'), - ) - .map((r) => Array.from(r.cells).reduce((s, c) => s + c.colSpan, 0)), + ) 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 + repeatHeaderPx = acc + } + } + // the spanning gap cell must cover exactly the table's column + // grid: a wider colSpan adds phantom columns, which collapses + // colgroup-less fixed-layout tables to ~1px columns (makeGapEl) + const gridCols = Math.max( + 1, + ...Array.from( + tr + .closest('table') + ?.querySelectorAll(':scope > tbody > tr') ?? [], ) - for (let d = $pos.depth; d > 0; d--) { - if ($pos.node(d).type.name === 'docTableRow') { - // no notes area in table gaps: pad the full remainder - const tablePad = Math.round(remaining) - gaps.push({ - pos: $pos.before(d), - kind: 'table', - cols: gridCols, - boundaryY: slice.start, - metrics: - tablePad > 0 - ? { ...metrics, marginBottom: metrics.marginBottom + tablePad } - : metrics, - ...hfProps, - ...(repeatHeaderEls + .filter( + (r) => + !r.classList.contains('page-gap') && + !r.classList.contains('page-repeat-header'), + ) + .map((r) => Array.from(r.cells).reduce((s, c) => s + c.colSpan, 0)), + ) + for (let d = $pos.depth; d > 0; d--) { + if ($pos.node(d).type.name === 'docTableRow') { + // no notes area in table gaps: pad the full remainder + const tablePad = Math.round(remaining) + gaps.push({ + pos: $pos.before(d), + kind: 'table', + cols: gridCols, + page: k + 1, + boundaryY: slice.start, + ...(fsplit ? { floatGap: true } : {}), + ...(repeatHeaderPx > 0 ? { repeatHeaderPx } : {}), + metrics: + tablePad > 0 ? { - 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('§'))}`, + ...metrics, + marginBottom: metrics.marginBottom + tablePad, + bodyMarginBottom: metrics.marginBottom, } - : {}), - }) - if (fsplit) { - const gapH = - metrics.marginBottom + tablePad + GAP_BAND + metrics.marginTop - carryGaps.set( - fsplit.blockTop, - (carryGaps.get(fsplit.blockTop) ?? 0) + gapH, - ) - } - break + : 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('§'))}`, + } + : {}), + }) + if (fsplit) { + const gapH = metrics.marginBottom + tablePad + GAP_BAND + metrics.marginTop + carryGaps.set(fsplit.blockTop, (carryGaps.get(fsplit.blockTop) ?? 0) + gapH) } + break } - } catch { - /* if posAtDOM fails, skip decorating this round */ } - break + } catch { + // posAtDOM failed: skip decorating this round + missCut(`row ${lead.row} has no ProseMirror position`) } + return } - if (!matched) { - // in-row cut point (page break between a cell's lines): anchor at the first line after it - const anchor = nextLineAnchor(b.el, cutOff, factor, lineRectsOf) - if (anchor == null) return - // anchors inside the read-only nested-table NodeView have no distinct PM - // position (posAtDOM collapses them all to the node start): overlay markers - if (anchorElement(anchor)?.closest('.doc-nested-table')) { - overlayCutAnchors.push(anchor) - return - } - const pos = posFromAnchor(editor.view, anchor) - if (pos == null) return - const cutCell = singleCutCell(cutRow, anchor) - 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 = ( - anchorElement(anchor)?.closest('td > *, th > *') ?? cutCell - ).getBoundingClientRect() - gaps.push({ - pos, - kind: 'cell', - boundaryY: slice.start, - metrics: { - ...metrics, - // rect offsets from the paper edge already include the page margins - marginLeft: (r.left - pmRect.left) / factor - pageLeftOf(nextSec), - marginRight: (pmRect.right - r.right) / factor - pageLeftOf(nextSec), - }, - ...(pullUp > 0.5 ? { pullUp } : {}), - ...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 }) - } + // in-row cut point (page break between a cell's lines): anchor at the first line after it + const cutRow = + lead.row !== undefined && lead.row >= 0 ? (realRows[lead.row] ?? null) : null + const anchor = nextLineAnchor(b.el, cutOff, factor, lineRectsOf) + if (anchor == null) { + missCut(`no line anchor after the cut in row ${lead.row}`) + return } - return - } - // fallback: font-load reflow can leave lineStartAnchor's exact-offset match - // just outside tolerance; the first line after the cut still gets the gap + // anchors inside the read-only nested-table NodeView have no distinct PM + // position (posAtDOM collapses them all to the node start): overlay markers + if (anchorElement(anchor)?.closest('.doc-nested-table')) { + overlayCutAnchors.push(anchor) + overlayPages.push(k + 1) + return + } + const pos = posFromAnchor(editor.view, anchor) + if (pos == null) { + missCut(`cell lead has no ProseMirror position`) + return + } + const cutCell = singleCutCell(cutRow, anchor, factor) + 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 = ( + anchorElement(anchor)?.closest('td > *, th > *') ?? cutCell + ).getBoundingClientRect() + gaps.push({ + pos, + kind: 'cell', + page: k + 1, + boundaryY: slice.start, + metrics: { + ...metrics, + // rect offsets from the paper edge already include the page margins + marginLeft: (r.left - pmRect.left) / factor - pageLeftOf(nextSec), + marginRight: (pmRect.right - r.right) / factor - pageLeftOf(nextSec), + }, + ...(pullUp > 0.5 ? { pullUp } : {}), + ...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', page: k + 1, metrics }) + } + return + } + // mid-paragraph page break (line-level cut point): insert an inline gap at the + // broken line. Fallback: font-load reflow can leave lineStartAnchor's exact-offset + // match just outside tolerance; the first line after the cut still gets the gap const anchor = lineStartAnchor(b.el, cutOff, factor, lineRectsOf) ?? nextLineAnchor(b.el, cutOff, factor, lineRectsOf) const pos = anchor ? posFromAnchor(editor.view, anchor) : undefined if (pos == null) { console.warn('[pagination] no line anchor at page boundary', slice.start) + missCut('no line anchor at the cut') return } // fold the block's offset from the paper edge (page margin + indent) into negative margins so the gap spans exactly the paper width const elRect = b.el.getBoundingClientRect() gaps.push({ pos, + page: k + 1, boundaryY: slice.start, metrics: { ...notesMetrics, @@ -3904,27 +5222,30 @@ export function App() { // first page has no gap widget; its floating header images ride a // dedicated zero-height widget at the document start if (slices.length > 0) { - const floats = pageHfOf(0, 'header').floats + const firstHeader = pageHfOf(0, 'header') + const floats = firstHeader.floats if (floats.length > 0) { const box = floatBoxOf(0) firstPageFloats = { - els: floats.map((img) => makeHfFloatImgEl(img, box, 'lead')), - key: `${floatSig(floats)}·${Math.round(box.pageW)}x${Math.round(box.pageH)}·${Math.round(box.marginTop)}·${Math.round(box.sectMarginTop)}·${Math.round(box.paperX ?? 0)}`, + els: floats.map((img) => + tagHfFloatEl(makeHfFloatImgEl(img, box, 'lead'), firstHeader, 0), + ), + key: `${floatSig(floats)}·${Math.round(box.pageW)}x${Math.round(box.pageH)}·${Math.round(box.marginLeft)}·${Math.round(box.hostLeft ?? 0)}·${Math.round(box.marginTop)}·${Math.round(box.sectMarginTop)}·${Math.round(box.paperX ?? 0)}`, } } } // silently dropped boundaries merge two pages into one giant page — surface them const boundaries = slices.slice(1).filter((s, k) => s.start !== slices[k].start).length - if (gaps.length + overlayCutAnchors.length !== boundaries) + if (gaps.length + overlayCutAnchors.length !== boundaries) { + gapBoundaryMismatch++ console.warn( `[pagination] ${gaps.length + overlayCutAnchors.length} page gaps built for ${boundaries} boundaries`, ) - // 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. + } + // DOM-only (contenteditable=false subtrees the save path never reads); + // a pristine open keeps the file's cached results like the refresh above const tocLines = pm.querySelectorAll('.doc-toc-line[data-toc-anchor]') - if (tocLines.length > 0) { + if (tocLines.length > 0 && slices.length > 0) { const anchorIdx = new Map() for (const b of parsed.blocks) { if (b.docxIndex == null) continue @@ -3934,24 +5255,33 @@ export function App() { 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) - } + backfillTocPageDom( + tocLines, + (anchor) => { + const idx = anchorIdx.get(anchor) + const top = idx === undefined ? undefined : topByIdx.get(idx) + if (top === undefined) return undefined + // pageAt is 1-based; pageNoTextOf indexes nums/slices 0-based + return pageNoTextOf(Math.max(0, Math.min(pageOfY(slices, top), slices.length - 1))) + }, + dirtyRef.current, + ) } } tGapsBuild = performance.now() - tGaps0 const tSet0 = performance.now() + const syncMs: Array<[string, number]> = [] + let tStage = tSet0 + const stage = (name: string) => { + const now = performance.now() + syncMs.push([name, Math.round(now - tStage)]) + tStage = now + } const layoutBatch = new LayoutBatch(editor.view) // split declared-height rows: resolve the engine's target heights to tr elements const rowFillEls: Array<{ el: Element; targetPx: number; extraPx?: number }> = [] for (const f of rowFills) { - const b = blocks.find((bb) => bb.tableRows && Math.abs(bb.top - f.blockTop) < 0.5) + const b = blocks[blockIndex.firstAtTop(f.blockTop, (bb) => !!bb.tableRows)] if (!b?.el) continue const trs = Array.from(b.el.querySelectorAll('tr')).filter( (tr) => @@ -3968,21 +5298,23 @@ export function App() { }) } setRowFills(editor.view, rowFillEls, layoutBatch) + stage('rowFills') // oversized single-line blocks: resolve the engine's page-bottom clips to their elements const oversizeEls: Array<{ el: HTMLElement; clipPx: number }> = [] for (const c of oversizeClips) { - const b = blocks.find( - (bb) => bb.oversizeLineH !== undefined && Math.abs(bb.top - c.blockTop) < 0.5, - ) + const b = + blocks[blockIndex.firstAtTop(c.blockTop, (bb) => bb.oversizeLineH !== undefined)] if (b?.el) oversizeEls.push({ el: b.el, clipPx: c.clipPx }) } setOversizeClips(editor.view, oversizeEls) + stage('oversize') // page/margin-anchored floated tables: resolve the engine's Y shifts to table elements const floatVEls: Array<{ el: Element dyPx: number flow?: boolean carryPx?: number + pull?: boolean }> = [] // blocks by rounded top: a scan per shift was floats × blocks on picture-heavy // documents; the first match in document order wins, as the scan did @@ -4010,6 +5342,20 @@ export function App() { const b = pageRelBlockAt(f.blockTop) if (b?.el) floatVEls.push({ el: b.el, dyPx: f.dyPx }) } + const pictureAt = blockAtTop((bb) => !!bb.floatPicture) + for (const f of floatPullUps) { + const b = pictureAt(f.blockTop) + if (b?.el) floatVEls.push({ el: b.el, dyPx: f.dyPx, pull: true }) + } + for (const f of floatCarries) { + const b = blocks.find( + (bb) => + !!bb.el && + !bb.floated && + Math.abs(bb.top - (bb.carryAppliedPx ?? 0) - f.blockTop) < 0.5, + ) + if (b?.el && f.carryPx > 0.5) floatVEls.push({ el: b.el, dyPx: 0, carryPx: f.carryPx }) + } const floatTableAt = blockAtTop((bb) => Boolean(bb.floatTable)) for (const f of floatFlows) { const b = floatTableAt(f.blockTop) @@ -4019,24 +5365,32 @@ export function App() { // beside the last portion — a flow spacer carries it past the earlier // portions, a gap-sized spacer past the table's in-table gaps for (const f of floatSplits) { - const bi = blocks.findIndex( - (bb) => bb.el && bb.floatTable && Math.abs(bb.top - f.blockTop) < 0.5, - ) + const bi = blockIndex.firstAtTop(f.blockTop, (bb) => !!bb.el && !!bb.floatTable) const anchor = bi >= 0 ? blocks[bi + 1] : undefined if (!anchor?.el || anchor.floated) continue const carry = f.blockTop + f.carryPx - (anchor.top - (anchor.carryAppliedPx ?? 0)) if (carry > 0.5) floatVEls.push({ el: anchor.el, dyPx: 0, carryPx: carry }) const gapPx = carryGaps.get(f.blockTop) ?? 0 - if (gapPx > 0.5) + if (gapPx > 0.5) { gaps.push({ el: anchor.el, carryPx: gapPx, metrics: { marginTop: 0, marginBottom: 0, marginLeft: 0, marginRight: 0 }, }) + carries.push({ y: anchor.top, px: Math.round(gapPx) }) + } } + // the cuts learn the gap widgets' geometry: every page reader below + // (float shifts, cell boxes, frames, status bar, thumbnails) reads them + applyGapLedger(cuts, gapLedgerOf(gaps, overlayPages)) + if (pageLayoutRef.current) pageLayoutRef.current.carries = carries + // no gap widgets (web / read mode, one page): the flow is the page + const ledgered = cuts.some((c) => c.gap) setFloatVShifts(editor.view, floatVEls, layoutBatch) + stage('floatV') pm.classList.remove('measuring-natural') setPageGaps(editor.view, gaps, firstPageFloats, layoutBatch) + stage('pageGaps') // mixed-column canvas: paint the engine's regions via per-block width/translate decorations const colSpecs = viewMode === 'print' && !readMode && colMode === 'mixed' && secList @@ -4061,10 +5415,26 @@ export function App() { viewMode === 'print' && !readMode && secList ? sectionGridPitchSpecs(blocks, secList) : [] // sections disagreeing on the docGrid charSpace delta: per-block letter-spacing vars const charSpecs = - viewMode === 'print' && !readMode && secList ? sectionCharSpaceSpecs(blocks, secList) : [] + viewMode === 'print' && !readMode && secList + ? sectionCharSpaceSpecs(blocks, secList, doc.parsed.balanceDbcsSpacing === true) + : [] // sections disagreeing on the top margin: per-block --doc-margin-top for page-relative anchors const topSpecs = viewMode === 'print' && !readMode && secList ? sectionTopMarginSpecs(blocks, secList) : [] + // page-opening sections clear the previous section's page-bottom floats + const sectStartSpecs = + viewMode === 'print' && !readMode && secList ? sectionPageStartSpecs(blocks, secList) : [] + // w:mirrorMargins: even pages translate onto the swapped text column + const mirrorSpecs = + viewMode === 'print' && !readMode && mirrorMargins + ? mirrorMarginSpecs( + blocks, + slices, + secList ?? [], + section, + secList ? pageNumbers(slices, secList) : slices.map((_, n) => n + 1), + ) + : [] // sectPr w:textDirection sections: writing-mode boxes placed sideways (width wins) const vertSpecs = viewMode === 'print' && !readMode && secList && hfHs @@ -4075,7 +5445,9 @@ export function App() { gridSpecs, charSpecs, topSpecs, + sectStartSpecs, secWSpecs, + mirrorSpecs, colSpecs, vaSpecs, vertSpecs, @@ -4093,27 +5465,46 @@ export function App() { layoutSpecs = [...mergedSpecs.values()] } setColumnLayout(editor.view, layoutSpecs, layoutBatch) + stage('columnLayout') + const tCommit0 = performance.now() layoutBatch.commit() + tCommit = performance.now() - tCommit0 + stage('commit') // in-table gap bands live in the spanning cell's coordinate space: // re-anchor them to the paper before the strips are measured/aligned alignTableGapFills(pm, factor) - if (secWSpecs.length > 0 && section) { + stage('tableGapFills') + if ((secWSpecs.length > 0 || mirrorMargins) && section) { const cSet = secList?.[0]?.settings ?? section alignGapHfStrips(pm, pageLeftOf(cSet) + twipsToPx(cSet.marginLeft), factor) } + stage('hfStrips') // after setPageGaps: widget insertion is synchronous, so anchor rects are final + if (ledgered) + stampGapFlowTops(pm, cuts, pm.getBoundingClientRect().top + mTopPx * factor, factor) syncFloatShifts( pm, floats, pm.getBoundingClientRect().top + mTopPx * factor, factor, mTopPx - twipsToPx((sections[0]?.settings ?? section)?.marginTop ?? 0), + ledgered ? { cuts, carries } : undefined, ) + stage('floatShifts') // Word keeps anchored objects on the page: cell boxes lifted past the // paper top by a negative anchor offset are pushed back down clampCellBoxTops(pm, pm.getBoundingClientRect().top, factor) clampCellImageTops(pm, factor) + pinCellBoxesToPage(pm, factor, cuts) + pinPageRelXPictures(pm, factor, cuts) + pinParagraphRelVPictures( + pm, + planOf, + new Set(gaps.flatMap((g) => ('el' in g && g.suppressLeadMt ? [g.el] : []))), + ) + stage('clampCells') syncCutOverlays((pm.closest('.page-wrap') as HTMLElement) ?? pm, overlayCutAnchors, factor) + stage('cutOverlays') { // page border (w:pgBorders): per-page overlay boxes (w:display can // exclude pages; the border must not run through the page gaps) @@ -4123,13 +5514,16 @@ export function App() { const firstFrame = pbSec ? { left: pageLeftOf(pbSec), width: twipsToPx(pbSec.pageWidth) } : undefined - syncPageBorders(wrapEl, borderStyle, factor, firstFrame) - syncLineNumbers(wrapEl, pm, blocks, secList ?? [], factor, blockMetaOf, firstFrame) + syncPageBorders(wrapEl, borderStyle, factor, firstFrame, cuts) + stage('pageBorders') + syncLineNumbers(wrapEl, pm, blocks, secList ?? [], factor, blockMetaOf, firstFrame, cuts) + stage('lineNumbers') // differing-width documents paint one sheet per page (the shared paper is transparent) syncPageSheets( wrapEl, factor, mixedPaper && viewMode === 'print' && !readMode ? (firstFrame ?? null) : null, + cuts, ) } syncMarginAnnotations( @@ -4139,24 +5533,31 @@ export function App() { factor, doc.parsed.blocks, editor.view, + marginHandlersRef.current, + cuts, ) + stage('sheets+annotations') tSetGaps = performance.now() - tSet0 - // suppression collapses the DOM after this pass sliced; one follow-up remeasure re-syncs (sig goes stable, no loop) - const sig = gaps.reduce((s, g, n) => (g.suppressLeadMt ? `${s},${n}` : s), '') - if (sig !== suppressSig) { - suppressSig = sig - followUp('suppress') + if (obstaclesChanged) followUp('obstacles') + // a fill just applied to a split row changed the table height under the slices + const fSig = rowFillsSig(rowFills) + if (fSig !== rowFillSig) { + rowFillSig = fSig + followUp('rowFills') } // freshly applied wrap widths (section widths, unequal column widths, // vertical-text line lengths) change line breaks: one follow-up remeasure with them in the DOM - const wSig = [...secWSpecs, ...colSpecs, ...vertSpecs] - .map((s) => `${Math.round(s.widthPx ?? -1)}:${Math.round(s.contentWPx ?? -1)}`) - .join(',') + const wSig = wrapWidthSig([...secWSpecs, ...colSpecs, ...vertSpecs, ...sectStartSpecs]) + let wrapWidthsChanged = false if (wSig !== secWidthSig) { secWidthSig = wSig + wrapWidthsChanged = true followUp('width') } - if (colGranted) followUp('columns') + if (colGranted) { + wrapWidthsChanged = true + followUp('columns') + } // freshly applied per-block letter-spacing changes line breaks the same way const cSig = charSpecs.length === 0 @@ -4164,22 +5565,43 @@ export function App() { : `${charSpecs.length}:${[...new Set(charSpecs.map((s) => s.charSpacePt))].join(',')}` if (cSig !== charSpaceSig) { charSpaceSig = cSig + wrapWidthsChanged = true followUp('charSpace') } + // the shrink loops measured line breaks the new widths just moved; + // their re-decision rides the follow-up pass already scheduled + if (wrapWidthsChanged) document.dispatchEvent(new Event(WRAP_WIDTHS_COMMITTED_EVENT)) // 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:not(.page-gap-carry)') - const lastGapEl = gapEls[gapEls.length - 1] - if (lastGapEl && slices.length > 1) { + const bands = pagePaperEdges(cuts, mTopPx).filter((e) => e.gap.kind !== 'cut') + const lastBand = bands[bands.length - 1] + if (lastBand && slices.length > 1) { const last = slices[slices.length - 1] const lastSec = secList?.[last.section]?.settings ?? section const lastHf = hfHs?.[last.section] ?? singleHfPx - const paperTop = - (lastGapEl.getBoundingClientRect().bottom - pm.getBoundingClientRect().top) / factor - - effectiveTopPx(lastSec, lastHf.headerPx) - pm.style.minHeight = `${Math.round(paperTop + twipsToPx(lastSec.pageHeight))}px` + const paperTop = lastBand.gapBottom - effectiveTopPx(lastSec, lastHf.headerPx) + if (pageReaderShadow.enabled) { + const gapEls = pm.querySelectorAll(PAGE_TURN_SEL) + const lastGapEl = gapEls[gapEls.length - 1] + const domTop = lastGapEl + ? (lastGapEl.getBoundingClientRect().bottom - pm.getBoundingClientRect().top) / + factor - + effectiveTopPx(lastSec, lastHf.headerPx) + : NaN + if (!(Math.abs(domTop - paperTop) <= 1)) + pageReaderShadow.report('lastPaper', domTop.toFixed(1), paperTop.toFixed(1)) + } + const paperH = Math.round(paperTop + twipsToPx(lastSec.pageHeight)) + pm.style.minHeight = `${paperH}px` + // a mixed-column last page keeps its columns stacked in the flow with no + // following gap to pull up over the vacated space: pin the box to the + // paper bottom (the stacked tail below holds nothing visible) + const stackedTail = last.regions && colMode === 'mixed' && (last.physHeight ?? 0) > 0 + if (stackedTail) pm.style.height = `${paperH}px` + else pm.style.removeProperty('height') } else { pm.style.removeProperty('min-height') + pm.style.removeProperty('height') } // endnote area: Word puts it right after the last body line, not at the page // bottom — anchor it to the flow end measured in the final display state @@ -4195,9 +5617,44 @@ export function App() { // for real-device verification/troubleshooting: current slices and block geometry (read-only snapshot, no functional dependency) ;(window as unknown as Record).__pageDebug = { slices, + fastPasses, + fastReject, + gapBoundaryMismatch, + pageCutMismatch, + pageCutDiag, + get pageReaderMismatch() { + return pageReaderShadow.count + }, + get pageReaderDiag() { + return pageReaderShadow.diag + }, + pageReaderShadowed: pageReaderShadow.enabled, + pageCuts, + floatSplits, + pageCutSig: pageCutSignature(pageCuts), + planDiff, + planDiffHistogram: planDiffHistogram(planDiff), + obstacleShadow: { wrappers: obstacleShadow.wrappers, boxes: obstacleShadow.boxes }, + layoutHealth: layoutHealth.snapshot(), + carriedSamples, + resumePasses, + resumeReject, + resumePage, + resumeDiag, colMode, retrigger, - followUps, + retriggerLog: chain.log, + followUps: chain.passes, + // live: a refusal after the last pass must be visible too + get loopStops() { + return chain.stops + }, + get tabFreezes() { + return tabLayoutDiag.freezes + }, + sliceIterationsCapped: lastOut?.iterationsCapped ?? false, + sliceRunaway: lastOut?.runaway ?? false, + rowSplitOverflows: lastOut?.rowSplitOverflows ?? 0, colSpecs: colSpecs.map((s) => ({ w: s.widthPx === undefined ? null : Math.round(s.widthPx), dx: Math.round(s.dx), @@ -4235,6 +5692,10 @@ export function App() { remeasureMs: performance.now() - tStart, measureMs: tMeasure, sliceMs: tSlice, + sliceIters, + sliceSplit, + commitMs: tCommit, + syncMs, gapsBuildMs: tGapsBuild, setGapsMs: tSetGaps, } @@ -4250,9 +5711,17 @@ export function App() { return gapIds }) } - locate() + setLayoutRev((v) => v + 1) } - const onUpdate = () => { + const onUpdate = ( + fastIndex: number | null = null, + dirtyIndex: number | null = fastIndex, + isFollowUp = false, + ) => { + if (disposed) return + if (fastIndex === null) fastEdits = null + else fastEdits?.add(fastIndex) + dirtyFrom = dirtyIndex === null || dirtyFrom === null ? null : Math.min(dirtyFrom, dirtyIndex) // while the tail streams, chunks land every few frames: a plain debounce // would either never fire (dense chunks) or pay a whole-document pass // per chunk (sparse ones); keep the pass already due and pace new ones @@ -4271,13 +5740,26 @@ export function App() { if (timer) window.clearTimeout(timer) // a whole-document pass after every pause blocks typing on long // documents: wait longer when the last pass was slow (Word paginates in - // the background too); short documents keep the 300 ms feel + // the background too); short documents keep the 300 ms feel. A typed + // edit arriving after a follow-up re-arms the timer with the edit delay. timer = window.setTimeout( remeasure, - Math.min(EDIT_PASS_DEBOUNCE_MAX_MS, Math.max(300, lastPassMs * EDIT_PASS_DUTY)), + isFollowUp + ? FOLLOW_UP_PASS_DELAY_MS + : Math.min(EDIT_PASS_DEBOUNCE_MAX_MS, Math.max(300, lastPassMs * EDIT_PASS_DUTY)), ) } - remeasure() + // while a phased open streams its tail, this effect re-runs as the section + // state fills in: a synchronous whole-document pass per run made a long + // open quadratic, so the paced scheduler takes it (the last chunk's update + // still lands a full pass once the tail has settled) + flushPaginationRef.current = () => { + if (timer === null) return + window.clearTimeout(timer) + remeasure() + } + if (isPhasedContentPending()) onUpdate() + else remeasure() // async @font-face loading triggers a full reflow (line-break points change); pagination // must be remeasured, and cached line samples invalidated (block heights may not change) // header/footer strips probed under a fallback face re-measure too @@ -4285,6 +5767,16 @@ export function App() { let hfTimer: number | undefined const onFontsChanged = (reprobeHf: boolean) => { bumpLineSampleFontEpoch() + // strut metrics measured under a fallback face before a bundled font loaded + if (takeSettledMetricFaces()) { + const parsed = fileCtxRef.current.doc?.parsed + const live = liveStylesRef.current + if (parsed) { + setDocCss( + docStyleCss(live?.parsed === parsed ? { ...parsed, styles: live.styles } : parsed), + ) + } + } if (reprobeHf) { bumpHfProbeFontEpoch() if (hfTimer) window.clearTimeout(hfTimer) @@ -4298,9 +5790,9 @@ export function App() { const onLoadingDone = () => onFontsChanged(true) document.fonts.addEventListener('loadingdone', onLoadingDone) scroller.addEventListener('scroll', locate, { passive: true }) - const onDocUpdate = () => { + const onDocUpdate = ({ transaction }: { transaction: Transaction }) => { resetWidthPassHistory(colWidthPass) - onUpdate() + onUpdate(singleInlineEditIndex(transaction), firstChangedTopLevelIndex(transaction)) } editor?.on('update', onDocUpdate) // justify-shrink re-decides via decoration-only transactions that move the @@ -4308,15 +5800,38 @@ export function App() { // changes, so without this the inline gap widgets keep their pre-shrink cut // positions and force a stretched-sparse page-last line (r177). The stale // block re-samples via the shrink fingerprint in lineSampleSig. + // the shrink list covers every justified paragraph; only the entries that + // differ from the last list moved a wrap point, so the pass resumes there + let shrinkSig = new Map() const onShrinkTr = (props: { transaction: Transaction }) => { - if ( - props.transaction.getMeta(justifyShrinkPluginKey) || - props.transaction.getMeta(floatFlowChangedMeta) - ) - onUpdate() + const decos = props.transaction.getMeta(justifyShrinkPluginKey) as + Array<{ from: number; to: number; type?: { attrs?: { style?: string } } }> | undefined + if (decos && editor) { + const next = new Map() + // guard: DecorationSet.create nulls out consumed entries of the array + // it is handed — a plugin seeing this meta first must not crash us + for (const d of decos) if (d) next.set(d.from, `${d.to}:${d.type?.attrs?.style ?? ''}`) + let minPos = Infinity + for (const [from, sig] of next) + if (shrinkSig.get(from) !== sig) minPos = Math.min(minPos, from) + for (const from of shrinkSig.keys()) if (!next.has(from)) minPos = Math.min(minPos, from) + shrinkSig = next + if (minPos === Infinity) return + const { doc: pmDoc } = editor.state + followUpAt('justify-shrink', pmDoc.resolve(Math.min(minPos, pmDoc.content.size)).index(0)) + return + } + if (props.transaction.getMeta(floatFlowChangedMeta)) followUp('float-flow') + const tabFrom = props.transaction.getMeta(tabReflowMeta) as number | undefined + if (tabFrom !== undefined && editor) { + const { doc: pmDoc } = editor.state + followUpAt('tab-wrap', pmDoc.resolve(Math.min(tabFrom, pmDoc.content.size)).index(0)) + } } editor?.on('transaction', onShrinkTr) return () => { + disposed = true + flushPaginationRef.current = () => {} if (timer) window.clearTimeout(timer) if (hfTimer) window.clearTimeout(hfTimer) document.fonts.removeEventListener('loadingdone', onLoadingDone) @@ -4324,7 +5839,6 @@ export function App() { editor?.off('update', onDocUpdate) editor?.off('transaction', onShrinkTr) } - // eslint-disable-next-line react-hooks/exhaustive-deps -- colMode/hasVertical/watermarkDirty feed the layout pass but must not retrigger this effect }, [ doc, section, @@ -4334,13 +5848,14 @@ export function App() { viewMode, readMode, blockMetaOf, + blockPlans, pageFootnotesOf, endnoteItems, editNote, canvasTop, effTopSingle, effBottomSingle, - singleFirstContentH, + singleVariantGeom, singleHfPx, hfHeightsOf, measureSingleFlow, @@ -4350,11 +5865,19 @@ export function App() { hfVariants, titlePg, evenOddHf, - sectionHfOverride, + mirrorMargins, + hfState, + hfSections, + hfResolveAt, + legacyBands, comments, + commentsUi.cards, + commentsUi.panel, // display-mode toggles reflow the text (No Markup hides deletions) and gate // the change bars, but dispatch no doc change: remeasure must follow them + // (revisions-in-balloons lifts deletions out of the flow the same way) revisionDisplay, + balloonMode, delSectBreaks, pendingWatermarkImage, ]) @@ -4369,24 +5892,117 @@ export function App() { const { $head } = editor.state.selection const topIndex = $head.depth > 0 ? $head.index(0) : 0 setActiveSection(sectionIndexAtBlock(editor.state.doc, sections, topIndex)) + if (!mirrorMargins) return + const layout = pageLayoutRef.current + const el = $head.depth > 0 ? editor.view.nodeDOM($head.before(1)) : null + // a fresh layout decoration redraws the node, so the measured element may be + // the block's previous DOM: fall back to its data-idx identity + const idx = el instanceof HTMLElement ? el.dataset.idx : undefined + const block = + layout && el + ? layout.blocks.find( + (b) => b.el === el || (idx !== undefined && String(b.docxIndex) === idx), + ) + : undefined + setCaretPageNo( + block && layout + ? (pageNumbers(layout.slices, layout.sections as SectionInfo[])[ + pageOfY(layout.slices, block.top) + ] ?? 1) + : 1, + ) } + locateCaretPageRef.current = locateSection locateSection() editor.on('selectionUpdate', locateSection) return () => { editor.off('selectionUpdate', locateSection) } - }, [editor, sections]) + }, [editor, sections, mirrorMargins]) // status-bar word count: the whole text is re-walked per doc, so pace it // (Word refreshes its count on idle too). A trailing throttle, not a // debounce: the streamed tail of a phased open would keep resetting one const [wordCount, setWordCount] = useState(0) + // Word's "selected/total" pair; 0 means no selection, so the bar falls back + // to the plain total. Cheap enough (a text range, not a document walk) to + // follow the selection directly rather than ride the throttle above. + const [selectionWordCount, setSelectionWordCount] = useState(0) + const [selectionHead, setSelectionHead] = useState(0) + // changes outside the body: notes and header / footer parts follow their models, + // textboxes and note anchors the document (revisionRanges refreshes with it) + const storyRevisions = useMemo(() => { + if (!editor || !doc) return [] + const pmDoc = editor.state.doc + return [ + ...collectHfRevisions(hfState), + ...collectTextboxRevisions(pmDoc), + ...collectNoteRevisions(pmDoc, 'footnote', footnotes), + ...collectNoteRevisions(pmDoc, 'endnote', endnotes), + ] + // revisionRanges: the throttled document-change trigger + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editor, doc, hfState, footnotes, endnotes, revisionRanges]) + storyRevisionsRef.current = storyRevisions + const allRevisions = useMemo( + () => (storyRevisions.length ? [...revisionRanges, ...storyRevisions] : revisionRanges), + [revisionRanges, storyRevisions], + ) + const revisionCount = allRevisions.length + const shownRevisions = useMemo( + () => filterRevisions(allRevisions, revisionFilter), + [allRevisions, revisionFilter], + ) + const reviewAuthors = useMemo(() => revisionAuthors(allRevisions), [allRevisions]) + const reviewEntries = useMemo( + () => (editor ? buildReviewEntries(editor.state.doc, shownRevisions, reviewAuthors) : []), + [editor, shownRevisions, reviewAuthors], + ) + const revisionAuthorStyle = useMemo( + () => revisionAuthorCss(reviewAuthors, revisionFilter.hiddenAuthors, movesByAuthor(tcOptions)), + [reviewAuthors, revisionFilter.hiddenAuthors, tcOptions], + ) + const isActiveEntry = (e: ReviewEntry) => + e.rev.story + ? e.rev.story.id === activeStoryId + : activeStoryId === null && selectionHead >= e.rev.from && selectionHead <= e.rev.to + const activeReviewIndex = useMemo( + () => reviewEntries.findIndex(isActiveEntry), + // eslint-disable-next-line react-hooks/exhaustive-deps + [reviewEntries, selectionHead, activeStoryId], + ) + // the AI panel list counts every pending change, untouched by the Show Markup filters + const allReviewEntries = useMemo( + () => (editor ? buildReviewEntries(editor.state.doc, allRevisions, reviewAuthors) : []), + [editor, allRevisions, reviewAuthors], + ) + const activeAllReviewIndex = useMemo( + () => allReviewEntries.findIndex(isActiveEntry), + // eslint-disable-next-line react-hooks/exhaustive-deps + [allReviewEntries, selectionHead, activeStoryId], + ) + useEffect(() => { + if (!editor) return + const refreshSelection = () => { + setSelectionWordCount(selectedWordCount(editor.state.doc, editor.state.selection)) + setSelectionHead(editor.state.selection.head) + // a caret move in the body ends the stop on a change outside it + if (lastStoryRef.current !== null) setActiveStoryId(null) + } + refreshSelection() + editor.on('selectionUpdate', refreshSelection) + return () => { + editor.off('selectionUpdate', refreshSelection) + } + }, [editor]) useEffect(() => { if (!editor) return let timer = 0 const refresh = () => { timer = 0 setWordCount(wordCountOfDoc(editor.state.doc)) + setRevisionRanges(doc ? revisionsOfDoc(editor.state.doc) : []) + setSelectionWordCount(selectedWordCount(editor.state.doc, editor.state.selection)) } const onUpdate = () => { if (!timer) timer = window.setTimeout(refresh, WORD_COUNT_THROTTLE_MS) @@ -4397,12 +6013,12 @@ export function App() { window.clearTimeout(timer) editor.off('update', onUpdate) } - }, [editor, docLoading]) + }, [editor, doc, docLoading]) /** Word word-count dialog: pages/lines estimated from the current layout */ const openStats = useCallback(() => { if (!editor) return - const text = editor.state.doc.textContent + const text = documentTextForWordCount(editor.state.doc) const pm = document.querySelector('.ProseMirror') const zoomFactor = zoom / 100 let lines = 0 @@ -4418,7 +6034,7 @@ export function App() { // inside table cells (descendants, not just top-level children) let paragraphs = 0 editor.state.doc.descendants((node) => { - if (node.isTextblock && node.textContent.trim() !== '') paragraphs++ + if (node.isTextblock && documentTextForWordCount(node).trim() !== '') paragraphs++ return true }) setStats({ @@ -4435,11 +6051,12 @@ export function App() { // Review > Editor, the Tools menu and Word's F7 all run the same AI proofread // behind the one-time whole-document-rewrite acknowledgement - const runAiProofread = useCallback(async () => { - if (localStorage.getItem(AI_REWRITE_ACK_KEY) !== '1') { - const confirmed = await appConfirmDialog({ message: t('ribbonAiRewriteConfirm') }) - if (!confirmed) return - } + const runAiProofread = useCallback(() => { + if ( + localStorage.getItem(AI_REWRITE_ACK_KEY) !== '1' && + !window.confirm(t('ribbonAiRewriteConfirm')) + ) + return localStorage.setItem(AI_REWRITE_ACK_KEY, '1') setShowAi(true) setAiPreset({ text: t('ribbonEditorPrompt'), nonce: Date.now(), autoRun: true }) @@ -4550,6 +6167,18 @@ export function App() { setFindFocusReplace((n) => n + 1) } } + // Go To: ⌥⌘G on the Mac (Word for Mac), Ctrl+G elsewhere (⇧⌘G stays Word Count) + if ( + (e.metaKey || e.ctrlKey) && + !e.shiftKey && + e.altKey === IS_MAC && + e.code === 'KeyG' && + doc + ) { + e.preventDefault() + setShowFind(true) + setFindFocusGoTo((n) => n + 1) + } // Word dialog shortcuts: Font ⌘D / Paragraph ⌥⌘M / Hyperlink ⌘K if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key === 'd' && doc && canEdit) { e.preventDefault() @@ -4563,7 +6192,10 @@ export function App() { e.preventDefault() setShowLinkModal(true) } - if (e.key === 'F9' && doc && editor?.isEditable) { + if (e.key === 'F9' && e.altKey && doc && editor) { + e.preventDefault() + toggleAllFieldCodes(editor.view) + } else if (e.key === 'F9' && doc && editor?.isEditable) { e.preventDefault() updateFields() } @@ -4619,7 +6251,8 @@ export function App() { ) { e.preventDefault() // textboxes have no heading nodes — same gate as the ribbon style gallery - if (!getActiveSubEditor()) applyParagraphStyle(editor, STYLE_KEYS[e.code]) + if (!getActiveSubEditor()) + applyParagraphStyle(editor, STYLE_KEYS[e.code], ribbonStylesRef.current) } // Word grow/shrink font ⇧⌘. / ⇧⌘, — via the ribbon closure so bursts stay coalesced if ( @@ -4702,7 +6335,7 @@ export function App() { // Track changes ⇧⌘E; forced by an editing restriction it cannot be turned off if ((e.metaKey || e.ctrlKey) && e.shiftKey && !e.altKey && e.code === 'KeyE' && doc) { e.preventDefault() - if (!trackChangesForced) setTrackChanges((v) => !v) + if (!isProtected && !trackChangesForced) toggleTrackChangesByUser() } // Word count ⇧⌘G if ((e.metaKey || e.ctrlKey) && e.shiftKey && !e.altKey && e.code === 'KeyG' && doc) { @@ -4754,6 +6387,8 @@ export function App() { insertField, runAiProofread, trackChangesForced, + isProtected, + toggleTrackChangesByUser, ]) // double-click an inline equation / click an equation block's edit button (ones with LaTeX source) → reopen the equation dialog for editing @@ -4788,6 +6423,9 @@ export function App() { case 'save-as': void save(true) break + case 'name-with-ai': + void nameWithAi() + break case 'undo': editor?.chain().focus().undo().run() break @@ -4795,25 +6433,31 @@ export function App() { editor?.chain().focus().redo().run() break case 'zoom-in': - setZoom((z) => Math.min(200, Math.round(z) + 10)) + setZoom((z) => stepDocsZoom(z, 1)) break case 'zoom-out': - setZoom((z) => Math.max(50, Math.round(z) - 10)) + setZoom((z) => stepDocsZoom(z, -1)) break case 'zoom-100': setZoom(100) break + case 'zoom-set': + if (payload) setZoom(clampDocsZoom(Number(payload))) + break case 'zoom-page-width': zoomFit('width') break case 'zoom-whole-page': zoomFit('page') break + case 'zoom-multi-page': + zoomFit('multi') + break case 'toggle-ai': setShowAi((v) => !v) break case 'toggle-dark': - setDarkPage((v) => !v) + updateDarkPage(!darkPageRef.current) break case 'insert-table': // Word semantics: the menu opens the Insert Table dialog (custom rows/cols) @@ -4853,14 +6497,58 @@ export function App() { case 'shortcuts': setShowShortcuts(true) break + case 'autocorrect-options': + setShowAutoCorrect(true) + break + case 'preferences': + setShowPreferences(true) + break + case 'table-gridlines': + toggleTableGridlines() + break + case 'table-insert-cells': + case 'table-delete-cells': + case 'table-split-cells': + case 'table-properties': + if (!editor || !doc || !editableOrToast(editor)) break + if (!ensureCaretInTable(editor.state, editor.view.dispatch)) { + showToast(t('ribbonNotInTable'), 'error') + break + } + setTableDialog( + command === 'table-insert-cells' + ? 'insertCells' + : command === 'table-delete-cells' + ? 'deleteCells' + : command === 'table-split-cells' + ? 'splitCells' + : 'properties', + ) + break + default: + if (command.startsWith('table-')) { + if (!editor || !doc) break + // selecting is fine on a read-only document; everything else edits + if (!command.startsWith('table-select-') && !editableOrToast(editor)) break + if (!ensureCaretInTable(editor.state, editor.view.dispatch)) { + showToast(t('ribbonNotInTable'), 'error') + break + } + editor.view.focus() + const cmd = TABLE_MENU_COMMANDS[command]?.(tableSectionWidthPx) + if (command === 'table-distribute-rows') distributeRowsEvenly(editor.view) + else if (cmd) cmd(editor.state, editor.view.dispatch) + } + break + // menu accelerators reach the strip / textbox editor that has focus, like the ribbon case 'bold': - editor?.chain().focus().toggleMark('bold').run() + ;(getActiveSubEditor() ?? editor)?.chain().focus().toggleMark('bold').run() break case 'italic': - editor?.chain().focus().toggleMark('italic').run() + ;(getActiveSubEditor() ?? editor)?.chain().focus().toggleMark('italic').run() break case 'underline': - editor?.chain().focus().toggleMark('underline').run() + ;(getActiveSubEditor() ?? editor)?.chain().focus().toggleMark('underline').run() break case 'align-left': align('left') @@ -4883,12 +6571,27 @@ export function App() { setFindFocusInput((n) => n + 1) } break + case 'replace': + if (doc) { + setShowFind(true) + setFindFocusReplace((n) => n + 1) + } + break + case 'goto': + if (doc) { + setShowFind(true) + setFindFocusGoTo((n) => n + 1) + } + break case 'export-pdf': void exportPdf() break case 'export-html': void exportHtml() break + case 'export-markdown': + void exportMarkdown() + break case 'export-images': void exportImages() break @@ -4904,68 +6607,141 @@ export function App() { openFile, openRecent, save, + nameWithAi, exportPdf, exportHtml, + exportMarkdown, exportImages, printDoc, zoomFit, openStats, startNewComment, runAiProofread, + toggleTableGridlines, + tableSectionWidthPx, + updateDarkPage, ]) - // Word: TOC entries jump on ⌘/Ctrl+click only; a plain click just places the - // caret. Resolve the bookmark anchor against the original block XML, fall - // back to matching the heading text. + // Resolve the bookmark anchor against the original block XML, fall back to + // matching the heading text of a TOC entry. + const jumpToAnchor = useCallback( + (anchor: string, tocTitle = '') => { + let target: Element | null = null + if (anchor && doc) { + const block = doc.parsed.blocks.find((b) => b.originalXml?.includes(`w:name="${anchor}"`)) + if (block && block.docxIndex !== null) { + target = document.querySelector(`.ProseMirror [data-idx="${block.docxIndex}"]`) + } + } + const title = tocTitle.replace(/\s+/g, '') + if (!target && title) { + target = + [ + ...document.querySelectorAll( + '.ProseMirror h1, .ProseMirror h2, .ProseMirror h3, .ProseMirror h4, .ProseMirror h5, .ProseMirror h6', + ), + ].find((h) => (h.textContent ?? '').replace(/\s+/g, '') === title) ?? null + } + target?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }, + [doc], + ) + + /** ⌘-click / Open Hyperlink: browser for http(s) (main-process openExternal), in-document jump for #bookmark */ + const openLink = useCallback( + (href: string, tocTitle = '') => { + const target = linkTarget(href) + if (target?.kind === 'external') window.open(target.url) + else if (target?.kind === 'anchor' || tocTitle) { + jumpToAnchor(target?.kind === 'anchor' ? target.name : '', tocTitle) + } + }, + [jumpToAnchor], + ) + + // Word: links and TOC entries jump on ⌘/Ctrl+click only; a plain click just + // places the caret. const onDocClick = useCallback( (e: ReactMouseEvent) => { + const tag = (e.target as HTMLElement).closest('.doc-comment-marker') as HTMLElement | null + if (tag?.dataset.commentId) { + setShowComments(true) + setCommentFocus({ id: tag.dataset.commentId, nonce: Date.now() }) + return + } const commentSpan = (e.target as HTMLElement).closest('.doc-comment') as HTMLElement | null if (commentSpan) { const ids = (commentSpan.dataset.commentIds ?? '').split(' ') - if (comments.some((c) => !c.done && ids.includes(c.id))) setShowComments(true) + const hit = comments.find((c) => !c.done && !c.parentId && ids.includes(c.id)) + if (hit) { + setShowComments(true) + setCommentFocus({ id: hit.id, nonce: Date.now() }) + } } + const mod = e.metaKey || e.ctrlKey // read-only display anchors are outside contenteditable and would navigate - // the renderer in place; Word semantics instead: plain click no-op, mod+click opens + // the renderer in place const a = (e.target as HTMLElement).closest('a[href]') as HTMLAnchorElement | null if (a) { e.preventDefault() - const href = a.getAttribute('href') ?? '' - if ((e.metaKey || e.ctrlKey) && /^https?:/i.test(href)) window.open(href) + if (mod) openLink(a.getAttribute('href') ?? '') + return } - if (!e.metaKey && !e.ctrlKey) return + if (!mod) return const line = (e.target as HTMLElement).closest('.doc-toc-line') as HTMLElement | null - if (!line) return - let target: Element | null = null - const anchor = line.dataset.tocAnchor - if (anchor && doc) { - const block = doc.parsed.blocks.find((b) => b.originalXml?.includes(`w:name="${anchor}"`)) - if (block && block.docxIndex !== null) { - target = document.querySelector(`.ProseMirror [data-idx="${block.docxIndex}"]`) - } - } - if (!target) { - const title = (line.dataset.tocTitle ?? '').replace(/\s+/g, '') - if (title) { - target = - [ - ...document.querySelectorAll( - '.ProseMirror h1, .ProseMirror h2, .ProseMirror h3, .ProseMirror h4, .ProseMirror h5, .ProseMirror h6', - ), - ].find((h) => (h.textContent ?? '').replace(/\s+/g, '') === title) ?? null - } - } - target?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + if (line) jumpToAnchor(line.dataset.tocAnchor ?? '', line.dataset.tocTitle ?? '') }, - [doc, comments], + [comments, openLink, jumpToAnchor], ) /** Editor right-click menu, only inside the document body */ const onDocContextMenu = useCallback( (e: ReactMouseEvent) => { - if (readMode || isProtected) return - if (!(e.target as HTMLElement).closest('.doc-page')) return - e.preventDefault() + if (readMode || (isProtected && !commentsAllowed)) return + const bubble = (e.target as HTMLElement).closest( + '.comment-bubble, .comment-balloon, .doc-del-marker', + ) as HTMLElement | null + if (!bubble && !(e.target as HTMLElement).closest('.doc-page')) return + // no preventDefault: Blink must still request a context menu so the main + // process receives the misspelling data it relays to this menu + const seq = ++ctxSeq.current + window.desktop.claimContextMenu?.(seq) + // keyboard-invoked (Shift+F10 / menu key): no pointer, open at the caret + const fromKeyboard = e.clientX === 0 && e.clientY === 0 + let x = e.clientX + let y = e.clientY + let pos: number | null = null + if (fromKeyboard && editor) { + pos = editor.state.selection.head + const c = editor.view.coordsAtPos(pos) + x = c.left + y = c.bottom + } let imageSrc: string | null = null + if (bubble && editor) { + // a balloon stands for its anchor: the menu acts on that change or thread + const commentId = bubble.dataset.commentId ?? null + const at = commentId + ? commentAnchors(editor.state.doc).find((a) => a.id === commentId)?.from + : bubble.classList.contains('doc-del-marker') + ? editor.view.posAtDOM(bubble, 0) + : Number(bubble.dataset.pos) + if (at != null && Number.isFinite(at)) { + const size = editor.state.doc.content.size + pos = Math.min(at, size) + // Word selects the whole change so New Comment anchors on it + const end = Math.min(Number(bubble.dataset.end), size) + const doc = editor.state.doc + // between() snaps ends that fall outside a textblock (deleted paragraph marks, tables) + const sel = + Number.isFinite(end) && end > pos + ? TextSelection.between(doc.resolve(pos), doc.resolve(end)) + : TextSelection.near(doc.resolve(pos), 1) + editor.view.dispatch(editor.state.tr.setSelection(sel)) + } + setCtxMenu({ x, y, seq, pos, imageSrc, link: null, commentId, balloon: true }) + return + } // Word behavior: right-clicking outside the selection moves the cursor there first (menu items act on the clicked block) if (editor) { // Right-clicking directly on an image / floating object selects it as a @@ -4977,14 +6753,23 @@ export function App() { // behind-text pictures live under the text layer's hit box: when the // press hits no glyph, fall through to the picture painted below // (Word selects it there too) - if (!protectedEl) { - const under = findFloatImageAt(e.clientX, e.clientY) + if (!protectedEl && !fromKeyboard) { + const under = findFloatImageAt(x, y) if (under) protectedEl = under.closest('.doc-protected') as HTMLElement | null } const img = (e.target as HTMLElement).closest('img') ?? protectedEl?.querySelector('img') imageSrc = img?.getAttribute('src') || null let selectedNode = false - if (protectedEl) { + // a floating run picture is its own node, outside any .doc-protected block + const runPic = + !protectedEl && !fromKeyboard ? runPictureAtEvent(editor.view, e.target, x, y) : null + if (runPic) { + editor.view.dispatch( + editor.state.tr.setSelection(NodeSelection.create(editor.state.doc, runPic.pos)), + ) + imageSrc = runPic.img.getAttribute('src') || imageSrc + selectedNode = true + } else if (protectedEl) { const dom = editor.view.nodeDOM.bind(editor.view) let nodePos = -1 editor.state.doc.descendants((node, pos) => { @@ -4998,9 +6783,10 @@ export function App() { selectedNode = true } } - if (!selectedNode) { - const hit = editor.view.posAtCoords({ left: e.clientX, top: e.clientY }) + if (!selectedNode && !fromKeyboard) { + const hit = editor.view.posAtCoords({ left: x, top: y }) if (hit) { + pos = hit.pos const { from, to } = editor.state.selection if (hit.pos < from || hit.pos > to) { editor.commands.setTextSelection(hit.pos) @@ -5008,27 +6794,242 @@ export function App() { } } } - setCtxMenu({ x: e.clientX, y: e.clientY, imageSrc }) + const anchorEl = (e.target as HTMLElement).closest('a[href]') as HTMLAnchorElement | null + const tocLine = (e.target as HTMLElement).closest('.doc-toc-line') as HTMLElement | null + const link = anchorEl + ? { href: anchorEl.getAttribute('href') ?? '' } + : tocLine + ? { + href: `#${tocLine.dataset.tocAnchor ?? ''}`, + toc: true, + tocTitle: tocLine.dataset.tocTitle ?? '', + } + : null + setCtxMenu({ x, y, seq, pos, imageSrc, link }) }, - [readMode, isProtected, editor], + [readMode, isProtected, commentsAllowed, editor], ) + /** double-click on a page-gap strip: edit that page's header/footer in place */ + const startGapHfEdit = (el: HTMLElement) => { + if (readMode || isProtected || hfEditorRef.current) return + const kind = el.dataset.hfKind as 'header' | 'footer' | undefined + if (!kind) return + const si = Number(el.dataset.hfSection ?? 0) + const variant = (el.dataset.hfVariant as HfView | undefined) ?? 'default' + const pageIdx = Number(el.dataset.hfPageIdx ?? 0) + const page = el.dataset.hfPage ?? '1' + const resolved = hfResolveAt(si, kind, variant) + const value = resolved.value ?? { text: '' } + let host = el + let saved = Array.from(el.childNodes) + el.replaceChildren() + el.classList.add('page-hf-editing') + let committed = false + const handle = mountHfEditor(el, { + value, + pageNo: page, + pageTotal: el.dataset.hfTotal ?? '1', + sectionPages: el.dataset.hfSectionPages, + spellcheck: spellcheckActive, + onCommit: (next) => { + committed = true + // the ribbon may have unlinked the section since the editor opened + commitHfAtRef.current(si, kind, variant, next, resolved.owner) + }, + onExit: () => { + host.classList.remove('page-hf-editing') + if (!committed) host.replaceChildren(...saved) + if (hfEditorRef.current === handle) hfEditorRef.current = null + setHfEditing(null) + }, + }) + hfEditorRef.current = handle + setHfEditing({ kind, section: si, variant, pageIdx, owner: resolved.owner }) + // a re-pagination (Link to Previous, distance, another strip's commit) rebuilds the + // gap widgets: the open editor moves into the strip's new element instead of dying + const selector = `.page-gap-hf.page-hf-${kind}[data-hf-page-idx="${pageIdx}"]` + const watch = () => { + if (hfEditorRef.current !== handle) return + if (!host.isConnected) { + const next = document.querySelector(selector) + if (!next) { + handle.exit() + return + } + saved = Array.from(next.childNodes) + next.replaceChildren(handle.editor.view.dom) + next.classList.add('page-hf-editing') + host = next + // detaching dropped the focus with the old widget + handle.editor.view.focus() + } + requestAnimationFrame(watch) + } + requestAnimationFrame(watch) + } + const startGapHfEditRef = useRef(startGapHfEdit) + startGapHfEditRef.current = startGapHfEdit + const applyStoryRevisionsRef = useRef(applyStoryRevisions) + applyStoryRevisionsRef.current = applyStoryRevisions + const commitHfAtRef = useRef(commitHfAt) + commitHfAtRef.current = commitHfAt + const firstPageNoText = formatPageNumber( + sections[0]?.pageNumberStart ?? 1, + sections[0]?.pageNumberFmt, + ) + const lastPageNoText = lastPageNo?.text ?? String(pageInfo.total) + const lastPageIdx = Math.max(0, pageInfo.total - 1) + /** open the strip editor for `kind` on page `pageIdx` (default: the document's first header / last footer) */ + const openHfEditor = (kind: 'header' | 'footer', pageIdx?: number) => { + hfEditorRef.current?.exit() + const edge = + pageIdx === undefined || (kind === 'header' ? pageIdx === 0 : pageIdx === lastPageIdx) + const el = edge + ? null + : document.querySelector( + `.page-gap-hf.page-hf-${kind}[data-hf-page-idx="${pageIdx}"]`, + ) + if (el) startGapHfEdit(el) + else setHfAreaEditRequest({ kind, nonce: Date.now() }) + } + const openHfEditorRef = useRef(openHfEditor) + openHfEditorRef.current = openHfEditor + // floating header pictures: moved and resized in header editing, reached from + // the body by a double-click (Word) + const hfFloatEditRef = useRef({ open: openHfEditor, commit: commitHfPictureAt, editable: false }) + hfFloatEditRef.current = { + open: openHfEditor, + commit: commitHfPictureAt, + editable: !readMode && !isProtected, + } + useEffect(() => { + const root = scrollContainerRef.current + if (!root) return + return installHfFloatEdit({ + root, + editing: () => hfEditingRef.current != null, + editable: () => hfFloatEditRef.current.editable, + open: (pageIdx) => hfFloatEditRef.current.open('header', pageIdx), + commit: (t, edit) => + hfFloatEditRef.current.commit(t.section, 'header', t.variant, t.drawing, edit), + }) + }, []) + const toggleEvenOddHf = (on: boolean) => { + hfEditorRef.current?.exit() + setEvenOddHf(on) + setEvenOddHfDirty(true) + setStatus(on ? t('appEvenOddOn') : t('appEvenOddOff')) + } + const onHfAction = (action: HfAction) => { + const cur = hfEditing + const sub = hfEditorRef.current?.editor + // the ribbon button took the focus; Word keeps the caret in the strip + const refocus = () => { + if (hfEditorRef.current) sub?.commands.focus() + } + const sectionOf = (kind: 'header' | 'footer') => + cur?.section ?? (kind === 'header' ? 0 : lastSectionIdx) + const variant = cur?.variant ?? 'default' + switch (action.type) { + case 'close': + hfEditorRef.current?.exit() + break + case 'goto': + case 'edit': + openHfEditor(action.kind, cur?.pageIdx) + break + case 'remove': + hfEditorRef.current?.exit() + commitHfAt(sectionOf(action.kind), action.kind, variant, { text: '' }) + break + case 'pageNumber': + hfEditorRef.current?.exit() + commitHfAt(sectionOf(action.kind), action.kind, variant, hfPageNumberOnly(action.align)) + break + case 'pageNumberHere': + if (sub) insertHfField(sub, 'PAGE') + break + case 'numPages': + if (sub) insertHfField(sub, 'NUMPAGES') + break + case 'pageNumberFormat': + openPgNumModal() + break + case 'removePageNumbers': + hfEditorRef.current?.exit() + for (const kind of ['header', 'footer'] as const) { + const v = hfValueAt(sectionOf(kind), kind, variant) + if (hfHasPageField(v)) commitHfAt(sectionOf(kind), kind, variant, hfWithoutPageMarks(v!)) + } + break + case 'field': + if (sub) insertHfInstrField(sub, action.instr, fieldValue(action.instr)) + break + case 'picture': + if (sub) { + void (async () => { + const picked = await window.desktop.pickImage() + if (!picked || !hfEditorRef.current) return + const dataUrl = `data:${picked.mime};base64,${picked.base64}` + const natural = await imageSizeOf(dataUrl) + insertHfPicture(sub, { dataUrl, widthPx: natural.width, heightPx: natural.height }) + })() + } + break + case 'linkToPrevious': + if (cur) setHfLink(cur.section, cur.kind, cur.variant, action.on) + if (!action.on) refocus() + break + case 'distance': + setHfDistance(cur?.section ?? activeSection, action.kind, action.twips) + refocus() + break + case 'titlePg': + toggleTitlePg(action.on) + refocus() + break + case 'evenOddHf': + toggleEvenOddHf(action.on) + refocus() + break + } + } + const hfEditingInfo: HfEditingInfo | null = hfEditing + ? { + kind: hfEditing.kind, + section: hfEditing.section, + sectionCount: hfSections.length, + variant: hfEditing.variant, + linked: hfLinkedAt(hfEditing.section, hfEditing.kind, hfEditing.variant), + headerDistTwips: (hfSections[hfEditing.section]?.settings ?? section)?.headerDist ?? 720, + footerDistTwips: (hfSections[hfEditing.section]?.settings ?? section)?.footerDist ?? 720, + titlePg, + evenOddHf, + } + : null + const hfSectionLabel = (kind: 'header' | 'footer', si: number) => + hfSections.length > 1 + ? t(kind === 'header' ? 'appHfHeaderSectionTag' : 'appHfFooterSectionTag', { n: si + 1 }) + : null + + // Word: double-clicking a picture only selects it (the press already did); + // the image viewer stays on the context menu const onDocDoubleClick = useCallback((e: ReactMouseEvent) => { const target = e.target as HTMLElement if (!target.closest('.doc-page')) return - // behind-text pictures sit under the text layer; same fall-through as the right-click path - const img = - target.closest('img') ?? findFloatImageAt(e.clientX, e.clientY)?.querySelector('img') - const src = img?.getAttribute('src') - if (src) setViewImage(src) + const gap = target.closest('.page-gap-hf') as HTMLElement | null + if (gap) startGapHfEditRef.current(gap) }, []) // hermesoffice CLI (`open --block`, `selection`): the shell evaluates this hook useEffect(() => { - ;(window as unknown as Record).__genofficeControl = (req: ControlRequest) => + ;(window as unknown as Record).__hermesofficeControl = (req: ControlRequest) => handleDocsControl(req, editor, doc !== null) }) + useEffect(() => (editor ? installSelectionBar(editor) : undefined), [editor]) + // e2e/automation hook: lets tests drive open/edit/save without native dialogs useEffect(() => { ;(window as unknown as Record).__aidocs = { @@ -5038,16 +7039,62 @@ export function App() { getStatus: () => status, exportPdfTo: (path: string) => exportPdf(path), exportHtmlTo: (path: string) => exportHtml(path), + exportMarkdownTo: (path: string) => exportMarkdown(path), + subEditor: () => getActiveSubEditor(), } - }, [editor, openRecent, save, status, exportPdf, exportHtml]) - + }, [editor, openRecent, save, status, exportPdf, exportHtml, exportMarkdown]) + + // Pending style definitions (Styles pane / AI define_style) show before the + // save writes them: styles.xml is re-resolved with the patches applied and + // the live map feeds the gallery, the pane, the format state and the doc CSS. + const [liveStyles, setLiveStyles] = useState<{ + parsed: ParsedDocFull + styles: Map + } | null>(null) + useEffect(() => { + const parsed = doc?.parsed + const upserts = Object.values(styleUpserts) + if (!parsed || upserts.length === 0) { + liveStylesRef.current = null + setLiveStyles(null) + return + } + let cancelled = false + previewFontSettings(parsed, upserts, defaultFonts) + .then((r) => { + // a document switch or a save re-parse while resolving: their open path already set fresh styles + if (cancelled || fileCtxRef.current.doc?.parsed !== parsed) return + liveStylesRef.current = { parsed, styles: r.styles } + setLiveStyles({ parsed, styles: r.styles }) + editor.storage.listNumbering.styles = r.styles + setDocCss(docStyleCss({ ...parsed, styles: r.styles })) + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, [doc, styleUpserts, defaultFonts, editor]) // shallow-stable snapshot of every editor read the ribbon displays: caret moves // that change none of it keep the reference, so the memoized Ribbon skips const formatState = useShallowStable( - computeFormatState(editor, doc?.parsed.styles, doc?.parsed.docDefaults), + computeFormatState( + editor, + liveStyles?.parsed === doc?.parsed ? liveStyles?.styles : doc?.parsed.styles, + doc?.parsed.docDefaults, + ), ) - const ribbonStyles = useMemo(() => (doc ? new Map(doc.parsed.styles) : undefined), [doc]) + const ribbonStyles = useMemo( + () => + doc + ? liveStyles?.parsed === doc.parsed + ? liveStyles.styles + : new Map(doc.parsed.styles) + : undefined, + [doc, liveStyles], + ) + const ribbonStylesRef = useRef(ribbonStyles) + ribbonStylesRef.current = ribbonStyles /** every function prop of the memoized Ribbon, with stable identities (dispatches into the latest render's closures) */ // ---- selection-scoped AI edit queue ---- @@ -5098,7 +7145,7 @@ export function App() { () => ({ list: () => reviewCtxRef.current.comments, reply: (parentId, text) => - replyToCommentImpl(reviewCtxRef.current, parentId, text, AI_REVISION_AUTHOR), + replyToCommentImpl(reviewCtxRef.current, parentId, text, aiRevisionAuthor()), resolve: (id) => { const ctx = reviewCtxRef.current if (!ctx.comments.some((c) => c.id === id)) return false @@ -5110,7 +7157,7 @@ export function App() { reviewCtxRef.current, range, text, - meta.author ?? AI_REVISION_AUTHOR, + meta.author ?? aiRevisionAuthor(), meta.initials, ), remove: (id) => { @@ -5159,6 +7206,15 @@ export function App() { ) return true }, + replace: (kind, id, next) => { + const current = list(kind) + if (!current.some((n) => n.id === id)) return false + commit( + kind, + current.map((n) => (n.id === id ? next : n)), + ) + return true + }, protectedMarkBlock: (kind, id) => { const { editor, doc } = reviewCtxRef.current if (!editor || !doc) return null @@ -5188,11 +7244,7 @@ export function App() { valueOf(kind: 'header' | 'footer', view: HfView): HeaderFooter | null { const pending = this.overlay.get(`${kind}:${view}`) if (pending) return pending - if (view === 'first') - return kind === 'header' ? hfVariants.headerFirst : hfVariants.footerFirst - if (view === 'even') return kind === 'header' ? hfVariants.headerEven : hfVariants.footerEven - if (multiHf) return sectionHfValue(kind) - return kind === 'header' ? header : footer + return hfValueAt(kind === 'header' ? 0 : lastSectionIdx, kind, view) }, commit: commitHf, titlePg, @@ -5207,53 +7259,26 @@ export function App() { // between renders, so a define_style followed by applyStyle must see the pending entry. const aiStyleUpsertsRef = useRef(styleUpserts) aiStyleUpsertsRef.current = styleUpserts - const onFontSettings = useCallback((scope: string, patch: DefaultFonts): Promise => { - const task = (async () => { - const ctx = fileCtxRef.current - const generation = currentDocGeneration() - if (!ctx.doc || !ctx.editor?.isEditable) return - const upserts = { ...aiStyleUpsertsRef.current } - let defaults = ctx.defaultFonts - if (scope === 'defaults') defaults = { ...defaults, ...patch } - else { - const styleId = scope.slice(6) - const style = ctx.doc.parsed.styles.get(styleId) - if (!style || (style.type !== 'paragraph' && style.type !== 'character')) return - upserts[styleId] = { - ...upserts[styleId], - styleId, - rPr: { ...upserts[styleId]?.rPr, ...patch }, - } - } - fontSettingsVersionRef.current++ - const resolved = await previewFontSettings(ctx.doc.parsed, Object.values(upserts), defaults) - // In-flight saves see the version bump; new saves wait for this task. - if (currentDocGeneration() !== generation || !fileCtxRef.current.doc) return - const latestDoc = fileCtxRef.current.doc - const parsed = { ...latestDoc.parsed, ...resolved } - aiStyleUpsertsRef.current = upserts - setStyleUpserts(upserts) - setDefaultFonts(defaults) - ctx.editor.storage.listNumbering.styles = resolved.styles - ctx.editor.storage.listNumbering.docDefaults = resolved.docDefaults - setDocCss(docStyleCss(parsed)) - const nextDoc = { ...latestDoc, parsed } - setDoc((prev) => (prev ? { ...prev, parsed } : prev)) - // Save/recovery callers resuming before React commits need the same snapshot. - fileCtxRef.current = { - ...fileCtxRef.current, - doc: nextDoc, - styleUpserts: upserts, - defaultFonts: defaults, - } - ctx.dirtyRef.current = true - })() - fontSettingsPendingRef.current = task - return task.finally(() => { - if (fontSettingsPendingRef.current === task) fontSettingsPendingRef.current = null - }) + /** queue a style definition for the next save; shared by the AI tools and the Styles pane */ + const upsertStyleDef = useCallback((up: StyleUpsert): string | null => { + const ctx = fileCtxRef.current + if (!ctx.doc) return 'no document is open' + const prev = aiStyleUpsertsRef.current[up.styleId] + const next = { + ...aiStyleUpsertsRef.current, + [up.styleId]: prev + ? { + ...prev, + ...up, + pPr: up.pPr || prev.pPr ? { ...prev.pPr, ...up.pPr } : undefined, + rPr: up.rPr || prev.rPr ? { ...prev.rPr, ...up.rPr } : undefined, + } + : up, + } + aiStyleUpsertsRef.current = next + ctx.setStyleUpserts(next) + return null }, []) - const aiDocExtras = useMemo( () => ({ styles: { @@ -5292,51 +7317,23 @@ export function App() { } return [...out.values()] }, - upsert: (up) => { - const ctx = fileCtxRef.current - if (!ctx.doc) return 'no document is open' - const prev = aiStyleUpsertsRef.current[up.styleId] - const next = { - ...aiStyleUpsertsRef.current, - [up.styleId]: prev - ? { - ...prev, - ...up, - pPr: up.pPr || prev.pPr ? { ...prev.pPr, ...up.pPr } : undefined, - rPr: up.rPr || prev.rPr ? { ...prev.rPr, ...up.rPr } : undefined, - } - : up, - } - aiStyleUpsertsRef.current = next - ctx.setStyleUpserts(next) - return null - }, + upsert: upsertStyleDef, }, watermark: { current: () => fileCtxRef.current.watermark, set: (spec) => { - const ctx = fileCtxRef.current - if (!ctx.doc) return 'no document is open' - if (spec && 'image' in spec) { - ctx.setWatermark(null) - ctx.setWatermarkStyle(null) - ctx.setWatermarkPicture(spec) - } else if (spec) { - const { text, ...style } = spec - ctx.setWatermark(text) - ctx.setWatermarkStyle(Object.keys(style).length > 0 ? style : null) - ctx.setWatermarkPicture(null) - } else { - ctx.setWatermark(null) - ctx.setWatermarkStyle(null) - ctx.setWatermarkPicture(null) - } - ctx.setWatermarkDirty(true) + if (!fileCtxRef.current.doc) return 'no document is open' + applyWatermark(spec) return null }, }, + revisionsLocked: isProtected || trackChangesForced, + storyRevisions: { + list: () => storyRevisionsRef.current, + apply: (targets, mode, all) => applyStoryRevisionsRef.current(targets, mode, all), + }, }), - [], + [upsertStyleDef, applyWatermark, isProtected, trackChangesForced], ) const aiHfAccess = useMemo( @@ -5513,11 +7510,32 @@ export function App() { const ribbonActions = useStableCallbacks({ allocateNumId: (kind: 'bullet' | 'ordered') => allocateListNumId(kind), - createListDef: (levels: CustomNumberingLevel[]) => createCustomListDef(levels), + createListDef: (levels: CustomNumberingLevel[]) => { + setListRecentsEpoch((n) => n + 1) + return createCustomListDef(levels) + }, + onListDialog: (kind: ListDialogKind) => setListDialog(kind), onParagraphDialog: () => setShowParaDialog(true), + onPasteDefaults: () => setShowAutoCorrect(true), + onTableDialog: (kind: TableDialogKind) => { + if ( + editor && + editableOrToast(editor) && + ensureCaretInTable(editor.state, editor.view.dispatch) + ) + setTableDialog(kind) + }, + onToggleTableGridlines: toggleTableGridlines, onOpen: () => void openFile(), onSave: () => void save(false), onSaveAs: () => void save(true), + onConvert: (format: ConvertFormat) => { + if (format === 'pdf') void exportPdf() + else if (format === 'html') void exportHtml() + else if (format === 'md') void exportMarkdown() + else exportImages() + }, + onNameWithAi: () => void nameWithAi(), onToggleAi: () => setShowAi((v) => !v), onSection: (next: SectionSettings) => { // layout applies to the cursor's section; the final section's sectPr goes through SaveOptions.section (also drives canvas geometry) @@ -5532,18 +7550,53 @@ export function App() { setStatus(t('appSectionSettingsApplied', { n: activeSection + 1 })) } }, + onPageBorderAll: (props: SectionSettings['pageBorderProps'] | null) => { + const bordered = ({ pageBorderProps: _drop, ...s }: SectionSettings): SectionSettings => + props ? { ...s, pageBorder: true, pageBorderProps: props } : { ...s, pageBorder: false } + setSections((prev) => prev.map((s) => ({ ...s, settings: bordered(s.settings) }))) + if (section) { + setSection(bordered(section)) + setSectionDirty(true) + } + const earlier = sections.slice(0, -1).map((_, i) => i) + if (earlier.length > 0) setSectionsDirty((d) => [...new Set([...d, ...earlier])]) + }, + onPaperSizeAll: ( + portraitW: number, + portraitH: number, + orientation: SectionSettings['orientation'], + ) => { + const sized = (s: SectionSettings): SectionSettings => + orientation === 'landscape' + ? { ...s, orientation, pageWidth: portraitH, pageHeight: portraitW } + : { ...s, orientation, pageWidth: portraitW, pageHeight: portraitH } + setSections((prev) => prev.map((s) => ({ ...s, settings: sized(s.settings) }))) + if (section) { + setSection(sized(section)) + setSectionDirty(true) + } + const earlier = sections.slice(0, -1).map((_, i) => i) + if (earlier.length > 0) setSectionsDirty((d) => [...new Set([...d, ...earlier])]) + }, + onMirrorMargins: (on: boolean) => { + if (on === mirrorMargins) return + setMirrorMargins(on) + setMirrorMarginsDirty(true) + }, onInsertSectionBreak: (type: 'nextPage' | 'continuous' | 'evenPage' | 'oddPage') => insertSectionBreak(type), onPageColor: (next: string | null) => { setPageColor(next) setPageColorDirty(true) }, - onWatermark: (next: string | null) => { - setWatermark(next) - setWatermarkDirty(true) - setWatermarkStyle(null) - setWatermarkPicture(null) - setStatus(next ? t('appWatermarkSet', { text: next }) : t('appWatermarkRemoved')) + onWatermark: (next: Watermark | null) => { + applyWatermark(next) + const text = typeof next === 'string' ? next : next && 'text' in next ? next.text : null + setStatus( + next + ? t('appWatermarkSet', { text: text ?? t('ribbonWatermarkPicture') }) + : t('appWatermarkRemoved'), + ) }, onThemeFonts: (fonts: ThemeFonts) => { setThemeFonts(fonts) @@ -5556,8 +7609,8 @@ export function App() { setStatus(t('appThemeColorsApplied', { name: colors.name ?? '' })) }, onInkTool: setInkTool, - onInkPen: setInkPen, - onInkHighlighter: setInkHighlighter, + onInkPresets: setInkPresets, + onInkActivate: setInkActiveId, onInkClearAll: clearInks, onInsertNote: insertNote, onAddSource: (source: SourceInfo) => { @@ -5568,41 +7621,60 @@ export function App() { headingPages, onZoom: setZoom, onZoomFit: zoomFit, - onDarkPage: setDarkPage, + onZoomDialog: () => setShowZoomDialog(true), + onDarkPage: updateDarkPage, onAiPreset: (text: string) => { // Word's Editor / Translate start working as soon as they're clicked setShowAi(true) setAiPreset({ text, nonce: Date.now(), autoRun: true }) }, - onHeader: (next: HeaderFooter) => { - setHeader(next) - setHeaderDirty(true) - }, + onHeader: (next: HeaderFooter) => commitHfAt(0, 'header', headerAreaView, next), onPageNumFormat: openPgNumModal, onInsertField: insertField, - onFooter: (next: HeaderFooter) => { - setFooter(next) - setFooterDirty(true) - }, - onTitlePg: toggleTitlePg, - onEvenOddHf: (on: boolean) => { - setEvenOddHf(on) - setEvenOddHfDirty(true) - setHfView(on ? 'even' : 'default') - setStatus(on ? t('appEvenOddOn') : t('appEvenOddOff')) - }, + onPickMergeDataSource: () => void pickMergeDataSource(), + onMergeMailings: mergeMailings, + onFooter: (next: HeaderFooter) => commitHfAt(lastSectionIdx, 'footer', footerAreaView, next), + onHfAction: (action: HfAction) => onHfAction(action), + onHfEdit: (kind: 'header' | 'footer') => openHfEditor(kind), onShowMarks: setShowMarks, onShowRuler: setShowRuler, onShowNav: setShowNav, - onShowComments: () => setShowComments(true), + onShowComments: () => toggleShowComments(), + onToggleComments: () => toggleShowComments(), onNewComment: startNewComment, - onTrackChanges: setTrackChanges, - onSpellcheck: setSpellcheck, - onRevisionDisplay: setRevisionDisplay, - onAcceptRevision: (all: boolean) => handleRevision('accept', all), - onRejectRevision: (all: boolean) => handleRevision('reject', all), + onDeleteComment: () => deleteCommentAtCaretImpl(reviewCtxRef.current), + onDeleteAllComments: (resolvedOnly: boolean) => + deleteAllCommentsImpl(reviewCtxRef.current, resolvedOnly), + onGotoComment: (dir: 1 | -1) => gotoCommentImpl(reviewCtxRef.current, dir), + onTrackChanges: setTrackChangesByUser, + onSpellcheck: (on: boolean) => { + if (largeDocSpellOff) setLargeDocSpellOff(false) + setSpellcheck(on) + }, + onRevisionDisplay: (mode: RevisionDisplayMode) => setRevisionDisplay(mode), + onAcceptRevision: (scope: RevisionScope) => handleRevision('accept', scope), + onRejectRevision: (scope: RevisionScope) => handleRevision('reject', scope), + onRevisionFilter: setRevisionFilter, + onToggleReviewPane: () => setShowReviewPane((v) => !v), onGotoRevision: (dir: 1 | -1) => { - if (editor) gotoRevision(editor, dir) + if (!editor) return + const body = filterRevisions(collectRevisions(editor.state.doc), revisionFilterRef.current) + const stories = filterRevisions(storyRevisionsRef.current, revisionFilterRef.current) + const ranges: RevisionRange[] = [...body, ...stories] + if (ranges.length === 0) return + // after a stop outside the body the walk continues from that entry + const at = ranges.findIndex((r) => r.story && r.story.id === lastStoryRef.current) + setActiveStoryId(null) + let target: RevisionRange | undefined + if (at >= 0) target = ranges[(at + dir + ranges.length) % ranges.length] + else { + const anchor = editor.state.selection.from + target = + dir === 1 + ? (body.find((r) => r.from > anchor) ?? stories[0] ?? body[0]) + : ([...body].reverse().find((r) => r.from < anchor) ?? ranges[ranges.length - 1]) + } + if (target) showRevision(target) }, onProtectDoc: () => setShowProtectDialog(true), onCompare: () => void compareWithFile(), @@ -5613,18 +7685,119 @@ export function App() { onPagePreview: () => setShowPagePreview(true), }) + // the panel closes; Contextual cards stay unless the panel was the List view itself const closeCommentsPanel = useCallback(() => { - setShowComments(false) + setCommentPanelOpen(false) + setCommentFocus(null) cancelNewComment() - }, [cancelNewComment]) + if (commentsMode === 'list') setShowComments(false) + }, [cancelNewComment, commentsMode]) + const toggleShowComments = useCallback(() => { + if (showComments) { + setCommentPanelOpen(false) + setCommentFocus(null) + setCommentPopup(null) + cancelNewComment() + } + setShowComments(!showComments) + }, [showComments, cancelNewComment]) + + // the popped-up thread closes on Escape or a click outside it and its icon + useEffect(() => { + if (!commentPopup) return + const onDown = (e: PointerEvent) => { + const el = e.target as Element | null + if (!el?.closest('.comment-bubble.comment-popup, .comment-balloon')) setCommentPopup(null) + } + const onKey = (e: globalThis.KeyboardEvent) => { + if (e.key === 'Escape') setCommentPopup(null) + } + document.addEventListener('pointerdown', onDown, true) + document.addEventListener('keydown', onKey, true) + return () => { + document.removeEventListener('pointerdown', onDown, true) + document.removeEventListener('keydown', onKey, true) + } + }, [commentPopup]) + useEffect(() => { + setCommentPopup(null) + }, [commentsUi.cards, commentsUi.panel]) + + // selection / popup changes only restyle the margin layer: redraw it without a pagination pass + useEffect(() => { + if (!editor || !doc) return + const pm = document.querySelector('.editor-scroll .ProseMirror') as HTMLElement | null + const wrap = pm?.closest('.page-wrap') as HTMLElement | null + if (!pm || !wrap) return + syncMarginAnnotations( + wrap, + pm, + comments, + zoom / 100, + doc.parsed.blocks, + editor.view, + marginHandlersRef.current, + ) + // only the flags on the root matter here; the pagination pass owns every other input + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [commentFocus?.id, commentPopup]) + + // Simple Markup overlay callbacks (the overlay is rebuilt per pagination + // frame; a stable object keeps the DOM listeners on the current editor) + const marginHandlersRef = useRef({}) + marginHandlersRef.current = { + onChangeBar: (pos) => { + setRevisionDisplay('all') + if (!editor) return + // the bar sits on the change's own line, so it is already in view: just + // select it; row / cell bars carry a node position, so snap to the nearest text + const { doc, tr } = editor.state + const $pos = doc.resolve(Math.min(pos, doc.content.size)) + editor.view.dispatch(tr.setSelection(TextSelection.near($pos, 1)).setMeta(TRACK_IGNORE, true)) + }, + onCommentBalloon: (id) => { + if (iconClickTarget(commentsUi) === 'focus') setCommentFocus({ id, nonce: Date.now() }) + else setCommentPopup(id) + }, + onCommentSelect: (id) => setCommentFocus({ id, nonce: Date.now() }), + onCommentReply: (id, text) => replyToComment(id, text), + onCommentResolve: resolveComment, + onCommentEdit: editComment, + onCommentDelete: deleteComment, + } const hasDoc = !!doc + // a balloon names the change starting at its anchor; a caret may sit on a boundary shared by two + const revisionsAtCtx = (all: RevisionRange[], pos: number, balloon: boolean) => { + const hits = revisionsInSelection(all, pos, pos) + if (!balloon) return hits + const own = hits.filter((r) => r.from <= pos && pos < r.to) + return own.length ? own : hits + } + const ctxCommentId = useMemo(() => { + if (!ctxMenu || !editor) return null + const ids = ctxMenu.commentId + ? [ctxMenu.commentId] + : commentIdsAt(editor.state, ctxMenu.pos ?? editor.state.selection.from) + const open = ids + .map((id) => comments.find((c) => c.id === id)) + .filter((c): c is CommentInfo => !!c && !c.done) + const root = open.find((c) => !c.parentId) ?? open[0] + return root ? (root.parentId ?? root.id) : null + }, [ctxMenu, editor, comments]) + // the context menu acts on the document as it is now, not the throttled pane snapshot + const liveRevisions = useMemo( + () => (ctxMenu && editor ? collectRevisions(editor.state.doc) : []), + [ctxMenu, editor], + ) // Undo/redo availability: refreshed on every transaction so the QAT buttons grey out when empty const [histState, setHistState] = useState({ canUndo: false, canRedo: false }) useEffect(() => { if (!editor) return - const refresh = () => + const refresh = ({ transaction }: { transaction?: { docChanged: boolean } } = {}) => { setHistState({ canUndo: editor.can().undo(), canRedo: editor.can().redo() }) + if (transaction?.docChanged) setDocVersion((v) => v + 1) + } refresh() editor.on('transaction', refresh) return () => { @@ -5677,6 +7850,246 @@ export function App() { [hasDoc, hasUnsavedChanges, autoSave, editor, save, lang, histState], ) + // ---- Multiple Pages: the grid is a function of zoom × pane width ---- + const [paneW, setPaneW] = useState(0) + useEffect(() => { + const el = scrollContainerRef.current + if (!doc || !el) return + const ro = new ResizeObserver(() => setPaneW(el.clientWidth)) + ro.observe(el) + setPaneW(el.clientWidth) + return () => ro.disconnect() + }, [doc]) + const mpPaperW = canvasBox ? paperWidthPx(sections, canvasSection) : 0 + const mpSlotW = mpPaperW + markupExtra + // differing paper widths keep the single column (their sheets are drawn per page) + const mpUniform = !sections.some((s) => pageLeftPx(s.settings, mpPaperW) > 0.5) + const mpCols = + doc && viewMode === 'print' && !readMode && mpUniform && multipageMode + ? multipageColumns(paneW, mpSlotW, zoom) + : 1 + const mpWanted = mpCols >= 2 + const [mpFrames, setMpFrames] = useState([]) + const canvasPageH = canvasBox?.height ?? 0 + useEffect(() => { + if (!mpWanted) { + setMpFrames((prev) => (prev.length ? [] : prev)) + return + } + const wrap = editor?.view.dom.closest('.page-wrap') + if (!wrap) return + const frames = pageFramesFromGaps(wrap, zoom / 100, undefined, pageLayoutRef.current?.cuts) + // the canvas ends where the content ends; the grid shows the last sheet whole + const last = frames[frames.length - 1] + if (last && last.bottom - last.top < canvasPageH) last.bottom = last.top + canvasPageH + setMpFrames(frames) + }, [doc, editor, mpWanted, zoom, layoutRev, canvasPageH]) + const multipage = mpWanted && mpFrames.length >= 2 + const mpLayout = useMemo( + () => + multipage + ? // every slot is a full sheet: a mid-paragraph cut leaves its canvas frame a + // few px short of the paper, and Word's grid is equal-height + layoutMultipage( + mpFrames.map((f) => Math.max(canvasPageH, f.bottom - f.top)), + mpCols, + mpSlotW, + ) + : null, + [multipage, mpFrames, mpCols, mpSlotW, canvasPageH], + ) + const [mpActive, setMpActive] = useState(0) + const mpActiveIdx = Math.max(0, Math.min(mpActive, mpFrames.length - 1)) + multipageActiveRef.current = multipage ? mpActiveIdx : null + mpFramesRef.current = mpFrames + + const [mpSnap, setMpSnap] = useState(null) + useEffect(() => { + if (!multipage) { + setMpSnap(null) + return + } + const wrap = editor?.view.dom.closest('.page-wrap') + if (!wrap) return + const raf = requestAnimationFrame(() => setMpSnap(snapshotCanvas(wrap, zoom / 100))) + return () => cancelAnimationFrame(raf) + }, [multipage, editor, zoom, layoutRev, mpFrames, mpActiveIdx]) + + /** the page under the caret, so the live window follows selection moves and page turns */ + const mpFollowCaret = useCallback(() => { + if (!editor || multipageActiveRef.current == null) return + const frames = mpFramesRef.current + const wrap = editor.view.dom.closest('.page-wrap') + if (!wrap || frames.length === 0) return + let y: number + try { + y = editor.view.coordsAtPos(editor.state.selection.head).top + } catch { + return + } + const canvasY = (y - wrap.getBoundingClientRect().top) / (zoomLiveRef.current / 100) + let idx = 0 + for (let i = 1; i < frames.length; i++) if (frames[i].top <= canvasY + 0.5) idx = i + setMpActive(idx) + }, [editor]) + useEffect(() => { + if (!editor || !multipage) return + const onTr = ({ transaction }: { transaction: Transaction }) => { + // a typed change settles on its page after the next pagination pass + if (!transaction.docChanged && transaction.selectionSet) mpFollowCaret() + } + editor.on('transaction', onTr) + return () => { + editor.off('transaction', onTr) + } + }, [editor, multipage, mpFollowCaret]) + useEffect(() => { + if (multipage) mpFollowCaret() + }, [multipage, mpFrames, mpFollowCaret]) + + /** a click on a painted page moves the live editor there and lands the caret under the pointer */ + const mpPickRef = useRef<{ x: number; y: number } | null>(null) + const onMpPick = useCallback((index: number, x: number, y: number) => { + mpPickRef.current = { x, y } + setMpActive(index) + }, []) + useLayoutEffect(() => { + const pick = mpPickRef.current + if (!pick || !editor) return + mpPickRef.current = null + const view = editor.view + const wrap = view.dom.closest('.page-wrap') + const frames = mpFramesRef.current + const frame = frames[mpActiveIdx] + if (!wrap || !frame) return + const z = zoomLiveRef.current / 100 + const wrapTop = wrap.getBoundingClientRect().top + const pageOf = (pos: number): number => { + const y = (view.coordsAtPos(pos).top - wrapTop) / z + let idx = 0 + for (let i = 1; i < frames.length; i++) if (frames[i].top <= y + 0.5) idx = i + return idx + } + let pos = view.posAtCoords({ left: pick.x, top: pick.y })?.pos ?? null + if (pos != null && pageOf(pos) !== mpActiveIdx) pos = null + if (pos == null) { + // the pointer is on blank paper whose nearest caret slot is another + // page's: take the picked page's block nearest to the pointer instead + const canvasY = (pick.y - wrapTop) / z + let best: { el: HTMLElement; d: number; r: DOMRect } | null = null + for (const el of Array.from(view.dom.children) as HTMLElement[]) { + if (el.classList.contains('ProseMirror-widget')) continue + const r = el.getBoundingClientRect() + const top = (r.top - wrapTop) / z + const bottom = (r.bottom - wrapTop) / z + if (bottom <= frame.top || top >= frame.bottom) continue + const d = canvasY < top ? top - canvasY : canvasY > bottom ? canvasY - bottom : 0 + if (!best || d < best.d) best = { el, d, r } + } + if (best) { + const x = Math.min(Math.max(pick.x, best.r.left + 1), best.r.right - 1) + const y = Math.min(Math.max(pick.y, best.r.top + 1), best.r.bottom - 1) + pos = view.posAtCoords({ left: x, top: y })?.pos ?? null + } + } + if (pos != null) { + const sel = TextSelection.near(view.state.doc.resolve(pos), 1) + view.dispatch(view.state.tr.setSelection(sel)) + } + view.focus() + }, [mpActiveIdx, editor]) + + /** scroller offset of the active slot's top edge (CSS px) */ + const mpActiveSlotTop = useCallback((): number | null => { + const scroller = scrollContainerRef.current + const grid = scroller?.querySelector('.doc-zoom') + const slot = mpLayout?.slots[mpActiveIdx] + if (!scroller || !grid || !slot) return null + const gridTop = + grid.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop + return gridTop + slot.top * (zoom / 100) + }, [mpLayout, mpActiveIdx, zoom]) + // a caret-driven page turn may land on a slot outside the viewport (zoom + // changes keep their own anchoring below) + const mpSeenActiveRef = useRef(null) + useLayoutEffect(() => { + const scroller = scrollContainerRef.current + const seen = mpSeenActiveRef.current + mpSeenActiveRef.current = multipage ? mpActiveIdx : null + if (!multipage || !scroller || !mpLayout || seen === mpActiveIdx) return + const top = mpActiveSlotTop() + if (top == null) return + const slot = mpLayout.slots[mpActiveIdx] + const bottom = top + slot.height * (zoom / 100) + const viewTop = scroller.scrollTop + const viewBottom = viewTop + scroller.clientHeight + if (bottom > viewTop && top < viewBottom) return + scroller.scrollTop = Math.max(0, top - 26) + scrollPosRef.current = { left: scroller.scrollLeft, top: scroller.scrollTop } + }, [multipage, mpActiveIdx, mpLayout, mpActiveSlotTop, zoom]) + + const multipagePrevRef = useRef({ on: false, cols: 1 }) + useLayoutEffect(() => { + const prevZoom = prevZoomRef.current + prevZoomRef.current = zoom + const anchor = zoomAnchorRef.current + zoomAnchorRef.current = null + const container = scrollContainerRef.current + const page = container?.querySelector('.doc-zoom') + if (!container || !page) return + // the Multiple Pages grid re-flows when its column count changes: keep + // the active page in view instead of a point that no longer exists + const prevMp = multipagePrevRef.current + const colsNow = multipage ? mpCols : 1 + multipagePrevRef.current = { on: multipage, cols: colsNow } + if (multipage !== prevMp.on || colsNow !== prevMp.cols) { + let top = mpActiveSlotTop() + if (top == null && editor) { + // grid collapsed: the caret's page in the single column + try { + const c = editor.view.coordsAtPos(editor.state.selection.head) + top = c.top - container.getBoundingClientRect().top + container.scrollTop - 120 + } catch { + top = null + } + } + if (top != null) { + container.scrollTop = Math.max(0, top - 26) + scrollPosRef.current = { left: container.scrollLeft, top: container.scrollTop } + } + return + } + if (prevZoom === zoom) return + + const ratio = zoom / prevZoom + const vx = anchor ? anchor.vx : container.clientWidth / 2 + const vy = anchor ? anchor.vy : container.clientHeight / 2 + const cs = getComputedStyle(container) + const padLeft = parseFloat(cs.paddingLeft) || 0 + const innerWidth = container.clientWidth - padLeft - (parseFloat(cs.paddingRight) || 0) + const containerRect = container.getBoundingClientRect() + const pageRect = page.getBoundingClientRect() + const pageLeft = pageRect.left - containerRect.left + container.scrollLeft + const pageTop = pageRect.top - containerRect.top + container.scrollTop + const prevPageLeft = padLeft + Math.max(0, (innerWidth - pageRect.width / ratio) / 2) + const prev = scrollPosRef.current + + const docX = Math.max(0, prev.left + vx - prevPageLeft) + const docY = Math.max(0, prev.top + vy - pageTop) + container.scrollLeft = pageLeft + docX * ratio - vx + container.scrollTop = pageTop + docY * ratio - vy + scrollPosRef.current = { left: container.scrollLeft, top: container.scrollTop } + }, [zoom, multipage, mpCols, mpActiveSlotTop, editor]) + + useEffect(() => { + if (!editor) return + editor.view.dispatch( + editor.state.tr + .setMeta(formattingMarksPluginKey, { on: showMarks, breaks: sectionBreakMap(sections) }) + .setMeta('addToHistory', false), + ) + }, [editor, showMarks, sections, lang]) + if (!editor) return null // canvas geometry is anchored to the first section (stable across cursor moves); @@ -5696,13 +8109,24 @@ export function App() { // pin whenever the shared paper is wider than the footer's own section (a wider // middle section widens the paper too) or the strip width differs from the // first-section default + // mirrored documents pin the first/last page's strips to that page's own (parity) + // margin: inside/outside differ, so the stylesheet's centering would miss the body + const firstPageMargins = canvasSection + ? pageMargins(canvasSection, sections[0]?.pageNumberStart ?? 1, mirrorMargins) + : null + const lastPageMargins = lastSection + ? pageMargins(lastSection, lastPageNo?.num ?? 1, mirrorMargins) + : null const edgeFooterStyle = lastBox && canvasBox && - (paperW - lastBox.width > 0.5 || Math.abs(lastBox.contentWidth - canvasBox.contentWidth) > 0.5) + lastPageMargins && + (paperW - lastBox.width > 0.5 || + Math.abs(lastBox.contentWidth - canvasBox.contentWidth) > 0.5 || + mirrorMargins) ? { width: `${lastBox.contentWidth}px`, - left: `${pageLeftOf(lastSection!) + twipsToPx(lastSection!.marginLeft)}px`, + left: `${pageLeftOf(lastSection!) + twipsToPx(lastPageMargins.left)}px`, transform: 'none', bottom: `${lastBox.footerDist}px`, } @@ -5710,13 +8134,22 @@ export function App() { // symmetric pin for the leading header (it belongs to the first section): the // widened shared paper would otherwise re-center it at the wrong x const edgeHeaderStyle = - canvasBox && canvasSection && paperW - canvasBox.width > 0.5 + canvasBox && + canvasSection && + firstPageMargins && + (paperW - canvasBox.width > 0.5 || mirrorMargins) ? { width: `${canvasBox.contentWidth}px`, - left: `${pageLeftOf(canvasSection) + twipsToPx(canvasSection.marginLeft)}px`, + left: `${pageLeftOf(canvasSection) + twipsToPx(firstPageMargins.left)}px`, transform: 'none', } : undefined + const rulerBase = sections[activeSection]?.settings ?? section + const rulerSwap = mirrorMargins && caretPageNo % 2 === 0 + const rulerSection = + rulerBase && rulerSwap + ? { ...rulerBase, marginLeft: rulerBase.marginRight, marginRight: rulerBase.marginLeft } + : rulerBase const docZoomStyle = { zoom: zoom / 100, '--page-w': canvasBox ? `${paperW}px` : undefined, @@ -5725,6 +8158,10 @@ export function App() { '--page-pad': canvasSection ? `${canvasTop}px ${twipsToPx(canvasSection.marginRight)}px ${canvasBottom}px ${twipsToPx(canvasSection.marginLeft)}px` : undefined, + '--page-mt': canvasSection ? `${canvasTop}px` : undefined, + '--page-mb': canvasSection ? `${canvasBottom}px` : undefined, + '--page-ml': canvasSection ? `${twipsToPx(canvasSection.marginLeft)}px` : undefined, + '--page-mr': canvasSection ? `${twipsToPx(canvasSection.marginRight)}px` : undefined, '--header-dist': canvasBox ? `${canvasBox.headerDist}px` : undefined, '--footer-dist': canvasBox ? `${canvasBox.footerDist}px` : undefined, '--page-bg': pageColor ? `#${pageColor}` : undefined, @@ -5747,19 +8184,27 @@ export function App() { const docZoomClass = [ 'doc-zoom', + multipage ? 'multipage' : '', showMarks ? 'show-marks' : '', `view-${viewMode}`, showGrid ? 'show-grid' : '', + tableGridlines ? 'show-table-gridlines' : '', ].join(' ') return (
    {docCss && } - {doc && liveDocCjk != null && ( - + {revisionAuthorStyle && } + {tcOptionsStyle && } + {liveLineFactor != null && ( + )} {doc && (gridPitchPt ?? mixedGridPitchPt) != null && ( // typed w:docGrid: line-height round(up) expressions snap to this pitch @@ -5767,7 +8212,16 @@ export function App() { )} {doc && charSpacePt != null && ( // w:docGrid charSpace: every character advances natural width + this delta - + + )} + {doc && balancedGrid && ( + // full-width spans take the whole delta back (halfwidth kana stay single-byte); + // an inline run letter-spacing still wins + )} {doc && section && ( // over-wide tables may spill into the margins (Word/LO), capped at the paper edge @@ -5791,23 +8245,42 @@ export function App() { { + setShowFind(true) + setFindFocusInput((n) => n + 1) + }} + onReplace={() => { + setShowFind(true) + setFindFocusReplace((n) => n + 1) + }} blocks={doc?.parsed.blocks ?? EMPTY_BLOCKS} styles={ribbonStyles} - onFontSettings={onFontSettings} docDefaults={doc?.parsed.docDefaults} + documentListPresets={documentLists} + recentListPresets={recentLists} showAi={showAi} section={sections[activeSection]?.settings ?? section} + tableGridlines={tableGridlines} activeSection={sections.length > 1 ? activeSection : null} + mirrorMargins={mirrorMargins} pageColor={pageColor} watermark={watermark} + watermarkStyle={watermarkStyle} + watermarkPicture={watermarkPicture} + watermarkDocImage={ + watermarkDirty + ? null + : (doc?.parsed.headerImages?.find((img) => img.wordArt || img.watermark) ?? null) + } themeFonts={themeFonts} themeColors={themeColors} inkTool={inkTool} - inkPen={inkPen} - inkHighlighter={inkHighlighter} + inkPresets={inkPresets} + inkActiveId={inkActiveId} inkCount={inkAnnotations.length} sources={sources} zoteroNoteFields={ @@ -5816,20 +8289,32 @@ export function App() { zoom={Math.round(zoom)} darkPage={darkPage} tabRequest={ribbonTabRequest} - header={header} - footer={footer} - titlePg={titlePg} - evenOddHf={evenOddHf} + header={shownHeader} + footer={shownFooter} + hfEditing={hfEditingInfo} showMarks={showMarks} showRuler={showRuler} showNav={showNav} + showStylesPane={showStylesPane} + onShowStylesPane={setShowStylesPane} commentCount={comments.length} + showComments={showComments} + commentsMode={commentsMode} + onCommentsMode={setCommentsMode} openCommentCount={comments.filter((c) => !c.parentId && c.done !== true).length} + resolvedCommentCount={comments.filter((c) => !c.parentId && c.done === true).length} + commentAtCaret={commentThreadAtCaretImpl(reviewCtxRef.current) !== null} canComment={!editor.state.selection.empty || wordRangeAtCaret(editor) !== null} trackChanges={trackChanges} - spellcheck={spellcheck} + spellcheck={spellcheckActive} revisionDisplay={revisionDisplay} + balloonMode={balloonMode} + onBalloonMode={setBalloonMode} + onTrackChangesOptions={() => setShowTcOptions(true)} revisionCount={revisionCount} + revisionFilter={revisionFilter} + revisionAuthors={reviewAuthors} + showReviewPane={showReviewPane} isProtected={isProtected} commentsAllowed={commentsAllowed} trackChangesForced={trackChangesForced} @@ -5846,7 +8331,11 @@ export function App() {
    {doc && ( -
    +
    {/* always mounted: collapse must not drop state or in-flight runs */} setShowAi(true)} onCollapse={() => setShowAi(false)} filePath={doc?.filePath ?? null} @@ -5873,6 +8363,19 @@ export function App() { pageSetupAccess={aiPageSetupAccess} docExtras={aiDocExtras} notesAccess={aiNotesAccess} + trackChanges={trackChanges} + trackChangesLocked={isProtected || trackChangesForced} + onTrackChangesToggle={() => setTrackChanges((v) => !v)} + revisionCount={revisionCount} + onRevisions={(action) => { + if (!isProtected && !trackChangesForced) handleRevision(action, 'all') + }} + reviewEntries={allReviewEntries} + activeReviewIndex={activeAllReviewIndex} + onRevisionSelect={showRevision} + onRevisionRange={(action, rev) => { + if (!isProtected && !trackChangesForced) handleRevisionRange(action, rev) + }} />
    )} @@ -5884,15 +8387,25 @@ export function App() { onClose={() => { setShowFind(false) // else the next plain ⌘F remount would still see a truthy nonce - // and land focus on the replace field + // and land focus on the replace / go-to field setFindFocusReplace(0) + setFindFocusGoTo(0) }} focusFindNonce={findFocusInput} focusReplaceNonce={findFocusReplace} + focusGoToNonce={findFocusGoTo} + layout={() => pageLayoutRef.current} /> )} {doc && showNav && ( - + )} {doc && ( }
    { scrollPosRef.current = { @@ -5945,145 +8458,207 @@ export function App() { onClick={onDocClick} onContextMenu={onDocContextMenu} onDoubleClick={onDocDoubleClick} - style={docZoomStyle} + style={ + multipage && mpLayout + ? { ...docZoomStyle, width: mpLayout.width, height: mpLayout.height } + : docZoomStyle + } > - {showRuler && section && ( - { - if (!editor) return - setParaAttrs(editor, { tabStops: stops ? JSON.stringify(stops) : null }) - }} - /> - )} -
    - {watermark && watermarkDirty && ( - - )} - {!readMode && ( -
    - {/* Always-available entry point: the ribbon checkbox alone was - undiscoverable while editing the header, so the toggle also lives here - (revealed on header hover until enabled) */} - - {titlePg && ( - - )} - {(titlePg || evenOddHf) && ( - - )} - {evenOddHf && ( - - )} -
    - )} - {/* Boolean(): a trailing 0 (empty non-floating image list) must not render as a literal "0" text node */} - {Boolean( - multiHf || - (hfViewTouched && effHfView !== 'default') || - hfHasVisibleContent(shownHeader, hfImagesOf('header')), - ) && ( - commitHf('header', next)} - pageTotal={pageInfo.total} - style={edgeHeaderStyle} - boxGeom={ - canvasSection && canvasBox - ? { - ...hfStripGeom(canvasSection), - stripLeft: edgeHeaderStyle - ? twipsToPx(canvasSection.marginLeft) - : (paperW - canvasBox.contentWidth) / 2, - } - : undefined - } +
    + {showRuler && rulerSection && !multipage && ( + { + if (!editor) return + setParaAttrs(editor, { tabStops: stops ? JSON.stringify(stops) : null }) + }} + onIndentsChange={(attrs) => { + if (editor) setParaAttrs(editor, { ...attrs }) + }} + onMarginsChange={({ left, right }) => { + const sec = sections[activeSection]?.settings ?? section + ribbonActions.onSection({ + ...sec, + marginLeft: rulerSwap ? right : left, + marginRight: rulerSwap ? left : right, + }) + }} /> )} - - {/* footnotes already shown per page in page gaps aren't repeated at the end (last page's footnotes still live here) */} - noteNo('footnote', n.id, i)} - onEdit={(id) => editNote('footnote', id)} - onDelete={(id) => deleteNote('footnote', id)} - /> - noteNo('endnote', n.id, i)} - onEdit={(id) => editNote('endnote', id)} - onDelete={(id) => deleteNote('endnote', id)} - /> - {Boolean( - multiHf || - (hfViewTouched && effHfView !== 'default') || - hfHasVisibleContent(shownFooter, hfImagesOf('footer')), - ) && ( - commitHf('footer', next)} - pageNo={lastPageNo?.text ?? pageInfo.total} - pageTotal={pageInfo.total} - style={edgeFooterStyle} - boxGeom={ - lastSection && lastBox - ? { - ...hfStripGeom(lastSection), - stripLeft: edgeFooterStyle - ? twipsToPx(lastSection.marginLeft) - : (paperW - lastBox.contentWidth) / 2, - } - : undefined - } +
    + {/* Boolean(): a trailing 0 (empty non-floating image list) must not render as a literal "0" text node */} + {Boolean( + multiHf || + headerAreaView !== 'default' || + hfAreaEditRequest?.kind === 'header' || + hfHasVisibleContent(shownHeader, hfImagesOf('header')), + ) && ( + { + const target = edgeHfTarget('header', headerAreaView) + commitHfAt(0, 'header', target.variant, next, target.owner) + }} + pageNo={firstPageNoText} + pageTotal={pageInfo.total} + sectionPages={edgeSectionPages?.first} + sectionLabel={hfSectionLabel('header', 0)} + editRequest={ + hfAreaEditRequest?.kind === 'header' ? hfAreaEditRequest.nonce : null + } + onEditingChange={(editing, handle) => { + hfEditorRef.current = editing ? handle : null + if (!editing) setHfAreaEditRequest(null) + setHfEditing( + editing + ? { + kind: 'header', + section: 0, + variant: headerAreaView, + pageIdx: 0, + owner: hfResolveAt(0, 'header', headerAreaView).owner, + } + : null, + ) + }} + style={edgeHeaderStyle} + boxGeom={ + canvasSection && canvasBox + ? { + ...hfStripGeom(canvasSection, legacyBands), + stripLeft: edgeHeaderStyle + ? twipsToPx(firstPageMargins!.left) + : (paperW - canvasBox.contentWidth) / 2, + } + : undefined + } + /> + )} + + {/* footnotes already shown per page in page gaps aren't repeated at the end (last page's footnotes still live here) */} + noteNo('footnote', n.id, i)} + onEdit={(id) => editNote('footnote', id)} + onDelete={(id) => deleteNote('footnote', id)} /> - )} - {(inkAnnotations.length > 0 || inkTool !== 'select') && !readMode && ( - noteNo('endnote', n.id, i)} + onEdit={(id) => editNote('endnote', id)} + onDelete={(id) => deleteNote('endnote', id)} /> - )} + {Boolean( + multiHf || + footerAreaView !== 'default' || + hfAreaEditRequest?.kind === 'footer' || + hfHasVisibleContent(shownFooter, hfImagesOf('footer')), + ) && ( + { + const target = edgeHfTarget('footer', footerAreaView) + commitHfAt( + lastSectionIdx, + 'footer', + target.variant, + next, + target.owner, + ) + }} + pageNo={lastPageNoText} + pageTotal={pageInfo.total} + sectionPages={edgeSectionPages?.last} + linked={hfLinkedAt(lastSectionIdx, 'footer', footerAreaView)} + sectionLabel={hfSectionLabel('footer', lastSectionIdx)} + editRequest={ + hfAreaEditRequest?.kind === 'footer' ? hfAreaEditRequest.nonce : null + } + onEditingChange={(editing, handle) => { + hfEditorRef.current = editing ? handle : null + if (!editing) setHfAreaEditRequest(null) + setHfEditing( + editing + ? { + kind: 'footer', + section: lastSectionIdx, + variant: footerAreaView, + pageIdx: lastPageIdx, + owner: hfResolveAt(lastSectionIdx, 'footer', footerAreaView) + .owner, + } + : null, + ) + }} + style={edgeFooterStyle} + boxGeom={ + lastSection && lastBox + ? { + ...hfStripGeom(lastSection, legacyBands), + stripLeft: edgeFooterStyle + ? twipsToPx(lastPageMargins!.left) + : (paperW - lastBox.contentWidth) / 2, + } + : undefined + } + /> + )} + {(inkAnnotations.length > 0 || inkTool !== 'select') && !readMode && ( + + )} +
    + {/* after the live editor in DOM order: first-match lookups of + .ProseMirror / .doc-page / .page-wrap must find the editor */} + {multipage && mpLayout && ( + + )}
    ) : (
    {t('appStartOpening')}
    @@ -6103,7 +8678,7 @@ export function App() {
    -
    +
    )} - {doc && showComments && ( + {doc && commentsUi.panel && ( + )} + {doc && showStylesPane && ( + setShowStylesPane(false)} /> )} {doc && compareResult && ( @@ -6141,21 +8730,59 @@ export function App() { onClose={() => setCompareResult(null)} /> )} + {doc && showReviewPane && ( + ({ + name, + inkIndex: authorInkIndex(reviewAuthors, name), + }))} + openComments={comments.filter((c) => !c.parentId && !c.done).length} + activeIndex={activeReviewIndex} + canEdit={!isProtected && !trackChangesForced} + onSelect={showRevision} + onAccept={(rev) => handleRevisionRange('accept', rev)} + onReject={(rev) => handleRevisionRange('reject', rev)} + onClose={() => setShowReviewPane(false)} + /> + )}
    {doc && ( <> - + + )} @@ -6163,28 +8790,68 @@ export function App() { {status && — {status}}
    - + + + +
    + )} + setZoom(Number(e.target.value))} /> + - {Math.round(zoom)}%
    @@ -6192,8 +8859,34 @@ export function App() { {showShortcuts && setShowShortcuts(false)} />} {showLinkModal && setShowLinkModal(false)} />} + {fieldEdit && ( + setFieldEdit(null)} + onSubmit={(instr) => setFieldInstr(editor, fieldEdit, instr, fieldValue(instr))} + /> + )} {showTableModal && ( - setShowTableModal(false)} /> + setShowTableModal(false)} + onInserted={() => setRibbonTabRequest({ tab: 'tableDesign', nonce: Date.now() })} + /> + )} + {showZoomDialog && ( + (typeof target === 'number' ? setZoom(target) : zoomFit(target))} + onClose={() => setShowZoomDialog(false)} + /> + )} + {showTcOptions && ( + setShowTcOptions(false)} + /> )} {showEquationModal && editor && ( setShowEquationModal(false)} /> @@ -6215,6 +8908,8 @@ export function App() { onParagraphDialog={() => setShowParaDialog(true)} onLink={() => setShowLinkModal(true)} onNewComment={startNewComment} + onRedact={startRedaction} + onUnredact={clearRedaction} onViewImage={setViewImage} onSaveImageAs={saveImageAs} onAiPreset={(text) => { @@ -6223,12 +8918,49 @@ export function App() { }} onRestartNumbering={restartNumbering} onContinueNumbering={continueNumbering} + onSetNumberingValue={() => setListDialog('value')} + onAdjustListIndents={() => setListDialog('indents')} + onChangeListLevel={changeListLevel} onUpdateFields={updateFields} + onEditField={setFieldEdit} + onOpenLink={(href) => openLink(href, ctxMenu.link?.tocTitle)} + onRespell={() => requestAnimationFrame(() => setRespellNonce((n) => n + 1))} + onTableDialog={ribbonActions.onTableDialog} + sectionContentWidthPx={tableSectionWidthPx} + revisions={ + isProtected || trackChangesForced + ? null + : { + at: revisionsAtCtx( + liveRevisions, + ctxMenu.pos ?? editor.state.selection.from, + !!ctxMenu.balloon, + ), + any: liveRevisions.length > 0, + onRange: handleRevisionRange, + onAll: (action) => handleRevision(action, 'all'), + } + } + comment={{ + id: ctxCommentId, + onReply: (id) => { + setShowComments(true) + setCommentFocus({ id, nonce: Date.now(), reply: true }) + }, + onDelete: deleteComment, + onResolve: (id) => resolveComment(id, true), + }} + trackChanges={ + isProtected || trackChangesForced + ? null + : { on: trackChanges, onToggle: toggleTrackChangesByUser } + } /> )} {viewImage && ( setShowFontDialog(false)} /> )} {doc && showParaDialog && ( - setShowParaDialog(false)} /> + setShowParaDialog(false)} + /> + )} + {doc && redactTarget && ( + setRedactTarget(null)} + onSubmit={(label) => { + // re-assert the captured range: the command marks the selection, + // and the reader may have clicked somewhere while deciding + if (editor) + editor + .chain() + .setTextSelection({ from: redactTarget.from, to: redactTarget.to }) + .setRedaction(label) + .run() + setRedactTarget(null) + }} + /> + )} + {doc && tableDialog === 'properties' && ( + setTableDialog(null)} + /> + )} + {doc && tableDialog === 'splitCells' && ( + setTableDialog(null)} /> + )} + {doc && tableDialog === 'insertCells' && ( + setTableDialog(null)} + /> + )} + {doc && tableDialog === 'deleteCells' && ( + setTableDialog(null)} /> + )} + {doc && tableDialog === 'cellMargins' && ( + setTableDialog(null)} + /> + )} + {doc && listDialog === 'bullet' && ( + { + if (picture) { + const id = addPictureBullet(numberingCtxRef.current, picture) + const at = listTarget?.ilvl ?? 0 + levels = levels.map((l, i) => (i === at ? { ...l, picBulletId: id } : l)) + } + applyDefinedList(levels, 'bullets') + }} + onClose={() => setListDialog(null)} + /> + )} + {doc && listDialog === 'number' && ( + applyDefinedList(levels, 'numbers')} + onClose={() => setListDialog(null)} + /> + )} + {doc && listDialog === 'multi' && ( + applyDefinedList(levels, 'multi')} + onClose={() => setListDialog(null)} + /> + )} + {doc && listDialog === 'value' && listTarget && ( + { + setNumberingValueImpl(numberingCtxRef.current, { mode, value }) + setListDialog(null) + editor?.commands.focus() + }} + onClose={() => setListDialog(null)} + /> + )} + {doc && listDialog === 'indents' && listTarget && ( + { + editListLevelImpl(numberingCtxRef.current, patch) + setListDialog(null) + editor?.commands.focus() + }} + onClose={() => setListDialog(null)} + /> )} {doc && notePrompt && ( @@ -6281,15 +9119,35 @@ export function App() { + hfLayoutResolved( + kind, + resolveHf({ ...hfState, sections: secs }, si, kind, variant), + hfPhantom, + ) + } + comments={commentsUi.cards ? comments : []} anchorBlocks={doc.parsed.blocks} - revisionView={revisionDisplay === 'all' && viewMode === 'print' ? editor?.view : null} + revisionView={balloonsActive && balloonMode !== 'inline' ? editor?.view : null} + balloonKinds={balloonRevKinds(balloonMode)} clearPageGaps={() => { // column-layout decorations stay: the preview measures with the block widths // (line boxes must keep column wrapping); transforms are neutralized by its @@ -6329,8 +9193,10 @@ export function App() { } }} onExportPdf={() => void exportPdf()} - onClose={() => setShowPagePreview(false)} - suppressEscape={showPrintDialog} + onClose={() => { + if (!exportLocksPreview) setShowPagePreview(false) + }} + suppressEscape={showPrintDialog || exportLocksPreview} /> )} @@ -6338,6 +9204,9 @@ export function App() { {stats && setStats(null)} />} + {showAutoCorrect && setShowAutoCorrect(false)} />} + {showPreferences && setShowPreferences(false)} />} + {docPwdPrompt && ( { + const dataUrl = await new Promise((resolve, reject) => { + const blobUrl = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml;charset=utf-8' })) + const img = new Image() + img.onload = () => { + URL.revokeObjectURL(blobUrl) + const canvas = document.createElement('canvas') + canvas.width = Math.round(size.width * 2) + canvas.height = Math.round(size.height * 2) + const ctx = canvas.getContext('2d') + if (!ctx) { + reject(new Error('canvas 2d context unavailable')) + return + } + ctx.scale(2, 2) + ctx.drawImage(img, 0, 0) + resolve(canvas.toDataURL('image/png')) + } + img.onerror = () => { + URL.revokeObjectURL(blobUrl) + reject(new Error('the mind map SVG could not be rasterized')) + } + img.src = blobUrl + }) + const scale = Math.min(1, MIND_MAP_PICTURE_MAX_WIDTH_PX / Math.max(1, size.width)) + insertPictureAtSelection(editor, { + dataUrl, + base64: dataUrl.slice(dataUrl.indexOf(',') + 1), + mime: 'image/png', + widthPx: Math.max(1, Math.round(size.width * scale)), + heightPx: Math.max(1, Math.round(size.height * scale)), + label, + }) +} + /** Cap on tool args/output persisted in the transcript (the store layer has another 16k truncation fallback) */ const PERSIST_TOOL_FIELD_MAX = 16_000 @@ -101,16 +180,23 @@ interface ChatEntry { error?: string streaming?: boolean turnLimit?: boolean - /** the run failed because Hermes is signed out — render an inline sign-in button */ + /** the run failed because Genspark is signed out — render an inline sign-in button */ loginRequired?: boolean /** tool executions performed during this assistant turn */ tools?: ToolActivity[] + /** interactive mind map from generate_mindmap's side-channel display (session-only; + * the persisted transcript keeps the tool call, so a reopened file can regenerate) */ + mindmap?: MindMapTreeNode /** document state before this turn's first edit — rendered as an inline roll-back action */ snapshot?: PmNode /** attachments consumed from the composer by this user message (read-only echo chips) */ attachments?: AttachmentMeta[] + /** skills the user attached to this message from the composer menu */ + skills?: string[] /** the selection this user message targeted, frozen at send */ scope?: AiScopeQuoteData + /** tokens the provider billed for this reply, summed across its turns (absent when unreported) */ + usage?: AiTokenUsageView } /** longest selection excerpt echoed on a user bubble */ @@ -128,34 +214,13 @@ const EDIT_STARTER_PROMPTS: StringKey[] = [ 'aiStarterPolishAll', 'aiStarterContinue', 'aiStarterFillTemplate', + 'aiStarterMindMap', ] /** resizable panel width: persisted, clamped so neither pane collapses */ const PANEL_WIDTH_KEY = 'docs-ai-panel-width' -const PANEL_WIDTH_DEFAULT = 360 -const PANEL_WIDTH_MIN = 280 - -function maxPanelWidth(): number { - // The viewport can be transiently tiny (a WebContentsView is 0×0 until the - // shell lays it out), so never let the ceiling drop below the minimum - return Math.max(PANEL_WIDTH_MIN, Math.min(720, Math.round(window.innerWidth * 0.6))) -} - -function clampPanelWidth(w: number): number { - return Math.min(Math.max(w, PANEL_WIDTH_MIN), maxPanelWidth()) -} - -function loadPanelWidth(): number { - const saved = Number(localStorage.getItem(PANEL_WIDTH_KEY)) - // static bounds only — clamping against the window here would bake a - // transiently small viewport into the restored preference - return Number.isFinite(saved) && saved > 0 - ? Math.min(Math.max(saved, PANEL_WIDTH_MIN), 720) - : PANEL_WIDTH_DEFAULT -} /** persisted UI preference: highlight AI edits in yellow and ask for confirmation */ -const TRACK_CHANGES_KEY = 'ai-docs-track-changes' /** Clipboard bitmap MIME → attachment extension (corresponds to ATTACHMENT_IMAGE_EXTS) */ const PASTE_MIME_EXT: Record = { @@ -165,126 +230,10 @@ const PASTE_MIME_EXT: Record = { 'image/webp': 'webp', } -/** File-type icons for attachment cards (Hermes attachment icon set); exts the +/** File-type icons for attachment cards (Genspark attachment icon set); exts the * attachment allowlist doesn't accept yet are mapped ahead so they light up when added */ -const ATTACHMENT_CARD_ICON_GROUPS: [icon: string, exts: string[]][] = [ - [fileWordIcon, ['doc', 'docx']], - [fileExcelIcon, ['xls', 'xlsx', 'xlsm', 'csv', 'tsv']], - [filePptIcon, ['ppt', 'pptx']], - [filePdfIcon, ['pdf']], - [fileImageIcon, ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'tiff', 'heic']], - [fileVideoIcon, ['mp4', 'mov', 'avi', 'mkv', 'webm', 'm4v']], - [fileVoiceIcon, ['mp3', 'wav', 'm4a', 'aac', 'flac', 'ogg', 'opus']], - [ - fileDocumentIcon, - [ - 'txt', - 'md', - 'markdown', - 'rtf', - 'log', - 'json', - 'yaml', - 'yml', - 'xml', - 'html', - 'htm', - 'js', - 'ts', - 'tsx', - 'jsx', - 'py', - 'java', - 'c', - 'h', - 'cpp', - 'go', - 'rs', - 'rb', - 'sh', - 'sql', - 'css', - ], - ], -] - -const ATTACHMENT_CARD_ICONS: Record = Object.fromEntries( - ATTACHMENT_CARD_ICON_GROUPS.flatMap(([icon, exts]) => exts.map((ext) => [ext, icon])), -) - -function AttachmentCardIcon({ ext }: { ext: string }) { - return -} - -/** Card name slot width: 190 card - 2 border - 8/14 padding - 40 icon - 10 gap */ -const CARD_NAME_MAX_WIDTH = 116 -let cardNameCtx: CanvasRenderingContext2D | null = null - -/** Ellipsize like the design: cut at the limit, strip trailing -_./spaces so - * punctuation never sits against the …; CSS text-overflow stays as fallback */ -function truncateCardName(name: string): string { - cardNameCtx ??= document.createElement('canvas').getContext('2d') - if (!cardNameCtx) return name - // must match the stack the card name actually renders with (body font in styles.css) - cardNameCtx.font = - "500 13px 'Segoe UI', -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif" - if (cardNameCtx.measureText(name).width <= CARD_NAME_MAX_WIDTH) return name - let lo = 1 - let hi = name.length - while (lo < hi) { - const mid = (lo + hi + 1) >> 1 - if (cardNameCtx.measureText(`${name.slice(0, mid)}…`).width <= CARD_NAME_MAX_WIDTH) lo = mid - else hi = mid - 1 - } - return `${name.slice(0, lo).replace(/[-_.\s]+$/, '')}…` -} - -function formatAttachmentSize(bytes: number): string { - return bytes >= 1024 * 1024 - ? `${(bytes / (1024 * 1024)).toFixed(2)} MB` - : `${(bytes / 1024).toFixed(2)} KB` -} - -/** Read-only echo of the attachments a user message consumed from the composer - * (image previews when the file is still readable; otherwise the placeholder icon) */ -function SentAttachments({ - atts, - previews, -}: { - atts: AttachmentMeta[] - previews: Record -}) { - return ( -
    - {atts.map((a) => - ATTACHMENT_IMAGE_EXTS.has(a.ext) ? ( - - {previews[a.path] ? ( - {a.name} - ) : ( - - - - )} - - ) : ( - - - - - - {truncateCardName(a.name)} - {formatAttachmentSize(a.sizeBytes)} - - - ), - )} -
    - ) -} -/** author name on AI-generated tracked revisions (accept/reject via Review) */ -export const AI_REVISION_AUTHOR = 'AI Assistant' +export { aiRevisionAuthor } interface AiPanelProps { editor: Editor @@ -298,6 +247,8 @@ interface AiPanelProps { preset?: { text: string; nonce: number; autoRun?: boolean } | null /** false shows only the collapsed rail; the component stays mounted so panel state survives */ open?: boolean + /** true suppresses even the collapsed rail (the "show the AI sidebar" setting is off) */ + hideRail?: boolean /** expand from the collapsed rail */ onExpand?: () => void /** collapse the panel to the sidebar rail */ @@ -323,6 +274,65 @@ interface AiPanelProps { docExtras?: AiDocExtras /** footnote / endnote lists for insert_footnote, insert_endnote, delete_note, read_notes */ notesAccess?: AiNotesAccess + /** the document's Track Changes state (shared with the Review ribbon and status bar) */ + trackChanges?: boolean + /** protection (read-only or forced trackedChanges): the pill and the accept / reject bar cannot act */ + trackChangesLocked?: boolean + onTrackChangesToggle?: () => void + /** pending revisions in the document, drives the accept-all / reject-all bar */ + revisionCount?: number + onRevisions?: (action: 'accept' | 'reject') => void + /** the pending revisions one by one (Reviewing Pane data) for the expandable list under the bar */ + reviewEntries?: ReviewEntry[] + activeReviewIndex?: number + onRevisionSelect?: (rev: RevisionRange) => void + onRevisionRange?: (action: 'accept' | 'reject', rev: RevisionRange) => void +} + +const EMPTY_REVIEW: ReviewEntry[] = [] +const EMPTY_NUMS: number[] = [] + +/** 1-based paragraph number (every textblock in document order, table cells included) per entry */ +function paragraphNumbers(doc: ProseNode, entries: ReviewEntry[]): number[] { + const starts: number[] = [] + doc.descendants((node, pos) => { + if (node.isTextblock) starts.push(pos) + return !node.isTextblock + }) + return entries.map((e) => { + let lo = 0 + let hi = starts.length + while (lo < hi) { + const mid = (lo + hi) >> 1 + if (starts[mid] <= e.rev.from) lo = mid + 1 + else hi = mid + } + return lo + }) +} + +/** dot colour of a list row: insertions green, deletions red, the rest neutral */ +const REVISION_TONE: Partial> = { + ins: 'ins', + moveTo: 'ins', + rowIns: 'ins', + cellIns: 'ins', + blockIns: 'ins', + paraMarkIns: 'ins', + del: 'del', + moveFrom: 'del', + rowDel: 'del', + cellDel: 'del', + blockDel: 'del', + paraMarkDel: 'del', +} + +const MODEL_BRIDGE: AiModelPickerBridge = { + getSettings: () => window.desktop.getAiSettings(), + setSettings: (settings) => window.desktop.setAiSettings(settings), + onSettingsChanged: (handler) => window.desktop.onAiSettingsChanged(handler), + gskLoggedIn: () => window.desktop.aiGskStatus().then((s) => !!s?.loggedIn), + openModelSettings: () => window.desktop.openAiModelSettings().catch(() => {}), } export function AiPanel({ @@ -333,6 +343,7 @@ export function AiPanel({ numIdFallback, preset, open = true, + hideRail = false, onExpand, onCollapse, filePath, @@ -347,16 +358,50 @@ export function AiPanel({ pageSetupAccess, docExtras, notesAccess, + trackChanges = false, + trackChangesLocked = false, + onTrackChangesToggle, + revisionCount = 0, + onRevisions, + reviewEntries = EMPTY_REVIEW, + activeReviewIndex = -1, + onRevisionSelect, + onRevisionRange, }: AiPanelProps) { const { t, lang } = useI18n() // Panel chrome follows the UI language; message text follows its own content (dir=auto below) const isRtl = lang === 'ar' || lang === 'he' const [input, setInput] = useState('') const [busy, setBusy] = useState(false) + /** messages queued while a reply runs; the pump re-runs them through runWith on settle */ + const msgQueue = useChatRunQueue<{ + attachments: AttachmentMeta[] + scope: AiScopeQuoteData | null + }>({ + busy, + submit: (text, meta) => { + const loop = loopRef.current + if (!loop || loop.busy || pendingSendRef.current) return false + runWith(text, text, meta.attachments, meta.scope) + return true + }, + }) /** Wall-clock start of the current run, drives the elapsed badge */ const runStartedAtRef = useRef(0) /** a send waiting on a phased open's tail; Stop / New chat abort it before it runs */ const pendingSendRef = useRef<{ aborted: boolean } | null>(null) + // snapshot of the starred files feeding the knowledge-base skill; refreshed + // on mount and before each send so a freshly starred file is answerable + const kbFilesRef = useRef([]) + const refreshKbFiles = (): Promise => { + if (typeof window.desktop?.kbList !== 'function') return Promise.resolve() + return window.desktop + .kbList() + .then((files) => { + kbFilesRef.current = files + }) + .catch(() => undefined) + } const [chat, setChat] = useState([]) /** a streamed write stopped early: the draft stays in the document until the user keeps or discards it */ const [activePartial, setActivePartial] = useState<{ blocks: number } | null>(null) @@ -365,13 +410,17 @@ export function AiPanel({ const writerEpochRef = useRef(0) /** Past conversation restored from JSONL (read-only transcript, not fed to the model) */ const [historicChat, setHistoricChat] = useState([]) - const [trackChanges, setTrackChanges] = useState( - () => localStorage.getItem(TRACK_CHANGES_KEY) === '1', - ) const [copiedIdx, setCopiedIdx] = useState(null) + const [revisionListOpen, setRevisionListOpen] = useState(false) + const revisionParas = useMemo( + () => + revisionListOpen && editor ? paragraphNumbers(editor.state.doc, reviewEntries) : EMPTY_NUMS, + [revisionListOpen, editor, reviewEntries], + ) const [attachments, setAttachments] = useState([]) + const [attachedSkills, setAttachedSkills] = useState([]) const [attachNotice, setAttachNotice] = useState(null) - /** data-URL previews for image attachments, keyed by path (Hermes composer thumbnails) */ + /** data-URL previews for image attachments, keyed by path (Genspark composer thumbnails) */ const [attachmentPreviews, setAttachmentPreviews] = useState>({}) /** image paths with a read already issued — one readAttachmentImage per attach, even while pending */ const previewRequestedRef = useRef(new Set()) @@ -379,6 +428,10 @@ export function AiPanel({ files skill must keep reading them mid-run and in follow-up turns. Deduped by path against the live composer list. */ const sentAttachmentsRef = useRef([]) + useEffect(() => { + void refreshKbFiles() + }, []) + useEffect(() => { // previews cover the composer plus every image echoed on a sent/history message // (history chips re-read the file by its stored path; a deleted file keeps the placeholder) @@ -433,8 +486,8 @@ export function AiPanel({ // preferred = the user's chosen width (the only value persisted); panelWidth = // what fits the current window. Deriving the display width from the preference // means a transiently small window never permanently shrinks the panel. - const preferredWidthRef = useRef(loadPanelWidth()) - const [panelWidth, setPanelWidth] = useState(() => clampPanelWidth(preferredWidthRef.current)) + const preferredWidthRef = useRef(loadAiPanelWidth(PANEL_WIDTH_KEY) ?? AI_PANEL_WIDTH_DEFAULT) + const [panelWidth, setPanelWidth] = useState(() => clampAiPanelWidth(preferredWidthRef.current)) const [resizing, setResizing] = useState(false) const asideRef = useRef(null) @@ -449,7 +502,7 @@ export function AiPanel({ // Re-derive the display width on window resize (max is 60% of the window); // growing the window back restores the preferred width useEffect(() => { - const onResize = () => setPanelWidth(clampPanelWidth(preferredWidthRef.current)) + const onResize = () => setPanelWidth(clampAiPanelWidth(preferredWidthRef.current)) window.addEventListener('resize', onResize) return () => window.removeEventListener('resize', onResize) }, []) @@ -471,7 +524,6 @@ export function AiPanel({ /** false once the user scrolls up to read; re-arms near the bottom */ const stickToBottomRef = useRef(true) /** projectId/chatId of the current chat */ - const sessionReadyRef = useRef | undefined>(undefined) const chatRefIds = useRef<{ projectId: string; chatId: string } | null>(null) // latest props for the loop's closures (the loop instance outlives renders) @@ -505,6 +557,8 @@ export function AiPanel({ numIdFallbackRef.current = numIdFallback const attachmentsRef = useRef(attachments) attachmentsRef.current = attachments + const attachedSkillsRef = useRef(attachedSkills) + attachedSkillsRef.current = attachedSkills /** attachments consumed by the most recent send — retry resends the same set */ const lastAttachmentsRef = useRef([]) /** the scope quote of the last send, so a retry reuses it instead of re-reading the live selection */ @@ -559,14 +613,18 @@ export function AiPanel({ const runToolsRef = useRef< Array<{ name: string; summary: string; isError?: boolean; input?: string; output?: string }> >([]) + /** tokens billed so far in the in-flight run (summed across turns; usage chips read it live) */ + const runUsageRef = useRef(undefined) + /** tokens billed across the panel's session (the composer chip) */ + const [sessionUsage, setSessionUsage] = useState(undefined) // ── Chat-history persistence ──────────────────────────────────────────── useEffect(() => { const api = (window as Window & { projectApi?: typeof window.projectApi }).projectApi if (!api) return const tempChatId = `unsaved-${Date.now()}` - const pendingChat = api.resolveChat({ filePath: filePath ?? null, tempChatId }) - sessionReadyRef.current = pendingChat + void api + .resolveChat({ filePath: filePath ?? null, tempChatId }) .then((ids) => { chatRefIds.current = ids return api.loadChat({ projectId: ids.projectId, chatId: ids.chatId, limit: 200 }) @@ -599,9 +657,8 @@ export function AiPanel({ loopRef.current?.restore(msgs.map((m) => ({ role: m.role, text: m.text }))) }) .catch(() => { - throw new Error('Could not restore this document history. Reopen the document and retry.') + /* history load failures are silent */ }) - void sessionReadyRef.current.catch(() => {}) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -633,31 +690,27 @@ export function AiPanel({ attachments?: AttachmentMeta[], scope?: AiScopeQuoteData, ) => { - const idsAtSend = chatRefIds.current + const ids = chatRefIds.current const api = (window as Window & { projectApi?: typeof window.projectApi }).projectApi - if (!api) return - void (sessionReadyRef.current ?? Promise.resolve()) - .then(() => { - const ids = idsAtSend ?? chatRefIds.current - if (!ids) return - return api.appendChat({ - projectId: ids.projectId, - chatId: ids.chatId, - role, - text, - ...(tools && tools.length > 0 ? { tools } : {}), - ...(attachments && attachments.length > 0 - ? { - attachments: attachments.map((a) => ({ - name: a.name, - path: a.path, - ext: a.ext, - sizeBytes: a.sizeBytes, - })), - } - : {}), - ...(scope ? { scope } : {}), - }) + if (!ids || !api) return + void api + .appendChat({ + projectId: ids.projectId, + chatId: ids.chatId, + role, + text, + ...(tools && tools.length > 0 ? { tools } : {}), + ...(attachments && attachments.length > 0 + ? { + attachments: attachments.map((a) => ({ + name: a.name, + path: a.path, + ext: a.ext, + sizeBytes: a.sizeBytes, + })), + } + : {}), + ...(scope ? { scope } : {}), }) .catch(() => { /* silent */ @@ -672,7 +725,8 @@ export function AiPanel({ 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 + // bounded: this is where a finished run's full-document snapshot lands + return boundChatHistory(next) }) } @@ -762,17 +816,24 @@ export function AiPanel({ loopRef.current = new AgentLoop({ transport: transportRef.current, systemSuffix: aiLangDirective, - // Fork: stable per-document session id (project-store chatId = sha256 of file path). - // Sent as X-Hermes-Session-Id → the Hermes gateway keeps ONE session per document, - // so follow-up questions continue the same conversation instead of opening a new thread - // and re-reading the document from scratch. - waitForSession: () => sessionReadyRef.current ?? Promise.resolve(), - sessionId: () => chatRefIds.current?.chatId, - skill: composeSkills('docs+files', '', [ + skill: composeSkills('docs+files+skills', '', [ + // the reader's starred files; empty while the user stars nothing — + // buildContext then returns '' and the skill stays out of the way. + // Mounted only when the bridge carries the kb channel at all: a test + // mock or an older preload must not grow tools that cannot execute. + ...(typeof window.desktop?.kbList === 'function' + ? [ + createKnowledgeBaseSkill({ + listFiles: () => kbFilesRef.current, + search: (query, limit) => window.desktop.kbSearch({ q: query, limit }), + read: (path, offset) => window.desktop.kbRead(path, offset), + }), + ] + : []), createDocsSkill( () => editorRef.current, numIds, - () => (trackChangesRef.current ? { author: AI_REVISION_AUTHOR } : undefined), + () => (trackChangesRef.current ? { author: aiRevisionAuthor() } : undefined), () => commentsAccessRef.current, () => hfAccessRef.current, () => imageGenerationAvailable(settingsRef.current, gskLoggedInRef.current), @@ -785,6 +846,7 @@ export function AiPanel({ () => mediaAnalysisAvailable(settingsRef.current, gskLoggedInRef.current), ), createFilesSkill(availableAttachments), + createSkillsSkill(), ]), captureSnapshot: () => editorRef.current.getJSON() as PmNode, events: { @@ -806,6 +868,9 @@ export function AiPanel({ // tracking on: revisions stay pending, handled in the Review tab if (!trackChangesRef.current) clearAiHighlights(true) } + // mind maps ride the UI-only display side channel onto the message + const display = execution.display + const mindmap = display?.kind === 'mindmap' ? display.tree : undefined runToolsRef.current.push({ name: call.name, summary: execution.summary, @@ -831,12 +896,20 @@ export function AiPanel({ : undefined, }, ], + ...(mindmap ? { mindmap } : {}), } }) }, onTurnEnd: () => { patchLastAssistant({ streaming: false }) - setChat((prev) => [...prev, { role: 'assistant', text: '', streaming: true }]) + setChat((prev) => + boundChatHistory([...prev, { role: 'assistant', text: '', streaming: true }]), + ) + }, + onUsage: (usage) => { + runUsageRef.current = addTokenUsage(runUsageRef.current, usage) + setSessionUsage((prev) => addTokenUsage(prev, usage)) + patchLastAssistant({ usage: runUsageRef.current }) }, onDone: ({ text, cancelled, turnLimit, truncated }) => { // module-level t: the loop instance is created only once; the component's t goes stale with the first-render closure @@ -877,25 +950,27 @@ export function AiPanel({ snapshot: runSnapshotRef.current ?? undefined, } } - return next + return boundChatHistory(next) }) // Signed-out failures get an inline sign-in button; detected via - // gsk status rather than matching the localized error text - if (settingsRef.current.provider === 'genspark') - void window.desktop - .aiGskStatus() - .then((status) => { - if (status.loggedIn) return - setChat((prev) => { - const next = [...prev] - const last = next.at(-1) - if (last?.role === 'assistant' && last.error) { - next[next.length - 1] = { ...last, loginRequired: true } - } - return next - }) + // gsk status rather than matching the localized error text — but only + // when Genspark is the provider: a BYOK user's error (e.g. their own + // provider's rate limit) must not advertise a Genspark sign-in (hermesoffice#1948) + void window.desktop + .aiGskStatus() + .then((status) => { + if (status.loggedIn) return + if ((settingsRef.current?.provider ?? 'genspark') !== 'genspark') return + setChat((prev) => { + const next = [...prev] + const last = next.at(-1) + if (last?.role === 'assistant' && last.error) { + next[next.length - 1] = { ...last, loginRequired: true } + } + return next }) - .catch(() => {}) + }) + .catch(() => {}) setBusy(false) }, }, @@ -930,7 +1005,14 @@ export function AiPanel({ const liveSelection = editor.state.selection const selectionText = liveSelection.empty ? '' - : editor.state.doc.textBetween(liveSelection.from, liveSelection.to, '\n', ' ').trim() + : redactTextBetween( + editor.getJSON() as RedactableNode, + liveSelection.from, + liveSelection.to, + '\n', + ) + .replace(/\s+/g, ' ') + .trim() const hasScopeSelection = selectionText.length > 0 /** the × on the scope chip: collapse the selection so the run targets the whole document */ @@ -941,7 +1023,9 @@ export function AiPanel({ const selectionScopeQuote = (): AiScopeQuoteData | undefined => { const { from, to, empty } = editor.state.selection if (empty) return undefined - const text = editor.state.doc.textBetween(from, to, ' ', ' ').replace(/\s+/g, ' ').trim() + const text = redactTextBetween(editor.getJSON() as RedactableNode, from, to, ' ') + .replace(/\s+/g, ' ') + .trim() if (!text) return undefined return { label: t('aiScopeSelection', { words: countWords(text) }), @@ -969,6 +1053,10 @@ export function AiPanel({ if (index !== null) navigateToBlock(editorRef.current, index) }, } + // [name](filenav:///abs/path) citations open the cited file in its own app + const fileNav = createFileNav((path) => { + void window.desktop.openSourcePath(path) + }) // follow the stream, but stop yanking once the user scrolls up to read; // `open` dep: re-expanding lands on messages streamed while collapsed @@ -984,7 +1072,7 @@ export function AiPanel({ stickToBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48 } - const run = () => runWith(input.trim()) + const run = () => runWith(input.trim(), input.trim(), undefined, undefined, true) /** Image attachments are read as base64 and go multimodal with this user message (≤5MB per image, max 20) */ const MAX_IMAGES_PER_MESSAGE = 20 @@ -1016,6 +1104,8 @@ export function AiPanel({ attachmentsOverride?: AttachmentMeta[], /** null = a retry that had no scope; undefined = capture the live selection */ retryScope?: AiScopeQuoteData | null, + /** only the composer's own send takes the attached skills along */ + attachSkills = false, ) => { const loop = loopRef.current if (!instruction || !loop || loop.busy || pendingSendRef.current) return @@ -1032,6 +1122,9 @@ export function AiPanel({ setAttachments([]) } lastAttachmentsRef.current = sentAtts + // skills ride with this one message only: the chips clear like attachments do + const sentSkills = attachSkills ? attachedSkillsRef.current : [] + if (sentSkills.length > 0) setAttachedSkills([]) // the queue batch and the continue action carry their own display text: no selection quote const scope = retryScope !== undefined @@ -1046,17 +1139,21 @@ export function AiPanel({ lastInstructionRef.current = instruction runToolsRef.current = [] runSnapshotRef.current = null + runUsageRef.current = undefined stickToBottomRef.current = true - setChat((prev) => [ - ...prev, - { - role: 'user', - text: displayInstruction, - ...(sentAtts.length > 0 ? { attachments: sentAtts } : {}), - ...(scope ? { scope } : {}), - }, - { role: 'assistant', text: '', streaming: true }, - ]) + setChat((prev) => + boundChatHistory([ + ...prev, + { + role: 'user', + text: displayInstruction, + ...(sentAtts.length > 0 ? { attachments: sentAtts } : {}), + ...(sentSkills.length > 0 ? { skills: sentSkills } : {}), + ...(scope ? { scope } : {}), + }, + { role: 'assistant', text: '', streaming: true }, + ]), + ) runStartedAtRef.current = Date.now() setBusy(true) // claimed before the async image read so Stop / New chat can flag this send at any point @@ -1073,7 +1170,7 @@ export function AiPanel({ }) // a phased open still streaming its tail: the context must describe the whole document .then(async (images) => { - await waitForFullContent() + await Promise.all([waitForFullContent(), refreshKbFiles()]) // a newer send (after New chat) owns the panel now: leave its state alone if (pendingSendRef.current !== pending) return pendingSendRef.current = null @@ -1088,11 +1185,37 @@ export function AiPanel({ setBusy(false) return } - return loop.run(instruction, images) + return loop.run(await withSkillBodies(instruction, sentSkills), images) }) } + /** the attached skills' full text in front of the request; a skill that vanished is skipped */ + const withSkillBodies = async (instruction: string, names: readonly string[]) => { + if (names.length === 0) return instruction + const bodies = await Promise.all( + names.map(async (name) => { + try { + return { name, body: await window.desktop.skillText(name) } + } catch { + return null + } + }), + ) + return withAttachedSkills( + instruction, + bodies.filter((b): b is { name: string; body: string } => b !== null), + ) + } + + const listSkillsForMenu = async () => + (await window.desktop.skillsList()).map(({ name, description }) => ({ name, description })) + const toggleSkill = (name: string) => + setAttachedSkills((prev) => + prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name], + ) + const cancel = () => { + msgQueue.hold() if (pendingSendRef.current) pendingSendRef.current.aborted = true loopRef.current?.cancel() } @@ -1124,7 +1247,27 @@ export function AiPanel({ const continueRun = () => runWith(DOCS_CONTINUE_INSTRUCTION, t('aiContinue')) + /** Enter while a reply runs: the draft joins the queue above the composer */ + const enqueueDraft = () => { + const text = input.trim() + if (!text) return + // snapshot + consume the composer attachments exactly like a send does: the + // queued message owns them, and pump time hands them to runWith as an override + const attachments = attachmentsRef.current + if (attachments.length > 0) { + const seen = new Set(sentAttachmentsRef.current.map((a) => a.path)) + sentAttachmentsRef.current = [ + ...sentAttachmentsRef.current, + ...attachments.filter((a) => !seen.has(a.path)), + ] + setAttachments([]) + } + msgQueue.enqueue(text, { attachments, scope: selectionScopeQuote() ?? null }) + setInput('') + } + const newChat = () => { + msgQueue.clear() if (pendingSendRef.current) { pendingSendRef.current.aborted = true pendingSendRef.current = null @@ -1140,6 +1283,7 @@ export function AiPanel({ // file context (availableAttachments merges sent + live). The typed // draft itself is kept — only staged files are dropped. setAttachments([]) + setAttachedSkills([]) setAttachNotice(null) sentAttachmentsRef.current = [] inputRef.current?.focus() @@ -1191,17 +1335,9 @@ export function AiPanel({ const removeAttachment = (path: string) => setAttachments((prev) => prev.filter((a) => a.path !== path)) - const acceptChanges = () => { - applyRevisionsBy(editorRef.current, AI_REVISION_AUTHOR, 'accept') - clearAiHighlights() - } - const toggleTrackChanges = () => { - const next = !trackChanges - setTrackChanges(next) - localStorage.setItem(TRACK_CHANGES_KEY, next ? '1' : '0') - // switching off keeps nothing pending: accept whatever is still highlighted - if (!next) acceptChanges() + if (trackChangesLocked) return + onTrackChangesToggle?.() } const rollback = (entryIdx: number, snapshot: PmNode) => { @@ -1228,7 +1364,7 @@ export function AiPanel({ document.body.style.cursor = 'col-resize' document.body.style.userSelect = 'none' const onMove = (ev: PointerEvent) => { - const w = clampPanelWidth(aiPanelWidthAtPointer(ev.clientX)) + const w = clampAiPanelWidth(aiPanelWidthAtPointer(ev.clientX)) preferredWidthRef.current = w setPanelWidth(w) } @@ -1244,7 +1380,7 @@ export function AiPanel({ document.body.style.cursor = '' document.body.style.userSelect = '' setResizing(false) - localStorage.setItem(PANEL_WIDTH_KEY, String(Math.round(preferredWidthRef.current))) + saveAiPanelWidth(PANEL_WIDTH_KEY, preferredWidthRef.current) } resizeCleanupRef.current = cleanup window.addEventListener('pointermove', onMove) @@ -1257,6 +1393,8 @@ export function AiPanel({ // collapsed: rail only — after all hooks, so the instance and its state survive if (!open) { + // the sidebar setting is off: nothing to show until an explicit AI action opens the panel + if (hideRail) return null return (
    @@ -1404,6 +1545,9 @@ export function AiPanel({ {entry.role === 'user' && entry.attachments && entry.attachments.length > 0 && ( )} + {entry.role === 'user' && entry.skills && ( + + )} {entry.role === 'assistant' && !entry.text && entry.streaming ? ( ) : entry.role === 'assistant' ? (
    - +
    ) : ( {entry.text} )} + {entry.role === 'assistant' && entry.usage && ( +
    + +
    + )} {entry.tools && entry.tools.length > 0 && } + {entry.mindmap && ( + { + setInput(t('aiMindMapAskPrompt', { label })) + inputRef.current?.focus() + }} + onJumpBlock={(index) => navigateToBlock(editorRef.current, index)} + onInsert={(svg, size) => { + const editor = editorRef.current + if (!editor || !entry.mindmap) return + insertSvgAsPng(editor, svg, size, t('aiMindMapTitle')).catch((err: unknown) => + showToast(err instanceof Error ? err.message : String(err), 'error'), + ) + }} + /> + )} {entry.error && (
    {t('aiErrorPrefix', { error: entry.error })}
    )} @@ -1531,6 +1697,91 @@ export function AiPanel({
    )} + {revisionCount > 0 && ( +
    + + + + {revisionListOpen && reviewEntries.length > 0 && ( +
    + {reviewEntries.map((e, i) => { + const kind = e.rev.kind + const tone = REVISION_TONE[kind] ?? 'fmt' + const para = revisionParas[i] ?? 0 + return ( +
    onRevisionSelect?.(e.rev)} + > +
    + + {t(KIND_LABEL[kind])} + {e.rev.story ? ( + · {t(STORY_LABEL[e.rev.story.kind])} + ) : ( + para > 0 && · {t('aiRevisionPara', { n: para })} + )} +
    + {e.excerpt && ( +
    + {e.excerpt} +
    + )} +
    + + +
    +
    + ) + })} +
    + )} +
    + )} onQueueFocus?.(qid)} /> + } + onQueue={enqueueDraft} + queuePlaceholder={AI_QUEUE_LABELS[lang].queuePlaceholder} header={ - (hasScopeSelection || attachments.length > 0) && ( + (hasScopeSelection || attachments.length > 0 || attachedSkills.length > 0) && ( <> + {hasScopeSelection && (
    @@ -1664,21 +1930,26 @@ export function AiPanel({ onPasteFiles={(files) => void onPasteFiles(files)} footerStart={ <> + + {sessionUsage && } + void pickAttachments()} + onManage={() => void window.desktop.openSkillsSettings?.()} + /> - } diff --git a/apps/docs/src/renderer/ai/MindMapCard.tsx b/apps/docs/src/renderer/ai/MindMapCard.tsx new file mode 100644 index 000000000..cac253ce9 --- /dev/null +++ b/apps/docs/src/renderer/ai/MindMapCard.tsx @@ -0,0 +1,523 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { MindMapTreeNode } from '@hermesoffice/agent-core' +import { + countMindMapNodes, + layoutMindMap, + MIND_MAP_EXPORT_COLORS, + MIND_MAP_METRICS, + type MindMapOrientation, +} from '@hermesoffice/agent-core/mind-map' +import { useI18n } from '../i18n/locale' + +/** + * Interactive mind map card rendered inside an assistant message (the + * NotebookLM-style diagram): a self-drawn SVG tidy tree with collapsible + * branches, wheel zoom + drag pan, node-click-to-ask, block-citation jumps + * and PNG/SVG export. No diagram library — plain SVG keeps the bundle flat and + * gives full control over node-level interaction. + */ + +const ZOOM_MIN = 0.3 +const ZOOM_MAX = 2.5 + +/** Font stack baked into exported SVGs (the live DOM uses the stylesheet). */ +const EXPORT_FONT = "'Arial', 'Inter', -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif" + +interface MindMapCardProps { + tree: MindMapTreeNode + /** click a node: ask the assistant about that topic */ + onAsk: (label: string) => void + /** click a node's block badge: jump to the cited document block */ + onJumpBlock: (index: number) => void + /** insert button: hands over the palette-baked export SVG and its layout + * size; the receiver writes it into the document */ + onInsert?: (svg: string, size: { width: number; height: number }) => void +} + +export function MindMapCard({ tree, onAsk, onJumpBlock, onInsert }: MindMapCardProps) { + const { t } = useI18n() + const [collapsed, setCollapsed] = useState>(() => new Set()) + const [orientation, setOrientation] = useState('top-down') + const [zoom, setZoom] = useState(1) + const [pan, setPan] = useState({ x: 8, y: 8 }) + const viewportRef = useRef(null) + const svgRef = useRef(null) + const zoomRef = useRef(1) + const dragRef = useRef<{ id: number; x: number; y: number } | null>(null) + + const layout = useMemo( + () => layoutMindMap(tree, collapsed, { orientation }), + [tree, collapsed, orientation], + ) + const totalNodes = useMemo(() => countMindMapNodes(tree), [tree]) + + const fitView = useCallback(() => { + const vp = viewportRef.current + zoomRef.current = 1 + setZoom(1) + setPan({ x: 8, y: vp ? Math.max(8, (vp.clientHeight - layout.height) / 2) : 8 }) + }, [layout.height]) + + // fit once per new map (collapse toggles keep the user's view) + const lastTreeRef = useRef(null) + useEffect(() => { + if (lastTreeRef.current === tree) return + lastTreeRef.current = tree + fitView() + }, [tree, fitView]) + + // Ctrl/Cmd+wheel zooms around the cursor (Chromium reports a trackpad pinch + // as ctrl+wheel) — the same gesture the editor canvas uses. A plain wheel is + // left alone so the chat panel still scrolls past the card; panning is drag. + useEffect(() => { + const vp = viewportRef.current + if (!vp) return + const onWheel = (e: WheelEvent) => { + if (!e.ctrlKey && !e.metaKey) return + e.preventDefault() + const rect = vp.getBoundingClientRect() + const ox = e.clientX - rect.left + const oy = e.clientY - rect.top + const next = Math.min( + ZOOM_MAX, + Math.max(ZOOM_MIN, zoomRef.current * Math.exp(-e.deltaY * 0.0015)), + ) + const k = next / zoomRef.current + zoomRef.current = next + setZoom(next) + setPan((p) => ({ x: ox - (ox - p.x) * k, y: oy - (oy - p.y) * k })) + } + vp.addEventListener('wheel', onWheel, { passive: false }) + return () => vp.removeEventListener('wheel', onWheel) + }, []) + + const toggleCollapse = (id: string) => + setCollapsed((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + + const onPointerDown = (e: React.PointerEvent) => { + // dragging starts on the background only; nodes handle their own clicks + if ((e.target as Element).closest('.ai-mindmap-node')) return + dragRef.current = { id: e.pointerId, x: e.clientX, y: e.clientY } + e.currentTarget.setPointerCapture(e.pointerId) + } + const onPointerMove = (e: React.PointerEvent) => { + const d = dragRef.current + if (!d || d.id !== e.pointerId) return + setPan((p) => ({ x: p.x + (e.clientX - d.x), y: p.y + (e.clientY - d.y) })) + dragRef.current = { ...d, x: e.clientX, y: e.clientY } + } + const onPointerUp = (e: React.PointerEvent) => { + if (dragRef.current?.id === e.pointerId) dragRef.current = null + } + + /** Serialize the live SVG into the standalone export file: the fixed palette + * baked in over the data-export attributes, white background, font stack + * inline. Both exports are built from this one file. */ + const serializeExportSvg = (svg: SVGSVGElement): string => { + const clone = svg.cloneNode(true) as SVGSVGElement + clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg') + clone.setAttribute('font-family', EXPORT_FONT) + const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect') + bg.setAttribute('x', '0') + bg.setAttribute('y', '0') + bg.setAttribute('width', String(layout.width)) + bg.setAttribute('height', String(layout.height)) + bg.setAttribute('fill', MIND_MAP_EXPORT_COLORS.background) + clone.insertBefore(bg, clone.firstChild) + const c = MIND_MAP_EXPORT_COLORS + for (const el of Array.from(clone.querySelectorAll('[data-export]'))) { + const kind = el.getAttribute('data-export') + const tag = el.tagName.toLowerCase() + const set = (attrs: Record) => { + for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v) + } + if (kind === 'edge' && tag === 'path') { + set({ fill: 'none', stroke: c.edge, 'stroke-width': '1.5' }) + } else if (kind === 'node-box') { + set({ fill: c.nodeFill, stroke: c.nodeStroke, 'stroke-width': '1' }) + } else if (kind === 'root-box') { + set({ fill: c.rootFill, stroke: c.rootStroke, 'stroke-width': '1' }) + } else if (kind === 'node-text') { + set({ fill: c.nodeText, 'font-size': '12.5' }) + } else if (kind === 'root-text') { + set({ fill: c.rootText, 'font-size': '12.5' }) + } else if (kind === 'badge') { + set({ fill: c.badgeFill }) + } else if (kind === 'badge-text') { + set({ fill: c.badgeText, 'font-size': '10' }) + } else if (kind === 'toggle') { + set({ fill: c.toggleFill, stroke: c.toggleStroke, 'stroke-width': '1' }) + } else if (kind === 'toggle-text') { + set({ fill: c.toggleText, 'font-size': '10' }) + } + } + return new XMLSerializer().serializeToString(clone) + } + + const downloadBlob = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) + } + + /** Vector export: the same baked file the PNG rasterizes, kept editable. */ + const exportSvg = () => { + const svg = svgRef.current + if (!svg) return + downloadBlob( + new Blob([serializeExportSvg(svg)], { type: 'image/svg+xml;charset=utf-8' }), + 'mindmap.svg', + ) + } + + /** Raster export: render the baked SVG at 2x and download the PNG. */ + const exportPng = () => { + const svg = svgRef.current + if (!svg) return + const svgUrl = URL.createObjectURL( + new Blob([serializeExportSvg(svg)], { type: 'image/svg+xml;charset=utf-8' }), + ) + const img = new Image() + img.onload = () => { + URL.revokeObjectURL(svgUrl) + const scale = 2 + const canvas = document.createElement('canvas') + canvas.width = Math.round(layout.width * scale) + canvas.height = Math.round(layout.height * scale) + const ctx = canvas.getContext('2d') + if (!ctx) return + ctx.scale(scale, scale) + ctx.drawImage(img, 0, 0) + canvas.toBlob((blob) => { + if (blob) downloadBlob(blob, 'mindmap.png') + }, 'image/png') + } + img.onerror = () => URL.revokeObjectURL(svgUrl) + img.src = svgUrl + } + + const { nodeWidth: W, nodeHeight: H } = MIND_MAP_METRICS + + return ( +
    +
    + {t('aiMindMapTitle')} + {t('aiMindMapNodes', { count: totalNodes })} + + + + + + {onInsert && ( + + )} + +
    +
    +
    + + + {layout.edges.map((edge) => ( + + ))} + + {layout.nodes.map((n) => { + const isRoot = n.depth === 0 + const labelX = n.block !== undefined ? 38 : 12 + const firstY = n.lines.length > 1 ? 12 : H / 2 + return ( + onAsk(n.label)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onAsk(n.label) + } + }} + > + + {n.block !== undefined && ( + { + e.stopPropagation() + onJumpBlock(n.block as number) + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + e.stopPropagation() + onJumpBlock(n.block as number) + } + }} + > + + + {n.block} + + + )} + + {n.lines.map((line, i) => ( + + {line} + + ))} + + {n.hasChildren && ( + { + e.stopPropagation() + toggleCollapse(n.id) + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + e.stopPropagation() + toggleCollapse(n.id) + } + }} + > + {n.collapsed ? ( + <> + + + {`+${n.descendants}`} + + + ) : ( + <> + + + − + + + )} + + )} + + ) + })} + +
    +
    +
    + ) +} diff --git a/apps/docs/src/renderer/ai/chat-retention.ts b/apps/docs/src/renderer/ai/chat-retention.ts new file mode 100644 index 000000000..4d1c84031 --- /dev/null +++ b/apps/docs/src/renderer/ai/chat-retention.ts @@ -0,0 +1,55 @@ +/** + * Retention policy for the AI panel's live chat transcript. + * + * Every completed run pins a full-document snapshot (the whole ProseMirror + * document as JSON) onto its chat entry, and the inline roll-back action + * renders from that field. Nothing ever released them, so the transcript grew + * without limit: a 122-page document over 20 turns of conversation held + * hundreds of megabytes of document copies in React state for the whole + * session. + * + * Policy: the transcript keeps its text for every retained turn, but only the + * newest {@link MAX_ROLLBACK_SNAPSHOTS} runs keep their snapshot, so one + * roll-back still undoes any recent run. Older snapshots are set to + * `undefined` rather than removed from the entry — the panel renders its + * roll-back button under `entry.snapshot &&`, so a released snapshot also + * removes the action and no button can ever point at a snapshot that is gone. + */ + +/** Runs whose roll-back point is kept. The sibling html/markdown panels cap at 20 string snapshots; a document JSON tree is far heavier, so this is tighter. */ +export const MAX_ROLLBACK_SNAPSHOTS = 5 + +/** Entries retained in memory. Each turn adds a user entry plus one or more assistant entries. */ +export const MAX_CHAT_ENTRIES = 60 + +/** + * Bound a chat array: drop the oldest entries past {@link MAX_CHAT_ENTRIES}, + * then release every snapshot beyond the newest {@link MAX_ROLLBACK_SNAPSHOTS}. + * + * Returns the input array itself when nothing needed trimming, so the common + * case (an append or a delta on a small transcript) does not re-render the log. + */ +export function boundChatHistory(entries: readonly T[]): T[] { + const kept = + entries.length > MAX_CHAT_ENTRIES ? entries.slice(entries.length - MAX_CHAT_ENTRIES) : entries + // walk from the newest end: the first MAX_ROLLBACK_SNAPSHOTS snapshots survive + let keptSnapshots = 0 + let released = false + const out: T[] = new Array(kept.length) + for (let i = kept.length - 1; i >= 0; i--) { + const entry = kept[i]! + if (entry.snapshot !== undefined) { + keptSnapshots++ + if (keptSnapshots > MAX_ROLLBACK_SNAPSHOTS) { + // clearing rather than deleting: the entry keeps its text and tools, + // and the render gate that offers roll-back also hides the button + out[i] = { ...entry, snapshot: undefined } as T + released = true + continue + } + } + out[i] = entry + } + if (released || kept !== entries) return out + return entries as T[] +} diff --git a/apps/docs/src/renderer/ai/comment-ops.ts b/apps/docs/src/renderer/ai/comment-ops.ts index 9e44763fd..df8576607 100644 --- a/apps/docs/src/renderer/ai/comment-ops.ts +++ b/apps/docs/src/renderer/ai/comment-ops.ts @@ -126,7 +126,8 @@ export function resolveCommentAnchor(editor: Editor, input: CommentAnchorInput): return { from: runs[0]!.pos, to: last.pos + (last.textEnd - last.textStart), - excerpt: liveText(anchorBlock.node), + // a person reads this: a comment preview must show the real words + excerpt: liveText(anchorBlock.node, true), } } if (typeof input.text !== 'string') return { error: 'text must be a string' } diff --git a/apps/docs/src/renderer/ai/doc-utils.ts b/apps/docs/src/renderer/ai/doc-utils.ts index 65adcbcc1..70290669d 100644 --- a/apps/docs/src/renderer/ai/doc-utils.ts +++ b/apps/docs/src/renderer/ai/doc-utils.ts @@ -23,20 +23,71 @@ export function blockRangePositions( const hasDelMark = (node: ProseMirrorNode) => node.marks.some((m) => m.type.name === 'del') -/** block text as it reads once pending tracked deletions are applied (textContent minus del runs) */ -export function liveText(node: ProseMirrorNode): string { +/** + * Block text as it reads once pending tracked deletions are applied + * (textContent minus del runs) and, by default, with withheld spans replaced + * by their markers. + * + * Most callers are building something for a model, and a span exists precisely + * so its words do not travel. Redacting here rather than at each call site + * matters because the failure is silent and total: one caller left reading the + * raw text hands over the whole document. `forDisplay` opts out for the two + * places a person is the reader — a comment preview, and a field's own value. + */ +export function liveText( + node: ProseMirrorNode, + forDisplay = false, + options: { + /// Insert before each textblock, the way textBetween's blockSeparator does: + /// word counting needs it so neighbouring blocks' words never merge. + blockSeparator?: string + /// Text an atomic inline leaf contributes (word counting reads inline + /// formulas and ruby bases; plain prose never supplies one). + leafText?: (leaf: ProseMirrorNode) => string + } = {}, +): string { if ((node.attrs?.blockRevision as { kind?: string } | null)?.kind === 'del') return '' + const leafText = + options.leafText ?? ((leaf: ProseMirrorNode) => leaf.type.spec.leafText?.(leaf) ?? '') let out = '' + // separators go *between* textblocks inside this node; the caller already + // placed one between this node and its preceding sibling + let firstTextblock = true const walk = (child: ProseMirrorNode): void => { if (hasDelMark(child)) return + if (!forDisplay && hasRedactMark(child)) { + out += redactMarkerFor(child) + return + } if (child.isText) out += child.text ?? '' - else if (child.isLeaf) out += child.type.spec.leafText?.(child) ?? '' - else child.forEach(walk) + else if (child.isLeaf) { + // a picture carries no text; the redaction marker was handled above + out += leafText(child) + } else { + // textBetween separates textblocks, not every container (table rows and + // cells sit between a table and its paragraphs without a separator) + if (child.isTextblock) { + if (options.blockSeparator !== undefined && !firstTextblock) out += options.blockSeparator + firstTextblock = false + } + child.forEach(walk) + } } node.forEach(walk) return out } +const hasRedactMark = (node: ProseMirrorNode): boolean => + node.marks.some((m) => m.type.name === 'redaction') + +const redactMarkerFor = (node: ProseMirrorNode): string => { + const attrs = node.marks.find((m) => m.type.name === 'redaction')?.attrs as + { label?: unknown } | undefined + return typeof attrs?.label === 'string' && attrs.label.trim() !== '' + ? `{{${attrs.label.trim()}}}` + : '{{private}}' +} + /** * The whole block is a pending deletion revision (struck through in the editor). * Checked structurally, not by text length: atom leaves (inline formulas, ruby) diff --git a/apps/docs/src/renderer/ai/docs-skill.ts b/apps/docs/src/renderer/ai/docs-skill.ts index db10d460c..918e29f04 100644 --- a/apps/docs/src/renderer/ai/docs-skill.ts +++ b/apps/docs/src/renderer/ai/docs-skill.ts @@ -1,3 +1,5 @@ +import { placeholderInstruction } from './redact' +import { redactLabelsOf } from './redact-view' import type { Editor } from '@tiptap/core' import type { AgentSkill } from '@hermesoffice/agent-core' import { @@ -67,7 +69,15 @@ export function createDocsSkill( id: 'docx', // live: the predicate is re-read before every model request get systemPrompt() { - return AGENT_SYSTEM_PROMPT + mediaOffNote() + // only when the open document actually withholds something, so a + // document without spans pays nothing and the model is not told to + // look for markers that are not there + const editor = getEditor() + const labels = editor + ? redactLabelsOf(editor.getJSON() as unknown as Parameters[0]) + : [] + const redact = labels.length > 0 ? `\n\n${placeholderInstruction(labels)}` : '' + return AGENT_SYSTEM_PROMPT + mediaOffNote() + redact }, get tools() { const hidden = mediaToolsOff() diff --git a/apps/docs/src/renderer/ai/edit-queue.ts b/apps/docs/src/renderer/ai/edit-queue.ts index 81fbd7677..c61d3c289 100644 --- a/apps/docs/src/renderer/ai/edit-queue.ts +++ b/apps/docs/src/renderer/ai/edit-queue.ts @@ -1,6 +1,7 @@ import type { Editor } from '@tiptap/core' import { NodeSelection, TextSelection, type Selection } from '@tiptap/pm/state' import { queueAnchorRange } from '../editor/ai-queue-anchors' +import { redactTextBetween } from './redact-view' /** * Selection-scoped AI edit queue: the user annotates passages with short @@ -60,8 +61,9 @@ export function resolveQueueItem(editor: Editor, item: DocsEditQueueItem): Resol const range = queueAnchorRange(editor.state, item.qid) if (!range) return { item, target: null } const indexes = blockRangeOfPositions(editor, range.from, range.to) - const text = editor.state.doc - .textBetween(range.from, range.to, '\n', ' ') + // the excerpt labels a queued edit, and the label is shown to the model; + // an anchor that covered a withheld span would carry the words along + const text = redactTextBetween(editor.getJSON(), range.from, range.to, ' ') .replace(/\s+/g, ' ') .trim() // a textless anchor (image/chart node selection) is still a live target — diff --git a/apps/docs/src/renderer/ai/files-skill.ts b/apps/docs/src/renderer/ai/files-skill.ts index fd6b20351..0c6c7d89c 100644 --- a/apps/docs/src/renderer/ai/files-skill.ts +++ b/apps/docs/src/renderer/ai/files-skill.ts @@ -1,99 +1,24 @@ -import type { AgentSkill } from '@hermesoffice/agent-core' +import { createAttachmentsSkill, type AgentSkill } from '@hermesoffice/agent-core' import type { AttachmentMeta } from '../../shared/ipc' -import { ATTACHMENT_IMAGE_EXTS } from '../../shared/ipc' import { t } from '../i18n/locale' -/** - * Chat-attachment capability as an AgentSkill: the per-turn context lists the - * attached local files, and read_attachment pages through their extracted - * text (parsing happens in the main process; files never leave the machine). - */ - -const READ_CHUNK_CHARS = 24_000 - -const FILES_SYSTEM_PROMPT = `## Attachments -The user may attach local files to the conversation (see the "attachment list" in each turn's context). -- When the user's request involves attachment content, read it with read_attachment first, then answer or write; do not guess content from file names. -- Long files are read in pages: the result reports the total character count and the current range; to continue, set offset to the end position of the previous slice. -- Image attachments (png/jpg/gif/webp) are already sent as images with the user message — just look at them; read_attachment is only for text-like attachments. -- Do not call read_attachment when there are no attachments or they are unrelated to the request.` - -function formatSize(bytes: number): string { - if (bytes < 1024) return `${bytes}B` - if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB` - return `${(bytes / 1024 / 1024).toFixed(1)}MB` -} - +/** the shared attachments skill over the Docs attachment IPC (parsing happens in the main process) */ export function createFilesSkill(getAttachments: () => AttachmentMeta[]): AgentSkill { - return { - id: 'files', - systemPrompt: FILES_SYSTEM_PROMPT, - tools: [ - { - name: 'read_attachment', - description: - 'Read the text content of an attachment (parsed locally). Long files are paged: read offset=0 first, then decide whether to continue based on the returned total character count.', - inputSchema: { - type: 'object', - properties: { - index: { - type: 'integer', - description: 'attachment index (0-based, see the attachment list)', - }, - offset: { type: 'integer', description: 'start character position, default 0' }, - }, - required: ['index'], - }, - }, - ], - buildContext: () => { - const list = getAttachments() - if (list.length === 0) return '' - const lines = list.map((a, i) => `${i} | ${a.name} | .${a.ext} | ${formatSize(a.sizeBytes)}`) - return `Attachment list (index | file name | type | size):\n${lines.join('\n')}` - }, - executeTool: async (call) => { - if (call.name !== 'read_attachment') { - return { output: `unknown tool: ${call.name}`, isError: true, summary: call.name } - } - const list = getAttachments() - const index = Number(call.input.index) - const att = Number.isInteger(index) ? list[index] : undefined - if (!att) { - return { - output: 'invalid attachment index (see the attachment list)', - isError: true, - summary: t('aiSumReadAttachment'), - } - } - // no text extraction for images: already provided as a multimodal image with the user message on send - if (ATTACHMENT_IMAGE_EXTS.has(att.ext)) { - return { - output: `${att.name} is an image attachment already sent as an image with the user message; just look at the image in the message, no text to read.`, - mutated: false, - summary: t('aiSumImageAttachment', { name: att.name }), - } - } - const offset = Math.max(0, Number(call.input.offset) || 0) - const result = await window.desktop.readAttachment(att.path, offset, READ_CHUNK_CHARS) - if (!result.ok) { - return { - output: result.error ?? 'read failed', - isError: true, - summary: t('aiSumRead', { name: att.name }), - } - } - const end = (result.offset ?? 0) + (result.text?.length ?? 0) - const header = `File ${att.name}, total characters ${result.totalChars}, this slice ${result.offset}-${end}${ - end < (result.totalChars ?? 0) - ? ' (not finished, continue with offset=' + end + ')' - : ' (end of file)' - }` + return createAttachmentsSkill({ + list: getAttachments, + read: async (path, offset, maxChars) => { + const result = await window.desktop.readAttachment(path, offset, maxChars) + if (!result.ok) throw new Error(result.error ?? 'read failed') return { - output: `${header}\n---\n${result.text ?? ''}`, - mutated: false, - summary: t('aiSumReadAttachmentName', { name: att.name }), + text: result.text ?? '', + totalChars: result.totalChars ?? 0, + offset: result.offset ?? 0, } }, - } + labels: { + read: (name) => t('aiSumReadAttachmentName', { name }), + image: (name) => t('aiSumImageAttachment', { name }), + invalid: t('aiSumReadAttachment'), + }, + }) } diff --git a/apps/docs/src/renderer/ai/floating-ops.ts b/apps/docs/src/renderer/ai/floating-ops.ts index a2d5d9317..193f8cd15 100644 --- a/apps/docs/src/renderer/ai/floating-ops.ts +++ b/apps/docs/src/renderer/ai/floating-ops.ts @@ -12,6 +12,7 @@ import { import type { AgentToolDef } from '../../shared/ipc' import { blockRangePositions } from './doc-utils' import { emuToPx, parseEmu, parsePoints } from './lengths' +import { MAX_FONT_SIZE_PT, MIN_FONT_SIZE_PT } from './ops' /** App- or CLI-owned watermark state; the header part is rewritten on save. */ export interface AiWatermarkAccess { @@ -327,7 +328,10 @@ export const INSERT_TEXT_BOX_TOOL: AgentToolDef = { type: ['string', 'null'], description: '"#RRGGBB" outline (default black); null = no outline', }, - fontSize: { type: 'number', description: 'text size in points' }, + fontSize: { + type: ['number', 'string'], + description: 'text size in points (a bare number, or a length like "12pt")', + }, align: { type: 'string', enum: ['left', 'center', 'right', 'justify'] }, bold: { type: 'boolean' }, color: { type: 'string', description: '"#RRGGBB" text color' }, @@ -373,8 +377,8 @@ export function textBoxNode( const run: Run = { text: '' } if (input.fontSize !== undefined) { const pt = parsePoints(input.fontSize) - if (pt === undefined || pt <= 0) - return { error: 'fontSize must be a positive number of points' } + if (pt === undefined || pt < MIN_FONT_SIZE_PT || pt > MAX_FONT_SIZE_PT) + return { error: `fontSize must be ${MIN_FONT_SIZE_PT}-${MAX_FONT_SIZE_PT}pt` } run.sizeHalfPoints = Math.round(pt * 2) } if (input.bold === true) run.bold = true diff --git a/apps/docs/src/renderer/ai/inherit-formatting.ts b/apps/docs/src/renderer/ai/inherit-formatting.ts index f221823a8..125c697ec 100644 --- a/apps/docs/src/renderer/ai/inherit-formatting.ts +++ b/apps/docs/src/renderer/ai/inherit-formatting.ts @@ -300,11 +300,10 @@ function inheritCell( ): PmNode { const attrs: Record = mode === 'full' ? { ...oldJson.attrs } : { ...(next.attrs ?? {}) } + // the old cell's look wins outright, null included: the parser's own defaults + // (new-table vAlign, synthetic header fill) must not restyle a rewritten table if (mode === 'style') { - for (const key of CELL_STYLE_ATTRS) { - const value = oldJson.attrs?.[key] - if (value != null) attrs[key] = value - } + for (const key of CELL_STYLE_ATTRS) attrs[key] = oldJson.attrs?.[key] ?? null } return { type: oldJson.type, attrs, content: inheritCellContent(old, oldJson, next) } } diff --git a/apps/docs/src/renderer/ai/lengths.ts b/apps/docs/src/renderer/ai/lengths.ts index 12671ae6c..4af17eff8 100644 --- a/apps/docs/src/renderer/ai/lengths.ts +++ b/apps/docs/src/renderer/ai/lengths.ts @@ -11,17 +11,30 @@ const EMU_PER: Record = { const LENGTH = /^\s*(-?\d+(?:\.\d+)?)\s*(emu|twips?|pt|px|in|cm|mm)\s*$/i +/** + * Largest magnitude a parsed length may have (~35in / ~89cm in EMU). + * AI-supplied lengths are untrusted: uncapped, "99999999in" becomes ~1e14 + * twips and breaks layout + save round-trip. + */ +export const MAX_LENGTH_EMU = 32_000_000 + +function boundEmu(emu: number): number | undefined { + if (!Number.isFinite(emu)) return undefined + const rounded = Math.round(emu) + return Math.abs(rounded) <= MAX_LENGTH_EMU ? rounded : undefined +} + /** "2.54cm" / "1in" / "72pt" / "96px" / a bare number in `bareUnit` -> EMU; undefined when unparseable */ export function parseEmu( value: unknown, bareUnit: keyof typeof EMU_PER = 'emu', ): number | undefined { if (typeof value === 'number') - return Number.isFinite(value) ? Math.round(value * EMU_PER[bareUnit]!) : undefined + return Number.isFinite(value) ? boundEmu(value * EMU_PER[bareUnit]!) : undefined if (typeof value !== 'string') return undefined const m = LENGTH.exec(value) if (!m) return undefined - return Math.round(Number(m[1]) * EMU_PER[m[2]!.toLowerCase()]!) + return boundEmu(Number(m[1]) * EMU_PER[m[2]!.toLowerCase()]!) } /** length -> twips; bare numbers are twips */ diff --git a/apps/docs/src/renderer/ai/note-ops.ts b/apps/docs/src/renderer/ai/note-ops.ts index 828061a18..dd9363932 100644 --- a/apps/docs/src/renderer/ai/note-ops.ts +++ b/apps/docs/src/renderer/ai/note-ops.ts @@ -1,7 +1,7 @@ import type { Editor } from '@tiptap/core' import type { Node as PmNode } from '@tiptap/pm/model' import type { Transaction } from '@tiptap/pm/state' -import type { NoteInfo } from '@hermesoffice/docx-engine' +import type { NoteInfo, NoteRun } from '@hermesoffice/docx-engine' import { posAfterText } from './after-text' export type NoteKind = 'footnote' | 'endnote' @@ -17,10 +17,95 @@ export interface AiNotesAccess { add(kind: NoteKind, text: string): string /** false when no note has that id */ remove(kind: NoteKind, id: string): boolean + /** swaps the note with that id for `next` (same id); false when no note has that id */ + replace(kind: NoteKind, id: string, next: NoteInfo): boolean /** block index of a protected block whose XML still holds the reference mark (nothing here can remove it) */ protectedMarkBlock?(kind: NoteKind, id: string): number | null } +/** every occurrence of `find` in `text`, honouring matchCase */ +function occurrences(text: string, find: string, matchCase: boolean): number[] { + const hay = matchCase ? text : text.toLowerCase() + const needle = matchCase ? find : find.toLowerCase() + const out: number[] = [] + let at = hay.indexOf(needle) + while (at !== -1) { + out.push(at) + at = hay.indexOf(needle, at + needle.length) + } + return out +} + +function replaceAll(text: string, find: string, replace: string, matchCase: boolean): string { + const hits = occurrences(text, find, matchCase) + let out = '' + let cursor = 0 + for (const at of hits) { + out += text.slice(cursor, at) + replace + cursor = at + find.length + } + return out + text.slice(cursor) +} + +/** + * findReplace inside one paragraph's display runs: a match that spans several runs lands in the + * first run's formatting, the covered tail of the later runs goes. Returns how many matched. + */ +function replaceInRuns(runs: NoteRun[], find: string, replace: string, matchCase: boolean): number { + const joined = runs.map((r) => r.text).join('') + const hits = occurrences(joined, find, matchCase) + for (const at of [...hits].reverse()) { + const end = at + find.length + let offset = 0 + let first = -1 + for (let i = 0; i < runs.length; i++) { + const run = runs[i]! + const runStart = offset + const runEnd = offset + run.text.length + offset = runEnd + if (runEnd <= at || runStart >= end) continue + const head = run.text.slice(0, Math.max(0, at - runStart)) + const tail = run.text.slice(Math.min(run.text.length, end - runStart)) + if (first === -1) { + first = i + run.text = head + replace + tail + } else { + run.text = head + tail + } + } + } + return hits.length +} + +/** + * The note with `find` swapped for `replace` throughout (findReplace semantics). Display runs are + * patched in place when they agree with the text; otherwise the plain text alone carries the + * change and the saved part is re-patched from it at the w:t level. + */ +export function editNoteText( + note: NoteInfo, + find: string, + replace: string, + matchCase = true, +): { note: NoteInfo; count: number } { + const count = occurrences(note.text, find, matchCase).length + if (count === 0) return { note, count } + const next: NoteInfo = { ...note, text: replaceAll(note.text, find, replace, matchCase) } + // runs are per paragraph: a match or replacement crossing a paragraph break cannot be patched in them + if (note.richParas && !find.includes('\n') && !replace.includes('\n')) { + const richParas = note.richParas.map((runs) => runs.map((r) => ({ ...r }))) + const patched = richParas.reduce( + (n, runs) => n + replaceInRuns(runs, find, replace, matchCase), + 0, + ) + if (patched === count) next.richParas = richParas + else delete next.richParas + } else { + delete next.richParas + } + return { note: next, count } +} + export interface NoteAnchor { blockIndex: number pos: number @@ -82,7 +167,8 @@ export function noteInsertPos( return posAfterText(block, start + 1, afterText, `block ${blockIndex}`) } -function renumber(tr: Transaction, kind: NoteKind): void { +/** Rewrite every reference mark of `kind` in `tr` to its 1-based document-order number. */ +export function renumber(tr: Transaction, kind: NoteKind): void { let num = 0 tr.doc.descendants((node, pos) => { if (node.type.name !== 'docNoteRef' || node.attrs.kind !== kind) return true diff --git a/apps/docs/src/renderer/ai/ops.ts b/apps/docs/src/renderer/ai/ops.ts index 0ce8e2796..82d51c507 100644 --- a/apps/docs/src/renderer/ai/ops.ts +++ b/apps/docs/src/renderer/ai/ops.ts @@ -1,10 +1,12 @@ +import { checkPlaceholders, placeholderSource, REDACT_MARK } from './redact' import type { Editor } from '@tiptap/core' import type { Node as PmDocNode, Schema } from '@tiptap/pm/model' import type { Transaction } from '@tiptap/pm/state' -import { generateTocFieldXml, type TocEntry } from '@hermesoffice/docx-engine' +import { generateTocFieldXml, type TabStop, type TocEntry } from '@hermesoffice/docx-engine' import { isEastAsianFontName } from '../font-list' import { t } from '../i18n/locale' import { blockRangePositions, isTrackedDeleted, liveText } from './doc-utils' +import { tabSpecsToStops, validateTabs, type TabSpec } from './tab-stops' import { styleOpDefs, type AiStyleAccess } from './style-ops' import { fieldOpDefs } from './field-ops' import { tableOpDefs } from './table-ops' @@ -66,6 +68,8 @@ export interface ParagraphFields { shadingFill?: string | null /** Paragraph borders, a subset of "tblr" */ borders?: string | null + /** the paragraph's own tab stops; [] or null clears them, a `clear` entry cancels a style stop */ + tabs?: TabSpec[] | null } export interface Op { @@ -201,6 +205,7 @@ const PARA_KEYS = [ 'pageBreakBefore', 'shadingFill', 'borders', + 'tabs', ] as const const IMAGE_KEYS = ['widthPx', 'heightPx', 'align'] as const @@ -234,15 +239,21 @@ function validateTarget(target: unknown, where: string): string | null { typeof tg.containsText === 'string' || (Array.isArray(tg.blockIndexes) && tg.blockIndexes.length > 0) || tg.scope === 'selection' || + tg.scope === 'document' || tg.range !== undefined - if (!hasCondition) return `${where}: target requires at least one condition` + if (!hasCondition) + return `${where}: target requires at least one condition (use {"scope":"document"} for the whole document)` return null } +const isEmptyTarget = (v: unknown): boolean => + !!v && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0 + function validateShape(op: Op, def: OpDef, where: string): string | null { if (def.target === 'none' && op.target !== undefined) { return `${where}: does not take a target` } + if (def.target === 'optional' && isEmptyTarget(op.target)) delete op.target if (def.target === 'required' || op.target !== undefined) { const error = validateTarget(op.target, where) if (error) return error @@ -285,6 +296,12 @@ const isNumberOrNull = (v: unknown) => v === null || (typeof v === 'number' && N /** a present number must be positive; null (= clear) passes */ const isPositiveOrNull = (v: unknown) => v === null || (typeof v === 'number' && Number.isFinite(v) && v > 0) +/** Word's w:sz range is 2..3276 half-points. */ +export const MIN_FONT_SIZE_PT = 1 +export const MAX_FONT_SIZE_PT = 1638 +const isFontSizeOrNull = (v: unknown) => + v === null || + (typeof v === 'number' && Number.isFinite(v) && v >= MIN_FONT_SIZE_PT && v <= MAX_FONT_SIZE_PT) const isStringOrNull = (v: unknown) => v === null || typeof v === 'string' function validateFontFields(op: Op, where: string): string | null { @@ -298,8 +315,8 @@ function validateFontFields(op: Op, where: string): string | null { if (op.highlight !== undefined && !isStringOrNull(op.highlight)) { return `${where}: highlight must be a color name / hex string or null` } - if (op.fontSize !== undefined && !isPositiveOrNull(op.fontSize)) { - return `${where}: fontSize must be a positive number of points (or null to clear)` + if (op.fontSize !== undefined && !isFontSizeOrNull(op.fontSize)) { + return `${where}: fontSize must be ${MIN_FONT_SIZE_PT}-${MAX_FONT_SIZE_PT}pt (or null to clear)` } if (op.fontFamily !== undefined && !isStringOrNull(op.fontFamily)) { return `${where}: fontFamily must be a string or null` @@ -354,12 +371,34 @@ function validateParagraphFields(op: Op, where: string): string | null { return `${where}: borders must be a subset of "tblr" (or null)` } } + if (op.tabs !== undefined) { + const error = validateTabs(op.tabs, where) + if (error) return error + } if (present(op, PARA_KEYS).length === 0) { return `${where}: give at least one paragraph field (${PARA_KEYS.join(', ')})` } return null } +/** the paragraph's tabStops attr with its own stops replaced; display-only (style-inherited, + * w:ptab) entries survive except where a new stop or a clear sits at their position */ +function mergeTabStopsAttr(existing: unknown, direct: TabStop[]): string { + let displayOnly: TabStop[] = [] + if (typeof existing === 'string') { + try { + const parsed: unknown = JSON.parse(existing) + if (Array.isArray(parsed)) + displayOnly = (parsed as TabStop[]).filter((s) => s.inherited || s.rel) + } catch { + /* malformed attr: no display-only stops */ + } + } + const taken = new Set(direct.map((s) => s.pos)) + const kept = displayOnly.filter((s) => !taken.has(s.pos)) + return JSON.stringify([...direct, ...kept].sort((a, b) => a.pos - b.pos)) +} + function validateAfterBlockIndex(op: Op, where: string): string | null { if (!Number.isInteger(op.afterBlockIndex) || Number(op.afterBlockIndex) < -1) { return `${where}: afterBlockIndex must be an integer ≥ -1 (-1 = start of document)` @@ -475,6 +514,18 @@ function paragraphsIn(b: TopBlock, scoped: SelRange | null): ParagraphHit[] { } /** true when [from, to) lies inside the block's selected span (no clip = whole block is fair game) */ +/** Text blocks a character-level scan visits inside a top block: the block itself, or the paragraphs nested in a container such as a table (kept to the scoped range) */ +function scanParagraphs(b: TopBlock, scoped: SelRange | null): ParagraphHit[] { + return paragraphsIn(b, scoped) +} + +/** a character span is in play when it lies inside the top block's clip and, for nested paragraphs, inside the scoped range (a caret-only scope leaves the block whole, as matchTarget does) */ +function insideScope(b: TopBlock, scoped: SelRange | null, from: number, to: number): boolean { + if (!insideClip(b, from, to)) return false + if (!scoped || b.node.isTextblock || scoped.from === scoped.to) return true + return from >= scoped.from && to <= scoped.to +} + function insideClip(b: TopBlock, from: number, to: number): boolean { return !b.clip || (from >= b.clip.from && to <= b.clip.to) } @@ -536,6 +587,8 @@ function applyParagraphPatch( } for (const p of paragraphsIn(b, scoped)) { const attrs = { ...p.node.attrs, ...patch } + if (Array.isArray(patch.tabStops)) + attrs.tabStops = mergeTabStopsAttr(p.node.attrs.tabStops, patch.tabStops as TabStop[]) const dirty = Object.keys(patch).some((k) => attrs[k] !== p.node.attrs[k]) if (!dirty) continue tr.setNodeMarkup(p.pos, undefined, changedAttrs(ctx, attrs)) @@ -637,6 +690,7 @@ function runSetParagraphFormat(op: Op, env: RunEnv): OpResult { for (const k of present(op, PARA_KEYS)) { if (k === 'shadingFill') patch.shadingFill = normalizeHex(op.shadingFill, op.op, 'shadingFill') else if (k === 'pageBreakBefore') patch.pageBreakBefore = op.pageBreakBefore === true + else if (k === 'tabs') patch.tabStops = tabSpecsToStops(op.tabs as TabSpec[] | null) else patch[k] = op[k] ?? null } return applyParagraphPatch(env, op, patch) @@ -749,6 +803,93 @@ function runSetHeadingLevel(op: Op, env: RunEnv): OpResult { return { op: 'setHeadingLevel', matched: matched.length, changed, skippedProtected } } +/** UI: Paragraph dialog outline level — a direct w:outlineLvl, the style stays (Word never restyles for it) */ +function runSetOutlineLevel(op: Op, env: RunEnv): OpResult { + const { tr, schema, ctx, sel } = env + const level = Number(op.level) + const target = targetOf(op) + const matched = matchTarget(tr.doc, target, sel) + const scoped = scopedRange(target, sel) + let changed = 0 + let skippedProtected = 0 + for (const b of matched) { + if (b.node.type.name === 'docProtected') { + skippedProtected++ + continue + } + for (const p of paragraphsIn(b, scoped)) { + const name = p.node.type.name + const isHeading = name === 'docHeading' + // a styled heading's level belongs to its style; list items have no level slot + if (isHeading ? !p.node.attrs.outlineOnly : name !== 'docParagraph') continue + if (level === 0) { + if (!isHeading) continue + const { level: _l, outlineOnly: _o, ...rest } = p.node.attrs + tr.setNodeMarkup(p.pos, schema.nodes.docParagraph, changedAttrs(ctx, rest)) + } else { + if (isHeading && Number(p.node.attrs.level) === level) continue + const attrs = { ...p.node.attrs, level, outlineOnly: true } + tr.setNodeMarkup(p.pos, schema.nodes.docHeading, changedAttrs(ctx, attrs)) + } + changed++ + } + } + return { op: 'setOutlineLevel', matched: matched.length, changed, skippedProtected } +} + +/** + * The block as the model reads it — withheld spans already replaced by their + * markers, so a before/after comparison is in one coordinate system — together + * with the markers that produced, one per withheld span. + * + * The markers are returned rather than recovered from the text because a + * `{{...}}` in a document is not necessarily ours. A mail-merge template is + * full of them, and a model asked to fill one in is doing exactly what it was + * asked; only the markers standing in for withheld spans have to survive an + * edit untouched, because nothing else in the document promises that. + */ +function modelViewOf(node: PmDocNode): { text: string; markers: string[] } { + const markers: string[] = [] + let out = '' + const visit = (n: PmDocNode) => { + const mark = n.marks.find((m) => m.type.name === REDACT_MARK) + if (mark) { + const label = mark.attrs?.label + const source = placeholderSource(typeof label === 'string' ? label : 'private') + markers.push(source) + out += source + return + } + if (n.isText) { + out += n.text ?? '' + return + } + if (n.isLeaf) return + n.forEach(visit) + } + node.forEach(visit) + return { text: out, markers } +} + +/** + * Document ranges a withheld span covers in a block, in the same coordinates + * the replacements are collected in. + * + * This is what catches an edit aimed at the span itself. Placeholder spelling + * cannot: `find` matches the real text, so a model that never saw the words + * cannot name them, and an edit that did reach them is a range collision + * rather than a marker the model spelled wrong. + */ +function redactionRangesOf(node: PmDocNode, base: number): Array<{ from: number; to: number }> { + const out: Array<{ from: number; to: number }> = [] + node.descendants((child, pos) => { + if (!child.isText || !child.text) return + if (!child.marks.some((m) => m.type.name === REDACT_MARK)) return + out.push({ from: base + pos, to: base + pos + child.text.length }) + }) + return out +} + function runFindReplace(op: Op, env: RunEnv): OpResult { const { tr, schema, ctx, sel } = env const find = String(op.find) @@ -756,6 +897,7 @@ function runFindReplace(op: Op, env: RunEnv): OpResult { const matchCase = op.matchCase !== false const needle = matchCase ? find : find.toLowerCase() const blocks = op.target ? matchTarget(tr.doc, targetOf(op), sel) : topLevelBlocks(tr.doc) + const scoped = op.target ? scopedRange(targetOf(op), sel) : null const replacements: Array<{ from: number; to: number; marks: PmDocNode['marks'] }> = [] const touchedIndexes = new Set() let skippedProtected = 0 @@ -767,29 +909,92 @@ function runFindReplace(op: Op, env: RunEnv): OpResult { continue } const blockDeleted = isTrackedDeleted(b.node) - b.node.forEach((child, offset) => { - if (!child.isText || !child.text) return - const struck = blockDeleted || child.marks.some((m) => m.type.name === 'del') - const hay = matchCase ? child.text : child.text.toLowerCase() - let at = hay.indexOf(needle) - while (at !== -1) { - const from = b.pos + 1 + offset + at - if (struck) { - skippedDeleted++ - } else if (insideClip(b, from, from + needle.length)) { - replacements.push({ from, to: from + needle.length, marks: child.marks }) - touchedIndexes.add(b.index) + for (const p of scanParagraphs(b, scoped)) { + const paraDeleted = blockDeleted || isTrackedDeleted(p.node) + p.node.forEach((child, offset) => { + if (!child.isText || !child.text) return + const struck = paraDeleted || child.marks.some((m) => m.type.name === 'del') + const hay = matchCase ? child.text : child.text.toLowerCase() + let at = hay.indexOf(needle) + while (at !== -1) { + const from = p.pos + 1 + offset + at + if (struck) { + skippedDeleted++ + } else if (insideScope(b, scoped, from, from + needle.length)) { + replacements.push({ from, to: from + needle.length, marks: child.marks }) + touchedIndexes.add(b.index) + } + at = hay.indexOf(needle, at + needle.length) } - at = hay.indexOf(needle, at + needle.length) - } - }) + }) + } } - // apply back-to-front so earlier positions stay valid - for (const r of [...replacements].reverse()) { - if (replace) tr.replaceWith(r.from, r.to, schema.text(replace, r.marks)) - else tr.delete(r.from, r.to) + // A rewrite is refused only when it would actually touch a withheld span: the + // collected replacements are exactly the text the model is about to + // overwrite, so a batch that edits a different paragraph — or the words + // beside a marker — is left alone, while one that eats a span is stopped + // before anything is dispatched. + if (replacements.length > 0) { + // 1. no replacement may overlap the words the model was never shown + let overlaps = 0 + for (const b of blocks) { + if (!touchedIndexes.has(b.index)) continue + for (const span of redactionRangesOf(b.node, b.pos + 1)) { + for (const r of replacements) { + if (r.from < span.to && r.to > span.from) overlaps++ + } + } + } + if (overlaps > 0) { + throw new Error( + `this edit would overwrite ${overlaps} private placeholder(s) — those words are withheld from the model, so rewrite around them instead of replacing them`, + ) + } + + // 2. and whatever the model wrote must leave every marker it was given + // standing. Both sides are the model's own view: comparing markers + // against raw text would report every batch as a deletion. + // + // A block is policed only when it actually holds a withheld span. A + // block with none is the document's own text, and a `{{token}}` in it + // is content to be filled in rather than a marker of ours — a mail + // merge template is the normal case, and policing it is what stopped + // `hermesoffice merge` from filling a docx at all. Where a block is + // policed, the whole `{{...}}` bag has to match, so a marker cannot be + // renamed, split, or shadowed by a token the model invented. + // + // The "after" side is read off the transaction *after* applying the + // replacements, rather than computed by shifting offsets. A withheld + // span is longer or shorter than the marker that stands in for it, so + // any arithmetic between the two coordinate systems drifts; replaying + // the real steps cannot. executeOps discards the transaction when this + // throws, so nothing reaches the document. + const before = new Map() + for (const b of blocks) { + if (touchedIndexes.has(b.index)) before.set(b.index, modelViewOf(b.node)) + } + + // the redaction mark belongs to the reader, so a rewrite never inherits it + for (const r of [...replacements].reverse()) { + const marks = r.marks.filter((m) => m.type.name !== REDACT_MARK) + if (replace) tr.replaceWith(r.from, r.to, schema.text(replace, marks)) + else tr.delete(r.from, r.to) + } + + let damaged = 0 + for (const b of topLevelBlocks(tr.doc)) { + const was = before.get(b.index) + if (was === undefined || was.markers.length === 0) continue + damaged += checkPlaceholders(was.text, modelViewOf(b.node).text).length + } + if (damaged > 0) { + throw new Error( + `this edit would damage ${damaged} private placeholder(s) (dropped, renamed, split or duplicated) — reproduce each {{...}} exactly and rephrase around them instead`, + ) + } } + if (touchedIndexes.size > 0) { for (const b of topLevelBlocks(tr.doc)) { if (touchedIndexes.has(b.index)) markChanged(tr, b.pos, ctx) @@ -805,12 +1010,31 @@ function runFindReplace(op: Op, env: RunEnv): OpResult { } } +/** does this text node currently carry the color / size the op asks to match? */ +function runStyleMatches(child: PmDocNode, op: Op): boolean { + const ts = child.marks.find((m) => m.type.name === 'docTextStyle') + if (op.matchColor !== undefined) { + const raw = ts?.attrs.color + const have = typeof raw === 'string' && raw && raw !== 'auto' ? raw.toUpperCase() : null + const want = op.matchColor === 'auto' ? null : normalizeHex(op.matchColor, op.op, 'matchColor') + if (have !== want) return false + } + if (op.matchFontSize !== undefined) { + const have = typeof ts?.attrs.sizeHalfPoints === 'number' ? ts.attrs.sizeHalfPoints : null + const want = + op.matchFontSize === 'default' ? null : Math.round((op.matchFontSize as number) * 2) + if (have !== want) return false + } + return true +} + function runSetMatchedFont(op: Op, env: RunEnv): OpResult { const { tr, ctx, sel } = env - const text = String(op.text) + const text = typeof op.text === 'string' ? op.text : null const matchCase = op.matchCase !== false - const needle = matchCase ? text : text.toLowerCase() + const needle = text === null ? null : matchCase ? text : text.toLowerCase() const blocks = op.target ? matchTarget(tr.doc, targetOf(op), sel) : topLevelBlocks(tr.doc) + const scoped = op.target ? scopedRange(targetOf(op), sel) : null const boolKeys = present(op, Object.keys(BOOL_MARK_TYPES)) const markPatch = buildTextMarkPatch(op) const touched = new Set() @@ -820,29 +1044,49 @@ function runSetMatchedFont(op: Op, env: RunEnv): OpResult { for (const b of blocks) { if (b.node.type.name === 'docProtected') { - if (blockText(b.node).includes(text)) skippedProtected++ + if (text === null || blockText(b.node).includes(text)) skippedProtected++ continue } const blockDeleted = isTrackedDeleted(b.node) - const contentFrom = b.pos + 1 - b.node.forEach((child, offset) => { - if (!child.isText || !child.text) return - const struck = blockDeleted || child.marks.some((m) => m.type.name === 'del') - const hay = matchCase ? child.text : child.text.toLowerCase() - let at = hay.indexOf(needle) - while (at !== -1) { - const from = contentFrom + offset + at - const to = from + needle.length - if (struck) { - skippedDeleted++ - } else if (insideClip(b, from, to)) { - applyFontRange(env, op, boolKeys, markPatch, from, to, b.node, contentFrom) - styledCount++ - touched.add(b.index) + for (const p of scanParagraphs(b, scoped)) { + const paraDeleted = blockDeleted || isTrackedDeleted(p.node) + const contentFrom = p.pos + 1 + p.node.forEach((child, offset) => { + if (!child.isText || !child.text) return + if (!runStyleMatches(child, op)) return + const struck = paraDeleted || child.marks.some((m) => m.type.name === 'del') + // no text to match: the whole run is one match + const ranges: Array<[number, number]> = [] + if (needle === null) { + ranges.push([0, child.text.length]) + } else { + const hay = matchCase ? child.text : child.text.toLowerCase() + let at = hay.indexOf(needle) + while (at !== -1) { + ranges.push([at, at + needle.length]) + at = hay.indexOf(needle, at + needle.length) + } } - at = hay.indexOf(needle, at + needle.length) - } - }) + for (const [start, end] of ranges) { + let from = contentFrom + offset + start + let to = contentFrom + offset + end + if (needle === null) { + // a style match is the whole run; a selection may cut into it, so clip instead of skipping + if (b.clip) [from, to] = [Math.max(from, b.clip.from), Math.min(to, b.clip.to)] + if (scoped && !b.node.isTextblock && scoped.from !== scoped.to) + [from, to] = [Math.max(from, scoped.from), Math.min(to, scoped.to)] + if (from >= to) continue + } + if (struck) { + skippedDeleted++ + } else if (insideScope(b, scoped, from, to)) { + applyFontRange(env, op, boolKeys, markPatch, from, to, p.node, contentFrom) + styledCount++ + touched.add(b.index) + } + } + }) + } } // mark steps never shift positions, so block offsets are still valid here for (const b of topLevelBlocks(tr.doc)) { @@ -1062,16 +1306,31 @@ register({ register({ name: 'setMatchedFont', - signature: `{ op: "setMatchedFont", text, matchCase?, target?, ${FONT_SIGNATURE_FIELDS} } // character-level: styles only the occurrences of text (not the whole block); target narrows the scanned blocks, omitted = whole document`, - keys: ['text', 'matchCase', ...FONT_KEYS], + signature: `{ op: "setMatchedFont", text?, matchCase?, matchColor?: "#RRGGBB"|"auto", matchFontSize?: pt|"default", target?, ${FONT_SIGNATURE_FIELDS} } // character-level: styles only the runs that match (not the whole block) — occurrences of text, and/or runs currently in matchColor / at matchFontSize (as shown by read_blocks spans); give at least one of text / matchColor / matchFontSize; target narrows the scanned blocks, omitted = whole document (table cells included)`, + keys: ['text', 'matchCase', 'matchColor', 'matchFontSize', ...FONT_KEYS], target: 'optional', validate(op, where) { const shape = validateShape(op, this, where) if (shape) return shape - if (typeof op.text !== 'string' || op.text === '') return `${where}: text must not be empty` + if (op.text !== undefined && (typeof op.text !== 'string' || op.text === '')) + return `${where}: text must not be empty` if (op.matchCase !== undefined && typeof op.matchCase !== 'boolean') { return `${where}: matchCase must be a boolean` } + if (op.matchColor !== undefined && op.matchColor !== 'auto') { + const error = checkHex(op.matchColor, where, 'matchColor') + if (error) return error + } + if ( + op.matchFontSize !== undefined && + op.matchFontSize !== 'default' && + !(typeof op.matchFontSize === 'number' && isFontSizeOrNull(op.matchFontSize)) + ) { + return `${where}: matchFontSize must be ${MIN_FONT_SIZE_PT}-${MAX_FONT_SIZE_PT}pt or "default" (runs without an explicit size)` + } + if (op.text === undefined && op.matchColor === undefined && op.matchFontSize === undefined) { + return `${where}: give text, matchColor or matchFontSize (what to match)` + } return validateFontFields(op, where) }, apply: runSetMatchedFont, @@ -1080,7 +1339,7 @@ register({ register({ name: 'setParagraphFormat', signature: - '{ op: "setParagraphFormat", target, align?: "left"|"center"|"right"|"justify"|null, lineSpacing?, indentLeft?, indentRight?, indentFirstLine?, spaceBefore?, spaceAfter?, pageBreakBefore?: boolean, shadingFill?: "#RRGGBB"|null, borders?: subset of "tblr"|null } // paragraph format of the targeted blocks (whole blocks, even for a partial selection); lengths in twips, lineSpacing as a multiple; indentFirstLine positive = first-line indent, negative = hanging; a two-character CJK indent ≈ font size in pt × 40 twips', + '{ op: "setParagraphFormat", target, align?: "left"|"center"|"right"|"justify"|null, lineSpacing?, indentLeft?, indentRight?, indentFirstLine?, spaceBefore?, spaceAfter?, pageBreakBefore?: boolean, shadingFill?: "#RRGGBB"|null, borders?: subset of "tblr"|null, tabs?: [{ pos: twips|"6.5in", align?: "left"|"center"|"right"|"decimal"|"bar", leader?: "none"|"dot"|"hyphen"|"underscore"|"middleDot", clear?: true }]|null } // paragraph format of the targeted blocks (whole blocks, even for a partial selection); lengths in twips, lineSpacing as a multiple; indentFirstLine positive = first-line indent, negative = hanging; a two-character CJK indent ≈ font size in pt × 40 twips; tabs replaces the paragraph\'s own stops ([] / null clears them, clear: true cancels a style stop); a tab character in HTML is ', keys: PARA_KEYS, target: 'required', validate(op, where) { @@ -1263,6 +1522,23 @@ register({ apply: runSetParagraphAttrs, }) +register({ + name: 'setOutlineLevel', + signature: '{ op: "setOutlineLevel", target, level: 0-9 }', + keys: ['level'], + target: 'required', + hidden: true, + validate(op, where) { + const shape = validateShape(op, this, where) + if (shape) return shape + if (!Number.isInteger(op.level) || Number(op.level) < 0 || Number(op.level) > 9) { + return `${where}: level must be an integer between 0 and 9` + } + return null + }, + apply: runSetOutlineLevel, +}) + register({ name: 'stepIndent', signature: '{ op: "stepIndent", target, delta: 1|-1 }', @@ -1317,7 +1593,15 @@ function summarize(results: OpResult[]): string { const total = results.reduce((sum, r) => sum + r.changed, 0) if (total === 0) { const skipped = results.reduce((sum, r) => sum + r.skippedProtected, 0) - return skipped > 0 ? t('aiCmdNoneSkipped', { count: skipped }) : t('aiCmdNone') + if (skipped > 0) return t('aiCmdNoneSkipped', { count: skipped }) + // blocks were found but needed no edit: say so, or the model retries other selectors. + // deleteBlocks is the only op whose `matched` includes its tracked-deleted targets; + // elsewhere skippedDeleted counts hits already left out of `matched`, hence the clamp. + const unchanged = results.reduce( + (sum, r) => sum + Math.max(0, r.matched - (r.skippedDeleted ?? 0)), + 0, + ) + return unchanged > 0 ? t('aiCmdNoneUnchanged', { count: unchanged }) : t('aiCmdNone') } const parts = results.map((r) => { let part: string diff --git a/apps/docs/src/renderer/ai/page-setup.ts b/apps/docs/src/renderer/ai/page-setup.ts index ab0350fb7..815cc448e 100644 --- a/apps/docs/src/renderer/ai/page-setup.ts +++ b/apps/docs/src/renderer/ai/page-setup.ts @@ -85,12 +85,21 @@ const TWIPS_PER: Record = { const LENGTH = /^\s*(-?\d+(?:\.\d+)?)\s*(twips?|pt|px|in|cm|mm)\s*$/i /** "2.54cm" / "1in" / "72pt" / 1440 (twips) → twips; undefined when unparseable */ +/** Largest magnitude accepted (~35in in twips): uncapped AI lengths break layout. */ +const MAX_TWIPS = 50400 + +function boundTwips(twips: number): number | undefined { + if (!Number.isFinite(twips)) return undefined + const rounded = Math.round(twips) + return Math.abs(rounded) <= MAX_TWIPS ? rounded : undefined +} + export function parseTwips(value: unknown): number | undefined { - if (typeof value === 'number') return Number.isFinite(value) ? Math.round(value) : undefined + if (typeof value === 'number') return boundTwips(value) if (typeof value !== 'string') return undefined const m = LENGTH.exec(value) if (!m) return undefined - return Math.round(Number(m[1]) * TWIPS_PER[m[2]!.toLowerCase()]!) + return boundTwips(Number(m[1]) * TWIPS_PER[m[2]!.toLowerCase()]!) } export function twipsToCm(twips: number): string { diff --git a/apps/docs/src/renderer/ai/protocol.ts b/apps/docs/src/renderer/ai/protocol.ts index e4ceca468..20604f91d 100644 --- a/apps/docs/src/renderer/ai/protocol.ts +++ b/apps/docs/src/renderer/ai/protocol.ts @@ -1,4 +1,5 @@ import type { Editor } from '@tiptap/core' +import { REDACT_MARK } from './redact' import type { Node as ProseMirrorNode } from '@tiptap/pm/model' import { TextSelection } from '@tiptap/pm/state' import { @@ -9,14 +10,24 @@ import { type TableModel, } from '@hermesoffice/docx-engine' import { pmTableToModel, tableModelToPmNode, type PmMark, type PmNode } from '../editor/convert' +import { wordTableCell, wordTableModel } from '../editor/new-table' import { equationBlockJson, inlineEquationNodeJson } from '../editor/equation' import { inheritFrom, inheritTableFormatting, sameBlockRole } from './inherit-formatting' -import { TRACK_IGNORE, type RevisionRange } from '../editor/revisions' -import { countWords } from '../word-count' +import { TRACK_AUTHOR, TRACK_IGNORE, type RevisionRange } from '../editor/revisions' +import { aiRevisionAuthor } from './revision-author' +import { countWords, documentTextForWordCount, leafTextForWordCount } from '../word-count' import { blockRangePositions, isTrackedDeleted, liveText } from './doc-utils' +import { + modelTextOf, + projectedNodeText, + redactNode, + redactTextBetween, + type RedactableNode, +} from './redact-view' import { opSignatures } from './ops' import { pageSetupContextLines, type AiSectionState } from './page-setup' import { listRevisionEntries } from './revision-ops' +import type { StoryRevision } from '../editor/story-revisions' export { blockRangePositions, isTrackedDeleted, liveText } @@ -68,6 +79,8 @@ export const OPS_GUIDE = [ '- "Center all images" → ops: [{"op":"setImageProperties","target":{"nodeType":"image"},"align":"center"}]', '- "In block 5, change 8% to 9%" → ops: [{"op":"findReplace","find":"8%","replace":"9%","target":{"blockIndexes":[5]}}]', '- "Bold every TODO:" → ops: [{"op":"setMatchedFont","text":"TODO:","bold":true}]', + '- "Turn all the red text back to normal" → ops: [{"op":"setMatchedFont","matchColor":"#FF0000","color":null}] (read_blocks shows the red runs as ; links and other colors are untouched)', + '- "Make every 9 pt run 10.5 pt" → ops: [{"op":"setMatchedFont","matchFontSize":9,"fontSize":10.5}]', '- "Make the selected words 14 pt blue" (partial selection) → ops: [{"op":"setFont","target":{"scope":"selection"},"fontSize":14,"color":"#1A73E8"}]', '- "Two-character first-line indent for the whole document" (11 pt font) → one setParagraphFormat for docParagraph with "indentFirstLine":440', '- "Insert a table of contents at the beginning" → ops: [{"op":"insertToc","afterBlockIndex":-1}]; if the user wants the TOC on its own page, also setParagraphFormat the first body block after the TOC with "pageBreakBefore":true', @@ -94,6 +107,8 @@ export const HTML_RULES = [ '- Use LaTeX for math (produces native Word equations): as a top-level block it becomes its own centered paragraph; placed inside

    /

  • / text it is an inline formula flowing with the text, e.g.

    From E = mc^2 we know…

    ; supports the common subset of \\frac \\sqrt super/subscripts \\sum \\int \\lim matrix environments Greek letters etc., the align environment is not supported; invalid LaTeX fails the whole call — fix and retry', '- Do not include /, markdown code fences, or explanatory text', "- … appears only in the context to mark the user's selection; it is not a tag you may write", + '- in read_blocks output is picture I of block B. To keep a picture when you replace its block, copy that exact tag into the new HTML where the picture should sit; a replaced range whose pictures you leave out loses them (the tool result says so). Any other is ignored — new pictures go through insert_picture', + '- appears only in read_blocks output, on runs whose color or size differs from the rest of the block (color:auto / font-size:default = back to the block\'s normal style); it is read-only — writing has no effect, change colors and sizes with the apply_ops formatting commands', '- Organize long content into sections with h2/h3 (unless the user only wants a single paragraph)', '- Keep the same language as the original document / user instruction, unless translation is requested', ].join('\n') @@ -117,7 +132,7 @@ export const AGENT_SYSTEM_PROMPT = [ '- Content changes: use insert_content for new content, and replace_blocks to rewrite/replace existing blocks (pass a block index range and the new HTML); replaced blocks pass their paragraph and text formatting (font, size, color, indent, spacing, alignment) on to the new blocks automatically, and a rewritten table keeps its widths, borders, shading and cell formatting, so a rewrite never needs follow-up formatting commands;', '- Long new content (drafting a whole document, a chapter, a full report/article/translation — anything beyond a few paragraphs) goes through write_document: you pass the plan and the reference material, and the system writer streams the text into the document while the user watches; never paste long content into insert_content. When the document is blank and the user asks for content, use write_document;', '- Formatting, structure, and batch operations (color/font size/line spacing/alignment/indent/heading level/find & replace/delete/move/list conversion) go through apply_ops — do not rewrite whole blocks with replace_blocks;', - '- Small in-place text fixes (changing a few words inside a sentence) go through apply_ops findReplace with a target — do not rewrite the whole block; styling every occurrence of a phrase (e.g. bold each "TODO") uses setMatchedFont;', + '- Small in-place text fixes (changing a few words inside a sentence) go through apply_ops findReplace with a target — do not rewrite the whole block; styling every occurrence of a phrase (e.g. bold each "TODO") uses setMatchedFont, and so does restyling every run of a given color or size (matchColor / matchFontSize) — never rewrite blocks or restyle whole blocks to fix a few runs;', '- When the user has text selected, the message includes the selection block indexes and content; rewrite-style requests apply to the selection by default;', '- When only part of a block is selected, the selected span is delimited by … and repeated as "Selected text": "this"/"the selected text" means exactly that span. Restyle it with apply_ops and target {"scope":"selection"} (run-level ops then touch only the selected characters); rewrite it with replace_selection. Never restyle or rewrite the whole block for a partial selection;', '- Web search: use web_search when you need up-to-date information/data/fact checking; search before writing about uncertain facts — do not fabricate;', @@ -141,7 +156,7 @@ export const AGENT_SYSTEM_PROMPT = [ '', '# Fields, footnotes & endnotes', '- insertField writes real Word fields (SEQ caption numbers, DATE/TIME, REF/PAGEREF to a bookmark, MERGEFIELD, PAGE/NUMPAGES, AUTHOR…). Results the editor cannot compute (page numbers, document properties) are placeholders marked for Word to recompute when the file opens; mention that when you report. TOC → insertToc, never insertField.', - '- insert_footnote / insert_endnote put a superscript reference mark into a block (after afterText, else at its end) and store the note text; read_notes lists notes with ids and anchored blocks; delete_note removes one with its mark. Notes are not document blocks — never reach them via block indexes or rewrite a block just to change its note.', + '- insert_footnote / insert_endnote put a superscript reference mark into a block (after afterText, else at its end) and store the note text; read_notes lists notes with ids and anchored blocks; edit_note changes the text of one in place (findReplace inside the note, id kept); delete_note removes one with its mark. Notes are not document blocks — never reach them via block indexes or rewrite a block just to change its note.', '', '# Headers & footers', '- The message context lists the current header/footer text. Change them with set_header_footer: plain text, \\n between lines; the tokens {PAGE} and {NUMPAGES} become live page-number fields (e.g. text "{PAGE} / {NUMPAGES}" renders as "3 / 12"); an empty string clears the text.', @@ -157,6 +172,13 @@ export const AGENT_SYSTEM_PROMPT = [ '- Redline summary shape: overall counts first (insertions/deletions, authors, date range), then the changes grouped by document section using the heading structure from the block list, one line each; end with a "Potential concerns" list flagging risky edits (deleted obligations or qualifiers, changed numbers/dates/amounts, weakened commitments). Cite block indexes so the user can locate each change.', '- Summarizing is read-only: do not modify the document, and never try to accept or reject revisions — the user does that in the Review tab.', '', + '# Mind map', + '- When the user asks for a mind map / knowledge structure / overview diagram of the document or a topic, call generate_mindmap with the whole tree in one call — never draw it with text or ask the user to confirm first;', + '- The root is the document title (or the focus topic when the user named one — also pass it as `focus`); its children are the main sections/headings, then key concepts, 2-5 per node, 2-4 levels deep;', + "- Labels are short phrases (2-8 words) in the document's own language and wording, taken from its headings and key sentences — never invent topics the document does not cover;", + "- Cite each node's source with `block` (a block index from the current block list); omit it for nodes that span several blocks. Only cite indexes that exist;", + "- generate_mindmap does not modify the document. After it returns, reply with one short sentence (e.g. that the map is ready and branches can be expanded). If the user wants the map inside the document, point at the card's insert button — it places the map in the document as a picture.", + '', '# HTML fragment rules', HTML_RULES, '', @@ -164,7 +186,7 @@ export const AGENT_SYSTEM_PROMPT = [ OPS_GUIDE, ].join('\n') -export { countWords } +export { countWords, documentTextForWordCount } // ---- selection scope ---- @@ -234,7 +256,55 @@ function escapeHtml(text: string): string { return text.replace(/&/g, '&').replace(//g, '>') } -function wrapInlineText(raw: string, marks: PmMark[]): string { +/** The run style a block's text mostly uses; runs that differ from it are the ones worth showing. */ +interface RunStyle { + color: string | null + sizeHalfPoints: number | null +} + +function runStyleOf(marks: PmMark[]): RunStyle { + const ts = marks.find((m: PmMark) => m.type === 'docTextStyle') + const color = ts?.attrs?.color + const size = ts?.attrs?.sizeHalfPoints + return { + // 'auto' is Word's explicit automatic color: the same as no color + color: typeof color === 'string' && color && color !== 'auto' ? color.toUpperCase() : null, + sizeHalfPoints: typeof size === 'number' ? size : null, + } +} + +function dominantRunStyle(content: PmNode[] | undefined): RunStyle { + const colors = new Map() + const sizes = new Map() + for (const node of content ?? []) { + if (node.type !== 'text' || !node.text) continue + // pending deletions are hidden from the model, so they must not set its baseline + if ((node.marks ?? []).some((m: PmMark) => m.type === 'del')) continue + const { color, sizeHalfPoints } = runStyleOf(node.marks ?? []) + colors.set(color, (colors.get(color) ?? 0) + node.text.length) + sizes.set(sizeHalfPoints, (sizes.get(sizeHalfPoints) ?? 0) + node.text.length) + } + const top = (m: Map, fallback: T): T => + [...m.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? fallback + return { color: top(colors, null), sizeHalfPoints: top(sizes, null) } +} + +/** + * Color / size the model needs to see: only where a run departs from the + * block's dominant style, so a uniformly styled document reads as plain text + * and a red correction or an oversized word stands out. Read-only markup — + * the parser drops styles (formatting goes through apply_ops). + */ +function styleAttr(marks: PmMark[], base: RunStyle): string { + const { color, sizeHalfPoints } = runStyleOf(marks) + const decl: string[] = [] + if (color !== base.color) decl.push(`color:${color ? `#${color}` : 'auto'}`) + if (sizeHalfPoints !== base.sizeHalfPoints) + decl.push(`font-size:${sizeHalfPoints === null ? 'default' : `${sizeHalfPoints / 2}pt`}`) + return decl.length ? ` style="${decl.join(';')}"` : '' +} + +function wrapInlineText(raw: string, marks: PmMark[], base?: RunStyle): string { let text = escapeHtml(raw) const has = (type: string) => marks.some((m: PmMark) => m.type === type) if (has('bold')) text = `${text}` @@ -243,11 +313,38 @@ function wrapInlineText(raw: string, marks: PmMark[]): string { if (has('strike')) text = `${text}` const link = marks.find((m: PmMark) => m.type === 'link') if (link?.attrs?.href) text = `${text}` + const style = base ? styleAttr(marks, base) : '' + if (style) text = `${text}` return text } -function inlineAtomToHtml(node: PmNode): string { +/** Export hook: maps a picture's source to the `src` the restricted HTML should carry; null drops it. */ +export type ImageSrcMapper = (src: string) => string | null + +/** `doc:B.I` — the I-th picture of block B; read_blocks shows it, replace_blocks resolves it back to the picture */ +export const IMAGE_REF_PREFIX = 'doc:' +const IMAGE_REF_RE = /^doc:(\d+)\.(\d+)$/ + +/** per-block counter handing out picture ordinals while a block serializes */ +interface ImageRefs { + block: number + next: number +} + +function inlineAtomToHtml(node: PmNode, image?: ImageSrcMapper, refs?: ImageRefs): string { if (node.type === 'hardBreak') return '
    ' + if (node.type === 'docInlineImage') { + // a rule image is decoration; a withheld picture must never become a fetchable ref + if (node.attrs?.rule || node.attrs?.redact) return '' + if (image) { + const src = image(String(node.attrs?.dataUrl ?? '')) + return src ? `` : '' + } + // the model's view: a picture is content it must see (an image-only + // paragraph used to read as

    and got replaced away), and the ref + // lets it keep the picture when rewriting the block + return refs ? `` : '' + } if (node.type === 'docInlineMath') { // editor-created formulas round-trip as LaTeX; parsed-from-docx ones // degrade to the flat token strip (structure lost if the model rewrites) @@ -269,8 +366,14 @@ export interface InlineSelection { * delimited by … (text nodes split at the boundaries). Every inline * node other than text is an atom, so it occupies exactly one position. */ -function inlineToHtml(content: PmNode[] | undefined, sel?: InlineSelection): string { +function inlineToHtml( + content: PmNode[] | undefined, + sel?: InlineSelection, + image?: ImageSrcMapper, + refs?: ImageRefs, +): string { if (!content) return '' + const base = dominantRunStyle(content) let html = '' let pos = 0 let open = false @@ -299,13 +402,14 @@ function inlineToHtml(content: PmNode[] | undefined, sel?: InlineSelection): str cuts.push(end) for (let i = 0; i < cuts.length - 1; i++) { boundary(cuts[i]) - if (!deleted) html += wrapInlineText(text.slice(cuts[i] - pos, cuts[i + 1] - pos), marks) + if (!deleted) + html += wrapInlineText(text.slice(cuts[i] - pos, cuts[i + 1] - pos), marks, base) } pos = end continue } boundary(pos) - if (!deleted) html += inlineAtomToHtml(node) + if (!deleted) html += inlineAtomToHtml(node, image, refs) pos += 1 } boundary(pos) @@ -362,8 +466,7 @@ function isQuoteParagraph(node: PmNode): boolean { /** table block -> restricted HTML so the model can read and rewrite it */ function tableToHtml(table: TableModel): string { - const headerRow = - table.rows.length > 1 && table.rows[0].every((c) => c.bold || c.fill !== undefined) + const headerRow = isHeaderRowTable(table) const rows = table.rows.map((row, r) => { const tag = headerRow && r === 0 ? 'th' : 'td' const cells = row @@ -374,6 +477,37 @@ function tableToHtml(table: TableModel): string { return `
    ${rows.join('')}
    ` } +function isHeaderRowTable(table: TableModel): boolean { + return table.rows.length > 1 && table.rows[0].every((c) => c.bold || c.fill !== undefined) +} + +/** + * Export variant: cells keep their inline pictures (the table model is text + * only). The grid comes from the model so merged cells keep their columns; + * each non-continuation entry maps to the row's next physical cell node. + */ +function tableToHtmlWithImages(node: PmNode, image: ImageSrcMapper): string { + const model = pmTableToModel(node) + const headerRow = isHeaderRowTable(model) + const rows = model.rows.map((row, r) => { + const tag = headerRow && r === 0 ? 'th' : 'td' + const physical = node.content?.[r]?.content ?? [] + let next = 0 + const cells = row + .map((entry) => { + if (entry.vMerge === 'continue') return `<${tag}>` + const cell = physical[next++] + const paras = (cell?.content ?? []) + .filter((n) => n.type === 'docParagraph' || n.type === 'docListItem') + .map((n) => inlineToHtml(n.content, undefined, image)) + return `<${tag}>${paras.join('
    ')}` + }) + .join('') + return `${cells}` + }) + return `${rows.join('')}
    ` +} + /** * Serialize top-level nodes [startIndex, endIndex] into restricted HTML. * Consecutive list items of the same kind are grouped into ul/ol. With `sel` @@ -384,8 +518,13 @@ export function serializeRangeToHtml( startIndex: number, endIndex: number, sel?: { from: number; to: number } | null, + image?: ImageSrcMapper, ): string { - const json = editor.getJSON() as PmNode + // The model's view, not the document's: a withheld span's words must + // not travel in the HTML this produces. `redactNode` is total, so a + // shape it does not recognise still comes through rather than + // throwing and falling back to the unredacted text. + const json = redactNode(editor.getJSON() as RedactableNode) as PmNode const children = (json.content ?? []).slice(startIndex, endIndex + 1) const parts: string[] = [] let listBuffer: { kind: string; items: string[] } | null = null @@ -412,29 +551,32 @@ export function serializeRangeToHtml( flushList() continue } + const refs: ImageRefs | undefined = image ? undefined : { block: startIndex + i, next: 0 } if (node.type === 'docHeading') { flushList() const level = Math.min(Math.max(Number(node.attrs?.level) || 1, 1), 6) - parts.push(`${inlineToHtml(node.content, inlineSel(i))}`) + parts.push(`${inlineToHtml(node.content, inlineSel(i), image, refs)}`) } else if (node.type === 'docListItem') { const kind = (node.attrs?.kind as string) ?? 'bullet' if (!listBuffer || listBuffer.kind !== kind) { flushList() listBuffer = { kind, items: [] } } - listBuffer.items.push(`
  • ${inlineToHtml(node.content, inlineSel(i))}
  • `) + listBuffer.items.push(`
  • ${inlineToHtml(node.content, inlineSel(i), image, refs)}
  • `) } else if (node.type === 'docParagraph') { flushList() if (isCodeParagraph(node)) { parts.push(`
    ${codeToHtml(node.content, inlineSel(i))}
    `) } else if (isQuoteParagraph(node)) { - parts.push(`
    ${inlineToHtml(node.content, inlineSel(i))}
    `) + parts.push( + `
    ${inlineToHtml(node.content, inlineSel(i), image, refs)}
    `, + ) } else { - parts.push(`

    ${inlineToHtml(node.content, inlineSel(i))}

    `) + parts.push(`

    ${inlineToHtml(node.content, inlineSel(i), image, refs)}

    `) } } else if (node.type === 'docTable') { flushList() - parts.push(tableToHtml(pmTableToModel(node))) + parts.push(image ? tableToHtmlWithImages(node, image) : tableToHtml(pmTableToModel(node))) } else { flushList() const formula = node.attrs?.formulaDisplay as { tokens?: string[]; latex?: string } | null @@ -522,6 +664,12 @@ export function buildDocumentContext( let index = 0 let fullText = '' let hasPendingDeletions = false + // textBetween's blockSeparator: between block units, never leading or trailing + let fullTextFirst = true + const appendFullText = (text: string) => { + fullText += fullTextFirst ? text : ` ${text}` + fullTextFirst = false + } editor.state.doc.forEach((node) => { let type: string let preview: string @@ -538,14 +686,24 @@ export function buildDocumentContext( preview = `(picture, no text — use analyze_media with blockIndex ${index} to see it)` } if (deleted) hasPendingDeletions = true - else fullText += node.textContent + else if (node.textContent !== '') appendFullText(node.textContent) } else { deleted = isTrackedDeleted(node) const live = liveText(node) // deleted blocks show their struck text so the model can talk about the // revision, but that text never counts as current content preview = deleted ? node.textContent : live - if (!deleted) fullText += live + if (!deleted && !live.trim()) { + const pictures = inlineImagesOf(node).length + if (pictures > 0) preview = `(${pictures} picture${pictures === 1 ? '' : 's'}, no text)` + } + if (!deleted) + appendFullText( + liveText(node, false, { + blockSeparator: ' ', + leafText: leafTextForWordCount, + }), + ) if (deleted || live !== node.textContent) hasPendingDeletions = true if (node.type.name === 'docHeading') { type = `h${Math.min(Math.max(Number(node.attrs.level) || 1, 1), 6)}` @@ -629,10 +787,10 @@ const REVISIONS_CONTEXT_MAX = 200 const REVISION_KIND_LABEL: Record = { ins: 'inserted', del: 'deleted', - // ins+del marks on the same text: added while tracking, then deleted — - // never part of the base document; both accept and reject remove it - both: 'inserted then deleted (not in the base document)', pPrChange: 'paragraph formatting changed', + paraMarkDel: 'paragraph mark deleted (joins the next paragraph)', + paraMarkIns: 'paragraph mark inserted', + paraMarkRPrChange: 'paragraph mark formatting changed', rPrChange: 'text formatting changed', moveFrom: 'moved away', moveTo: 'moved here', @@ -642,18 +800,27 @@ const REVISION_KIND_LABEL: Record = { cellDel: 'table cell deleted', blockIns: 'block inserted', blockDel: 'block deleted', + tblPrChange: 'table formatting changed', + tblGridChange: 'table grid changed', + trPrChange: 'table row formatting changed', + tblPrExChange: 'table row exceptions changed', + tcPrChange: 'table cell formatting changed', + cellMerge: 'table cells merged', + numberingChange: 'numbering changed', + sectPrChange: 'section formatting changed', } /** flat listing of every pending tracked change; backs the read_revisions tool */ -export function buildRevisionsContext(editor: Editor): string { - const revisions = listRevisionEntries(editor.state.doc) +export function buildRevisionsContext(editor: Editor, stories?: StoryRevision[]): string { + const revisions = listRevisionEntries(editor.state.doc, stories) if (revisions.length === 0) return '(the document has no tracked revisions)' const shown = revisions.slice(0, REVISIONS_CONTEXT_MAX) const lines = shown.map((rev) => { const date = rev.date ? ` on ${rev.date.slice(0, 10)}` : '' const excerpt = rev.text ? `: "${clip(rev.text, 200)}"` : '' const change = rev.change ? ` (${rev.change})` : '' - return `- ${rev.id} | block ${rev.blockIndex} | ${REVISION_KIND_LABEL[rev.kind]} by ${rev.author || 'unknown'}${date}${change}${excerpt}` + const where = rev.location ? `in ${rev.location}` : `block ${rev.blockIndex}` + return `- ${rev.id} | ${where} | ${REVISION_KIND_LABEL[rev.kind]} by ${rev.author || 'unknown'}${date}${change}${excerpt}` }) const header = `Tracked revisions (${revisions.length} pending; deleted text shows what will disappear on accept; ids are positional and renumber after every edit):` const overflow = @@ -683,7 +850,8 @@ export function commentAnchors(editor: Editor): Map { .split(' ') .filter(Boolean)) { const entry = found.get(id) ?? { blockIndex: index, text: '' } - entry.text += node.text ?? '' + // a comment can anchor across a withheld span; the model sees the placeholder + entry.text += projectedNodeText(node as unknown as RedactableNode) found.set(id, entry) } }) @@ -691,7 +859,15 @@ export function commentAnchors(editor: Editor): Map { ? (block.attrs.commentStarts as string[]) : [] for (const id of starts) { - if (!found.has(id)) found.set(id, { blockIndex: index, text: block.textContent }) + if (!found.has(id)) + found.set(id, { + blockIndex: index, + // Through `toJSON()`, never the live node. `redactNode` walks + // `content` as an array, and a live ProseMirror node holds a + // Fragment there, so the whole subtree would come back untouched and + // a withheld span would travel verbatim. + text: modelTextOf(block.toJSON() as RedactableNode), + }) } index++ }) @@ -770,8 +946,10 @@ export function buildDocContext( SELECTION_MAX_CHARS, ) : '' + // `textBetween` would read the words; the context quotes the selection back + // to the model, so it has to be the projected read const selectedText = partial - ? editor.state.doc.textBetween(partial.from, partial.to, '\n', ' ') + ? redactTextBetween(editor.getJSON() as RedactableNode, partial.from, partial.to) : '' const selectionLines = partial ? [ @@ -855,11 +1033,29 @@ function parseInline(element: Node, marks: PmMark[]): PmNode[] { nodes.push({ type: 'hardBreak' }) return } + // HTML whitespace collapsing eats literal tabs, so a tab character needs a marker; + // a self-closing is an open tag in HTML, so its "children" are the + // text that follows and still have to be read + if (el.hasAttribute('data-tab')) { + nodes.push({ type: 'text', text: '\t', ...(marks.length > 0 ? { marks: [...marks] } : {}) }) + nodes.push(...parseInline(el, marks)) + return + } if (tag === 'a') { const href = el.getAttribute('href') ?? '' nodes.push(...parseInline(el, [...marks, { type: 'link', attrs: { href, rId: null } }])) return } + if (tag === 'img') { + const ref = IMAGE_REF_RE.exec(el.getAttribute('src') ?? '') + if (ref) + nodes.push({ + type: IMAGE_REF_NODE, + attrs: { block: Number(ref[1]), index: Number(ref[2]) }, + }) + // any other is not restricted HTML: pictures come in through insert_picture + return + } if (tag === 'formula') { const latex = (el.textContent ?? '').trim() if (!latex) return @@ -972,21 +1168,12 @@ function parseTable(el: Element): PmNode | null { if (cols === 0) return null const headerRow = rawRows[0].some((c) => c.tagName.toLowerCase() === 'th') const rows: TableCell[][] = rawRows.map((cells, r) => { - const isHeader = headerRow && r === 0 - const row: TableCell[] = cells.map((cell) => ({ - paras: cellParas(cell), - ...(isHeader ? { bold: true, fill: TABLE_HEADER_FILL } : {}), - })) - while (row.length < cols) - row.push({ paras: [''], ...(isHeader ? { bold: true, fill: TABLE_HEADER_FILL } : {}) }) + const style = headerRow && r === 0 ? { bold: true, fill: TABLE_HEADER_FILL } : undefined + const row = cells.map((cell) => wordTableCell(cellParas(cell), style)) + while (row.length < cols) row.push(wordTableCell([''], style)) return row }) - // equal column grid, like the ribbon insert and the save-path backfill - // (pmTableToModel): without colWidthsPct the table renders with no , - // leaving the fixed-layout column grid to the browser — fragile against - // spanning pagination widgets and different from what a save/reload shows - const table: TableModel = { rows, colWidthsPct: Array.from({ length: cols }, () => 100 / cols) } - return tableModelToPmNode(table) + return tableModelToPmNode(wordTableModel(rows, cols)) } /**
     -> mono/shaded paragraph; newlines preserved as hard breaks */
    @@ -1023,6 +1210,66 @@ function parseBlockquote(el: Element): PmNode | null {
      * Parse a restricted HTML fragment returned by the model into top-level PmNodes.
      * Tolerates markdown code fences and plain-text responses.
      */
    +/** placeholder emitted by the parser for ``; resolveImageRefs swaps in the real picture */
    +const IMAGE_REF_NODE = '__imageRef'
    +
    +/** the pictures read_blocks showed for this block, in the same order: no rules, no pending deletions, no withheld pictures */
    +function inlineImagesOf(block: ProseMirrorNode): ProseMirrorNode[] {
    +  const out: ProseMirrorNode[] = []
    +  block.descendants((n) => {
    +    if (
    +      n.type.name === 'docInlineImage' &&
    +      !n.attrs.rule &&
    +      !n.marks.some((m) => m.type.name === 'del' || m.type.name === REDACT_MARK)
    +    )
    +      out.push(n)
    +    return true
    +  })
    +  return out
    +}
    +
    +/**
    + * Swap `` placeholders for the pictures they name in the
    + * current document (before the edit lands). Unknown refs are dropped.
    + * Returns the blocks that were referenced, so replace_blocks can tell which
    + * pictures of the replaced range survive.
    + */
    +export function resolveImageRefs(
    +  editor: Editor,
    +  nodes: PmNode[],
    +): { nodes: PmNode[]; kept: Array<{ block: number; index: number }>; unresolved: number } {
    +  const kept: Array<{ block: number; index: number }> = []
    +  let unresolved = 0
    +  const doc = editor.state.doc
    +  const visit = (list: PmNode[]): PmNode[] =>
    +    list.flatMap((n) => {
    +      if (n.type === IMAGE_REF_NODE) {
    +        const block = Number(n.attrs?.block)
    +        const index = Number(n.attrs?.index)
    +        const picture =
    +          block >= 0 && block < doc.childCount ? inlineImagesOf(doc.child(block))[index] : undefined
    +        if (!picture) {
    +          unresolved++
    +          return []
    +        }
    +        kept.push({ block, index })
    +        return [picture.toJSON() as PmNode]
    +      }
    +      return n.content ? [{ ...n, content: visit(n.content) }] : [n]
    +    })
    +  return { nodes: visit(nodes), kept, unresolved }
    +}
    +
    +/** pictures (non-rule inline images) inside top-level blocks [start, end] */
    +export function countInlineImages(editor: Editor, start: number, end: number): number {
    +  let n = 0
    +  for (let i = start; i <= end && i < editor.state.doc.childCount; i++) {
    +    const block = editor.state.doc.child(i)
    +    if (!isTrackedDeleted(block)) n += inlineImagesOf(block).length
    +  }
    +  return n
    +}
    +
     export function parseHtmlFragment(raw: string, numIds: NumIds): PmNode[] {
       let text = raw.trim()
       const fence = /```(?:html)?\s*([\s\S]*?)```/.exec(text)
    @@ -1282,7 +1529,9 @@ export function replaceBlockRange(
         editor.view.dispatch(tr)
         return true
       }
    -  editor.view.dispatch(editor.state.tr.replaceWith(from, to, pmNodes))
    +  editor.view.dispatch(
    +    editor.state.tr.replaceWith(from, to, pmNodes).setMeta(TRACK_AUTHOR, aiRevisionAuthor()),
    +  )
       return true
     }
     
    @@ -1315,6 +1564,7 @@ export function replaceInlineRange(
         tr.setSelection(TextSelection.create(tr.doc, from, from + inserted))
         const block = tr.doc.nodeAt(blockPos)
         if (block) tr.setNodeMarkup(blockPos, undefined, { ...block.attrs, aiChanged: true })
    +    tr.setMeta(TRACK_AUTHOR, aiRevisionAuthor())
       }
       editor.view.dispatch(tr)
     }
    @@ -1351,7 +1601,7 @@ export function insertBlocksAfter(
           offset += node.nodeSize
         }
         tr.setMeta(TRACK_IGNORE, true)
    -  }
    +  } else tr.setMeta(TRACK_AUTHOR, aiRevisionAuthor())
       editor.view.dispatch(tr)
       return true
     }
    diff --git a/apps/docs/src/renderer/ai/redact-view.ts b/apps/docs/src/renderer/ai/redact-view.ts
    new file mode 100644
    index 000000000..1352f224b
    --- /dev/null
    +++ b/apps/docs/src/renderer/ai/redact-view.ts
    @@ -0,0 +1,174 @@
    +import {
    +  redactTextBetween as redactRangeTextBetween,
    +  type WithheldMark,
    +  type WithheldNode,
    +} from '@hermesoffice/agent-core/redact-range'
    +import { REDACT_MARK, placeholderSource } from './redact'
    +
    +/**
    + * The model-facing view of a document: every withheld span becomes its marker,
    + * and the text it covers disappears. A withheld picture becomes a marker too,
    + * without a path, so the model is not handed a reference it could fetch.
    + *
    + * The walk takes a plain shape rather than a ProseMirror node so it can be
    + * tested without a schema, and so it does not depend on which ProseMirror
    + * version the editor is pinned to. It is total: anything it does not recognise
    + * passes through, because a throw here would be a silent leak — the caller's
    + * fallback would be the unredacted text.
    + */
    +/** the shared shape, not a second one */
    +export type RedactableNode = WithheldNode
    +export type RedactableMark = WithheldMark
    +
    +/**
    + * TipTap serialises a mark's `type` as its schema object rather than the
    + * registered name, so both forms have to be accepted — a strict
    + * `=== 'redaction'` matches nothing and the span is read back in full.
    + */
    +function markName(mark: RedactableMark | undefined): string {
    +  const type = mark?.type
    +  if (typeof type === 'string') return type
    +  return type?.name ?? ''
    +}
    +
    +function nameOf(node: RedactableNode): string {
    +  const type = node.type
    +  if (typeof type === 'string') return type
    +  return type?.name ?? ''
    +}
    +
    +function redactionMarkOf(node: RedactableNode): RedactableMark | undefined {
    +  return node.marks?.find((m) => markName(m) === REDACT_MARK)
    +}
    +
    +function isWithheld(node: RedactableNode): boolean {
    +  return redactionMarkOf(node) !== undefined
    +}
    +
    +function labelOf(node: RedactableNode): string {
    +  const label = redactionMarkOf(node)?.attrs?.label
    +  return typeof label === 'string' ? label : 'private'
    +}
    +
    +/** A picture that is withheld still needs a marker: it has no text to redact. */
    +function isPicture(node: RedactableNode): boolean {
    +  const name = nameOf(node)
    +  return name === 'docInlineImage' || name === 'image'
    +}
    +
    +/** nodes ProseMirror counts as a single token: their JSON carries no content */
    +const ATOM_TYPES = new Set([
    +  'hardBreak',
    +  'docNoteRef',
    +  'docXeMark',
    +  'docRuby',
    +  'docInlineImage',
    +  'docInlineMath',
    +  'docCellBoxes',
    +  'docNestedTable',
    +  'docProtected',
    +])
    +
    +/** The model-facing text of one node: a withheld span reads as its placeholder. */
    +export function projectedNodeText(node: RedactableNode): string {
    +  if (isWithheld(node)) return placeholderSource(labelOf(node))
    +  return typeof node.text === 'string' ? node.text : ''
    +}
    +
    +/**
    + * The text of a document as the model may read it.
    + *
    + * `limit` stops the walk once roughly that many characters are collected —
    + * the naming caller reads this on the UI thread and the shell truncates the
    + * answer to 4000 characters anyway, so a long document should not pay for
    + * its whole walk. Without a limit the walk covers the document whole.
    + */
    +export function modelTextOf(node: RedactableNode, limit?: { maxChars: number }): string {
    +  return modelTextWalk(node, limit ? { chars: limit.maxChars } : null)
    +}
    +
    +/** What is left of a `modelTextOf` limit while the walk collects. */
    +interface TextBudget {
    +  chars: number
    +}
    +
    +function modelTextWalk(node: RedactableNode, budget: TextBudget | null): string {
    +  if (budget !== null && budget.chars <= 0) return ''
    +  if (isWithheld(node)) return placeholderSource(labelOf(node))
    +  if (typeof node.text === 'string') {
    +    if (budget !== null) budget.chars -= node.text.length
    +    return node.text
    +  }
    +  const children = node.content
    +  if (!Array.isArray(children)) return ''
    +  let out = ''
    +  for (const child of children) {
    +    out += modelTextWalk(child, budget)
    +    // a block boundary is a line break, but not a trailing one: the caller
    +    // joins this with other text and a stray newline reads as a blank line
    +    if (Array.isArray(child.content) && child !== children[children.length - 1]) out += '\n'
    +    if (budget !== null && budget.chars <= 0) break
    +  }
    +  return out
    +}
    +
    +/**
    + * The text of a range, as the model may read it.
    + *
    + * A thin wrapper over the shared walker so the options are stated once for
    + * docs — the mark name, the marker, and the picture rule all live here rather
    + * than at every call site.
    + */
    +export function redactTextBetween(
    +  node: RedactableNode,
    +  from: number,
    +  to: number,
    +  blockSeparator = '\n',
    +): string {
    +  return redactRangeTextBetween(node, from, to, {
    +    markName: REDACT_MARK,
    +    marker: placeholderSource,
    +    blockSeparator,
    +    // an atom not listed here would serialise without `content` and read as
    +    // an empty container, so the set has to name every one the schema has
    +    isLeaf: (n) => ATOM_TYPES.has(nameOf(n as RedactableNode) ?? ''),
    +  })
    +}
    +
    +/** The same document with spans replaced, for callers that need the nodes. */
    +export function redactNode(node: T): T {
    +  if (isWithheld(node)) {
    +    if (isPicture(node)) {
    +      // a picture's `src` and inlined `dataUrl` are exactly what must not
    +      // travel: either one would let the model fetch the withheld image
    +      return { type: 'docInlineImage', attrs: { redact: labelOf(node) } } as unknown as T
    +    }
    +    return { type: 'text', text: placeholderSource(labelOf(node)) } as unknown as T
    +  }
    +  if (Array.isArray(node.content)) {
    +    return { ...node, content: node.content.map((c) => redactNode(c)) }
    +  }
    +  return node
    +}
    +
    +/** Every label withheld in a document, for the model's instruction. */
    +export function redactLabelsOf(node: RedactableNode): string[] {
    +  const out: string[] = []
    +  const visit = (n: RedactableNode) => {
    +    if (isWithheld(n)) out.push(labelOf(n))
    +    if (Array.isArray(n.content)) n.content.forEach(visit)
    +  }
    +  visit(node)
    +  return out
    +}
    +
    +/** How many spans a document withholds; the guard's "is there anything to check". */
    +export function redactionCount(node: RedactableNode): number {
    +  let n = 0
    +  const visit = (x: RedactableNode) => {
    +    if (isWithheld(x)) n++
    +    if (Array.isArray(x.content)) x.content.forEach(visit)
    +  }
    +  visit(node)
    +  return n
    +}
    diff --git a/apps/docs/src/renderer/ai/redact.ts b/apps/docs/src/renderer/ai/redact.ts
    new file mode 100644
    index 000000000..74ec03ab9
    --- /dev/null
    +++ b/apps/docs/src/renderer/ai/redact.ts
    @@ -0,0 +1,61 @@
    +/**
    + * Withheld spans for the document editor: text the reader keeps but does not
    + * want a model to read.
    + *
    + * A span is an ordinary inline mark over the real words, not a replacement for
    + * them. The document and the .docx on disk still hold the actual text — losing
    + * the reader's own data to hide it from a model would be the worse failure. On
    + * disk the mark becomes a character border with a custom run property, so Word
    + * renders the words with a line under them and the label rides along inside
    + * the file. What changes is the *model's* view: when a block is serialized for
    + * a request, the span is written as `{{label}}` instead of its contents.
    + *
    + * Two consequences follow from the mark sitting on real text:
    + *
    + * - the model can split it, the way it could not split an atom. A model
    + *   answering `{{cli}}ent pho{{ne}}` is plausible, so every model edit is
    + *   checked before it is allowed into the document.
    + * - an image can be withheld too. Images carry no text, so there is nothing to
    + *   redact — the point is that the model is not told a path that would let it
    + *   fetch the picture.
    + *
    + * The rules about markers, and the prompt they produce, are shared with the
    + * other four editors and live in `@hermesoffice/agent-core/redact-core`. What
    + * stays here is what is specific to a document: the mark's name, and the two
    + * consequences above, which is what `middle` in the spec below is for.
    + */
    +
    +import {
    +  placeholderInstruction as buildInstruction,
    +  type PlaceholderSpec,
    +} from '@hermesoffice/agent-core/redact-core'
    +
    +export const REDACT_MARK = 'redaction'
    +
    +/** What a document's markers need that a sheet's or a deck's do not. */
    +const DOC_SPEC: PlaceholderSpec = {
    +  subject: 'document',
    +  opening:
    +    'Each stands in for something the reader has deliberately withheld from you; you cannot see what is inside, and that is the point.',
    +  boundary: 'a line break',
    +  middle: [
    +    'Write the prose around them as if each stood for the words it replaces, so "call {{client phone}}" reads as a natural instruction to phone someone.',
    +    'If a request needs what a placeholder hides, write around it rather than guessing.',
    +    'A placeholder may stand in for a picture: treat it as an image that was withheld and write around it.',
    +  ],
    +}
    +
    +export function placeholderInstruction(labels: readonly string[]): string {
    +  return buildInstruction(labels, DOC_SPEC)
    +}
    +
    +export {
    +  MAX_LABEL_LENGTH,
    +  checkPlaceholders,
    +  collectPlaceholders,
    +  isWholePlaceholder,
    +  placeholderSource,
    +  readPlaceholderLabel,
    +  sanitizeLabel,
    +  type PlaceholderIssue,
    +} from '@hermesoffice/agent-core/redact-core'
    diff --git a/apps/docs/src/renderer/ai/revision-author.ts b/apps/docs/src/renderer/ai/revision-author.ts
    new file mode 100644
    index 000000000..83909d52a
    --- /dev/null
    +++ b/apps/docs/src/renderer/ai/revision-author.ts
    @@ -0,0 +1,6 @@
    +import { authorName } from '../author-name'
    +
    +/** AI edits are attributed to the Settings user name, as Word does for add-in edits */
    +export function aiRevisionAuthor(): string {
    +  return authorName()
    +}
    diff --git a/apps/docs/src/renderer/ai/revision-ops.ts b/apps/docs/src/renderer/ai/revision-ops.ts
    index 37ab3f755..4f8401c86 100644
    --- a/apps/docs/src/renderer/ai/revision-ops.ts
    +++ b/apps/docs/src/renderer/ai/revision-ops.ts
    @@ -7,6 +7,8 @@ import {
       TEXT_STYLE_FIELDS,
       type RevisionRange,
     } from '../editor/revisions'
    +import type { StoryKind, StoryRevision } from '../editor/story-revisions'
    +import { redactTextBetween } from './redact-view'
     
     export type RevisionType = 'insertion' | 'deletion' | 'formatting' | 'move'
     
    @@ -31,6 +33,9 @@ export interface RevisionEntry {
       change?: string
       from: number
       to: number
    +  /** absent for body changes; the story a change outside the body lives in */
    +  location?: StoryKind
    +  story?: StoryRevision
     }
     
     export interface RevisionSelector {
    @@ -49,16 +54,25 @@ const TYPE_OF: Record = {
       blockIns: 'insertion',
       rowIns: 'insertion',
       cellIns: 'insertion',
    -  // ins+del marks on the same text count as a deletion: both accept and reject remove it
    -  both: 'deletion',
       del: 'deletion',
       blockDel: 'deletion',
       rowDel: 'deletion',
       cellDel: 'deletion',
    +  paraMarkIns: 'insertion',
    +  paraMarkDel: 'deletion',
       pPrChange: 'formatting',
       rPrChange: 'formatting',
    +  paraMarkRPrChange: 'formatting',
       moveFrom: 'move',
       moveTo: 'move',
    +  tblPrChange: 'formatting',
    +  tblGridChange: 'formatting',
    +  trPrChange: 'formatting',
    +  tblPrExChange: 'formatting',
    +  tcPrChange: 'formatting',
    +  cellMerge: 'formatting',
    +  numberingChange: 'formatting',
    +  sectPrChange: 'formatting',
     }
     
     function blockIndexOfPos(doc: PmNode, pos: number): number {
    @@ -125,9 +139,9 @@ function paragraphFormatChange(doc: PmNode, r: RevisionRange): string | undefine
       return pairs.length ? pairs.join('; ') : undefined
     }
     
    -/** Every pending tracked change with a positional id and enough context to pick it. */
    -export function listRevisionEntries(doc: PmNode): RevisionEntry[] {
    -  return collectRevisions(doc).map((r, i) => {
    +/** Every pending tracked change with a positional id and enough context to pick it (stories after the body). */
    +export function listRevisionEntries(doc: PmNode, stories: StoryRevision[] = []): RevisionEntry[] {
    +  const body: RevisionEntry[] = collectRevisions(doc).map((r, i) => {
         const change =
           r.kind === 'rPrChange'
             ? runFormatChange(doc, r)
    @@ -142,12 +156,29 @@ export function listRevisionEntries(doc: PmNode): RevisionEntry[] {
           author: r.author || 'unknown',
           ...(r.date ? { date: r.date } : {}),
           blockIndex: blockIndexOfPos(doc, r.from),
    -      text: doc.textBetween(r.from, r.to, '\n', ' ').replace(/\s+/g, ' ').trim(),
    +      // the model's copy, not the document's: a revision range can cover a
    +      // withheld span, and this text is sent to the model as-is
    +      text: redactTextBetween(doc.toJSON(), r.from, r.to, ' ').replace(/\s+/g, ' ').trim(),
           ...(change ? { change } : {}),
           from: r.from,
           to: r.to,
         }
       })
    +  const outside: RevisionEntry[] = stories.map((r, i) => ({
    +    id: `r${body.length + i + 1}`,
    +    type: TYPE_OF[r.kind],
    +    kind: r.kind,
    +    author: r.author || 'unknown',
    +    ...(r.date ? { date: r.date } : {}),
    +    // header / footer parts sit in no body block: -1 never matches a blockIndex / blockRange pick
    +    blockIndex: r.from >= 0 ? blockIndexOfPos(doc, r.from) : -1,
    +    text: r.story.text,
    +    from: r.from,
    +    to: r.to,
    +    location: r.story.kind,
    +    story: r,
    +  }))
    +  return [...body, ...outside]
     }
     
     function tally(entries: RevisionEntry[], key: 'author' | 'type'): string {
    @@ -220,8 +251,9 @@ export function validateSelector(
     export function selectRevisions(
       doc: PmNode,
       sel: RevisionSelector,
    +  stories: StoryRevision[] = [],
     ): { entries: RevisionEntry[] } | { error: string } {
    -  const all = listRevisionEntries(doc)
    +  const all = listRevisionEntries(doc, stories)
       if (all.length === 0) return { error: 'the document has no pending tracked changes' }
       if (sel.ids) {
         const known = new Map(all.map((e) => [e.id, e]))
    @@ -255,23 +287,34 @@ export function selectRevisions(
       return { entries }
     }
     
    +export interface StoryRevisionAccess {
    +  list(): StoryRevision[]
    +  apply(targets: StoryRevision[], mode: 'accept' | 'reject', all?: boolean): number
    +}
    +
     /** accept or reject the selected changes in one transaction, last position first */
     export function applyRevisionSelection(
       editor: Editor,
       sel: RevisionSelector,
       mode: 'accept' | 'reject',
    +  stories?: StoryRevisionAccess,
     ): { entries: RevisionEntry[] } | { error: string } {
    -  const picked = selectRevisions(editor.state.doc, sel)
    +  const picked = selectRevisions(editor.state.doc, sel, stories?.list() ?? [])
       if ('error' in picked) return picked
    +  // stories first: textbox entries address their anchor node by position, which body edits shift
    +  const outside = picked.entries.filter((e) => e.story).map((e) => e.story!)
    +  if (outside.length) stories?.apply(outside, mode, !!sel.all)
       applyRevisions(
         editor,
    -    picked.entries.map((e) => ({
    -      from: e.from,
    -      to: e.to,
    -      kind: e.kind,
    -      author: e.author,
    -      ...(e.date ? { date: e.date } : {}),
    -    })),
    +    picked.entries
    +      .filter((e) => !e.story)
    +      .map((e) => ({
    +        from: e.from,
    +        to: e.to,
    +        kind: e.kind,
    +        author: e.author,
    +        ...(e.date ? { date: e.date } : {}),
    +      })),
         mode,
       )
       return picked
    diff --git a/apps/docs/src/renderer/ai/skills-skill.ts b/apps/docs/src/renderer/ai/skills-skill.ts
    new file mode 100644
    index 000000000..dd1370251
    --- /dev/null
    +++ b/apps/docs/src/renderer/ai/skills-skill.ts
    @@ -0,0 +1,99 @@
    +import type { AgentSkill } from '@hermesoffice/agent-core'
    +import type { UserCliRun } from '../../shared/ipc'
    +import { t } from '../i18n/locale'
    +
    +/**
    + * The command line the user's skills teach, as an AgentSkill.
    + *
    + * Skills themselves are not offered to the model: no listing, no lookup. The
    + * only way a skill's text reaches a turn is the user attaching it from the
    + * composer "+" menu, and then it rides in front of the request (see
    + * withAttachedSkills). What stays here is `run_cli`, so an attached skill
    + * that says "run hermesoffice convert …" can be followed. The main process owns
    + * the boundary — it spawns the bundled hermesoffice CLI and nothing else
    + * (user-cli.ts) — so this file is a caller, not a guard.
    + */
    +
    +const SKILLS_SYSTEM_PROMPT = `## Attached skills and the command line
    +The user may attach a skill to a message: its full text then appears at the
    +top of that message inside  tags. Follow its steps with the document
    +tools you already have; quote its constraints rather than paraphrasing them,
    +and prefer its steps over guessing. Never assume a skill the user did not
    +attach.
    +- run_cli executes the bundled hermesoffice command line (the same "hermesoffice …
    +  --json" contract skills describe). Use it when an attached skill directs you
    +  to, or for batch work no single document call covers. It runs only this
    +  app's CLI — it is not a general shell — and long output is truncated.`
    +
    +export function createSkillsSkill(): AgentSkill {
    +  return {
    +    id: 'skills',
    +    systemPrompt: SKILLS_SYSTEM_PROMPT,
    +    tools: [
    +      {
    +        name: 'run_cli',
    +        description:
    +          "Run the bundled hermesoffice command line (it always answers --json). Only this app's own CLI, not a general shell: document commands such as convert, create, info, docs, sheet, slides, pdf; no --force, and apply/replace must take --out. Relative paths resolve in the open document's folder; reads and writes stay inside the allowed folders the result names.",
    +        inputSchema: {
    +          type: 'object',
    +          properties: {
    +            args: {
    +              type: 'array',
    +              items: { type: 'string' },
    +              description: 'the command and its arguments, e.g. ["convert","in.docx","--to","pdf"]',
    +            },
    +          },
    +          required: ['args'],
    +        },
    +      },
    +    ],
    +    executeTool: async (call) => {
    +      try {
    +        if (call.name === 'run_cli') {
    +          const args = call.input.args
    +          if (
    +            !Array.isArray(args) ||
    +            args.length === 0 ||
    +            args.some((a) => typeof a !== 'string')
    +          ) {
    +            return {
    +              output: 'args must be a non-empty array of strings',
    +              isError: true,
    +              summary: t('aiSumRunCli', { args: '?' }),
    +            }
    +          }
    +          const out: UserCliRun = await window.desktop.runCli(args as string[])
    +          const json = (out.json ?? {}) as { summary?: string; output_path?: string }
    +          const summary = typeof json.summary === 'string' ? `\nsummary: ${json.summary}` : ''
    +          const where = [
    +            out.cwd ? `cwd: ${out.cwd}` : '',
    +            out.allowedRoots?.length ? `allowed folders: ${out.allowedRoots.join(', ')}` : '',
    +          ]
    +            .filter(Boolean)
    +            .join('\n')
    +          return {
    +            output: `exit ${out.code}${summary}\n${out.stdout}${
    +              out.stderr ? `\n[stderr]\n${out.stderr}` : ''
    +            }${where ? `\n${where}` : ''}`,
    +            isError: !out.ok,
    +            // a CLI run that wrote a file changed the world, not this editor's
    +            // document: count it as a mutation so the loop does not treat the
    +            // repeat as a redundant read, and so the final answer reports it
    +            mutated: out.ok && typeof json.output_path === 'string',
    +            summary: t('aiSumRunCli', { args: (args as string[]).join(' ') }),
    +          }
    +        }
    +        return { output: `unknown tool: ${call.name}`, isError: true, summary: call.name }
    +      } catch (err) {
    +        // in a standalone docs window (not the full app) the main process has
    +        // no handler for these channels — say that instead of leaking a raw
    +        // IPC error into the transcript
    +        return {
    +          output: err instanceof Error ? err.message : String(err),
    +          isError: true,
    +          summary: call.name,
    +        }
    +      }
    +    },
    +  }
    +}
    diff --git a/apps/docs/src/renderer/ai/style-ops.ts b/apps/docs/src/renderer/ai/style-ops.ts
    index 53c57b63c..73df41dfd 100644
    --- a/apps/docs/src/renderer/ai/style-ops.ts
    +++ b/apps/docs/src/renderer/ai/style-ops.ts
    @@ -3,6 +3,7 @@ import type { StyleParaProps, StyleRunProps, StyleUpsert } from '@hermesoffice/d
     import type { AgentToolDef } from '../../shared/ipc'
     import { parsePoints } from './lengths'
     import type { Op, OpContext, OpDef, OpResult, RunEnv, SelRange, Target, TopBlock } from './ops'
    +import { tabSpecsToStops, validateTabs, type TabSpec } from './tab-stops'
     
     /** A style as the AI sees it: the styles.xml entry plus definitions pending in this session. */
     export interface AiStyleInfo {
    @@ -105,6 +106,12 @@ export const DEFINE_STYLE_TOOL: AgentToolDef = {
                 description:
                   '1-9: the style becomes a heading of that level in the navigation pane / TOC',
               },
    +          tabs: {
    +            type: ['array', 'null'],
    +            description:
    +              'tab stops of the style (replaces its w:tabs; [] or null removes them): [{ pos: twips or "6.5in", align?: left|center|right|decimal|bar, leader?: none|dot|hyphen|underscore|middleDot, clear?: true }]',
    +            items: { type: 'object' },
    +          },
             },
           },
           run: {
    @@ -240,6 +247,11 @@ function paraProps(input: Record): Result {
           return err('paragraph.outlineLevel must be 1-9 or null')
         out.outlineLevel = v as number | null
       }
    +  if (input.tabs !== undefined) {
    +    const error = validateTabs(input.tabs, 'paragraph')
    +    if (error) return err(error)
    +    out.tabStops = tabSpecsToStops(input.tabs as TabSpec[] | null)
    +  }
       return { value: out }
     }
     
    diff --git a/apps/docs/src/renderer/ai/tab-stops.ts b/apps/docs/src/renderer/ai/tab-stops.ts
    new file mode 100644
    index 000000000..0f8115519
    --- /dev/null
    +++ b/apps/docs/src/renderer/ai/tab-stops.ts
    @@ -0,0 +1,42 @@
    +import type { TabStop } from '@hermesoffice/docx-engine'
    +import { parseTwips } from './lengths'
    +
    +export interface TabSpec {
    +  /** twips, or a length string ("1in", "6.5in") */
    +  pos: number | string
    +  align?: 'left' | 'center' | 'right' | 'decimal' | 'bar'
    +  leader?: 'none' | 'dot' | 'hyphen' | 'underscore' | 'middleDot' | 'heavy'
    +  /** cancel the style-inherited stop at this position instead of defining one */
    +  clear?: boolean
    +}
    +
    +const TAB_ALIGNS = ['left', 'center', 'right', 'decimal', 'bar'] as const
    +const TAB_LEADERS = ['none', 'dot', 'hyphen', 'underscore', 'middleDot', 'heavy'] as const
    +
    +/** shared by setParagraphFormat and define_style → paragraph.tabs */
    +export function validateTabs(tabs: unknown, where: string): string | null {
    +  if (tabs === null) return null
    +  if (!Array.isArray(tabs))
    +    return `${where}: tabs must be an array of { pos, align?, leader?, clear? } or null`
    +  for (const t of tabs as Record[]) {
    +    if (!t || typeof t !== 'object') return `${where}: each tab must be an object`
    +    if (parseTwips(t.pos) === undefined)
    +      return `${where}: tab pos must be twips or a length like "6.5in"`
    +    if (t.align !== undefined && !(TAB_ALIGNS as readonly unknown[]).includes(t.align))
    +      return `${where}: tab align must be ${TAB_ALIGNS.join(' / ')}`
    +    if (t.leader !== undefined && !(TAB_LEADERS as readonly unknown[]).includes(t.leader))
    +      return `${where}: tab leader must be ${TAB_LEADERS.join(' / ')}`
    +    if (t.clear !== undefined && typeof t.clear !== 'boolean')
    +      return `${where}: tab clear must be a boolean`
    +  }
    +  return null
    +}
    +
    +/** validated TabSpec list → engine stops (w:tabs entries); null/[] → [] */
    +export function tabSpecsToStops(tabs: TabSpec[] | null): TabStop[] {
    +  return (tabs ?? []).map((t) => {
    +    const stop: TabStop = { pos: parseTwips(t.pos)!, val: t.clear ? 'clear' : (t.align ?? 'left') }
    +    if (t.leader && t.leader !== 'none' && !t.clear) stop.leader = t.leader
    +    return stop
    +  })
    +}
    diff --git a/apps/docs/src/renderer/ai/table-ops.ts b/apps/docs/src/renderer/ai/table-ops.ts
    index 44dd697c0..eef5fc05a 100644
    --- a/apps/docs/src/renderer/ai/table-ops.ts
    +++ b/apps/docs/src/renderer/ai/table-ops.ts
    @@ -61,14 +61,19 @@ const LENGTH = /^\s*(\d+(?:\.\d+)?)\s*(twip|pt|px|in|cm|mm)\s*$/i
     /** Word's default body width (Letter, 1in margins); only used for tables without any width */
     const DEFAULT_BODY_TWIPS = 9360
     
    +/** Largest table length honored (~35in in twips): uncapped AI lengths
    + *  break layout, mirroring the parseEmu/parseTwips budget. */
    +export const MAX_TABLE_TWIPS = 50400
    +
     /** number = twips; "2.5cm" / "1in" / "72pt" / "96px" / "10mm" */
     export function parseTableLength(value: unknown): number | undefined {
    -  if (typeof value === 'number') return value > 0 ? value : undefined
    +  if (typeof value === 'number')
    +    return Number.isFinite(value) && value > 0 && value <= MAX_TABLE_TWIPS ? value : undefined
       if (typeof value !== 'string') return undefined
       const m = LENGTH.exec(value)
       if (!m) return undefined
       const twips = Number(m[1]) * TWIPS_PER[m[2]!.toLowerCase()]!
    -  return twips > 0 ? twips : undefined
    +  return Number.isFinite(twips) && twips > 0 && twips <= MAX_TABLE_TWIPS ? twips : undefined
     }
     
     interface TableHit {
    @@ -185,7 +190,7 @@ function cellsInRect(map: TableMap, rect: Rect): number[] {
     }
     
     /** px per grid column when the table carries absolute widths */
    -function columnWidthsPx(table: PmNode, map: TableMap): number[] | null {
    +export function columnWidthsPx(table: PmNode, map: TableMap): number[] | null {
       const widths: number[] = []
       const first = table.firstChild
       if (!first) return null
    @@ -214,7 +219,7 @@ function derivedWidthsPx(table: PmNode, map: TableMap): number[] {
      * array (px per grid column). `widths === null` keeps the table percentage
      * based: cells lose their px widths and colWidthsPct is renormalized.
      */
    -function reflowColumns(
    +export function reflowColumns(
       tr: Transaction,
       tablePos: number,
       widths: number[] | null,
    @@ -245,7 +250,7 @@ function reflowColumns(
       })
     }
     
    -function pctOf(table: PmNode, map: TableMap, widths: number[] | null): number[] {
    +export function pctOf(table: PmNode, map: TableMap, widths: number[] | null): number[] {
       if (widths) {
         const total = widths.reduce((s, w) => s + w, 0) || 1
         return widths.map((w) => (w / total) * 100)
    @@ -257,7 +262,7 @@ function pctOf(table: PmNode, map: TableMap, widths: number[] | null): number[]
     }
     
     /** prosemirror-tables' addColumn maps through the whole transaction; this one only through its own steps */
    -function insertColumnAt(tr: Transaction, tablePos: number, col: number): void {
    +export function insertColumnAt(tr: Transaction, tablePos: number, col: number): void {
       const table = tr.doc.nodeAt(tablePos)!
       const map = TableMap.get(table)
       const start = tablePos + 1
    @@ -290,13 +295,14 @@ const COPIED_CELL_ATTRS = [
       'vAlign',
       'cellMar',
       'textDirection',
    +  'noWrap',
       'align',
       'bold',
       'color',
     ] as const
     
     /** new rows/columns take the visual attrs of the neighbouring cell, like Word */
    -function copyCellFormatting(
    +export function copyCellFormatting(
       tr: Transaction,
       tablePos: number,
       isNew: (rect: Rect) => boolean,
    @@ -316,14 +322,15 @@ function copyCellFormatting(
         if (refRow < 0 || refRow >= map.height || refCol < 0 || refCol >= map.width) continue
         const ref = table.nodeAt(map.map[refRow * map.width + refCol]!)!
         const cell = table.nodeAt(pos)!
    -    const patch: Record = {}
    +    // a new cell has no raw tcPr: the copied direction / margins / noWrap only reach the file when written from the attrs
    +    const patch: Record = { tcPrEdited: true }
         for (const k of COPIED_CELL_ATTRS) patch[k] = ref.attrs[k]
         tr.setNodeMarkup(start + pos, undefined, { ...cell.attrs, ...patch })
       }
     }
     
     /** prosemirror-tables' addRow, but positions are read from the current doc, not mapped through earlier batch steps */
    -function insertRowAt(tr: Transaction, tablePos: number, row: number): void {
    +export function insertRowAt(tr: Transaction, tablePos: number, row: number): void {
       const table = tr.doc.nodeAt(tablePos)!
       const map = TableMap.get(table)
       const start = tablePos + 1
    diff --git a/apps/docs/src/renderer/ai/tools.ts b/apps/docs/src/renderer/ai/tools.ts
    index b95f6820e..bef7be041 100644
    --- a/apps/docs/src/renderer/ai/tools.ts
    +++ b/apps/docs/src/renderer/ai/tools.ts
    @@ -1,10 +1,14 @@
     import type { Editor } from '@tiptap/core'
     import type { Mark, Node as ProseMirrorNode } from '@tiptap/pm/model'
    -import type { ChartDisplay, CommentInfo, NewChart } from '@hermesoffice/docx-engine'
    +import type { ChartDisplay, CommentInfo, NewChart, NoteInfo } from '@hermesoffice/docx-engine'
    +import type { ToolDisplay } from '@hermesoffice/agent-core'
     import type { AgentToolCall, AgentToolDef, CreateDocumentType } from '../../shared/ipc'
     import { t } from '../i18n/locale'
    +import { MIND_MAP_LIMITS, MIND_MAP_TOOL, parseMindMapTree } from '@hermesoffice/agent-core/mind-map'
     import { executeOps, opNames } from './ops'
     import { clipExcerpt, repliesOf, resolveCommentAnchor } from './comment-ops'
    +import { redactTextBetween } from './redact-view'
    +import { insertPictureAtSelection } from '../editor/insert-picture'
     import {
       PAGE_SETUP_TOOL,
       SECTION_BREAK_TOOL,
    @@ -41,9 +45,11 @@ import {
       describePending,
       listRevisionEntries,
       validateSelector,
    +  type StoryRevisionAccess,
     } from './revision-ops'
     import {
       buildNotesContext,
    +  editNoteText,
       insertNoteRef,
       noteInsertPos,
       removeNoteRefs,
    @@ -65,6 +71,8 @@ import {
       isBlankDocument,
       isTrackedDeleted,
       parseHtmlFragment,
    +  resolveImageRefs,
    +  countInlineImages,
       parseInlineFragment,
       replaceBlockRange,
       replaceInlineRange,
    @@ -116,7 +124,11 @@ export const AGENT_TOOLS: AgentToolDef[] = [
         inputSchema: {
           type: 'object',
           properties: {
    -        html: { type: 'string', description: 'restricted HTML fragment to insert' },
    +        html: {
    +          type: 'string',
    +          description:
    +            'restricted HTML fragment to insert;  is a tab character (literal tabs collapse to spaces)',
    +        },
             afterBlockIndex: {
               type: 'integer',
               description:
    @@ -166,7 +178,11 @@ export const AGENT_TOOLS: AgentToolDef[] = [
           properties: {
             startBlockIndex: { type: 'integer', description: 'start block index (0-based, inclusive)' },
             endBlockIndex: { type: 'integer', description: 'end block index (inclusive)' },
    -        html: { type: 'string', description: 'replacement restricted HTML fragment' },
    +        html: {
    +          type: 'string',
    +          description:
    +            'replacement restricted HTML fragment;  is a tab character',
    +        },
           },
           required: ['startBlockIndex', 'endBlockIndex', 'html'],
         },
    @@ -359,6 +375,26 @@ export const AGENT_TOOLS: AgentToolDef[] = [
           required: ['kind', 'id'],
         },
       },
    +  {
    +    name: 'edit_note',
    +    description:
    +      'Change the text of an existing footnote or endnote in place (findReplace inside the note): the note keeps its id, reference mark and formatting. Use for a typo or a citation fix instead of delete_note + insert_footnote.',
    +    inputSchema: {
    +      type: 'object',
    +      properties: {
    +        kind: {
    +          type: 'string',
    +          enum: ['footnote', 'endnote'],
    +          description: 'omit when the id is unambiguous across footnotes and endnotes',
    +        },
    +        id: { type: 'string', description: 'note id from read_notes' },
    +        find: { type: 'string', description: 'exact text inside the note to replace' },
    +        replace: { type: 'string', description: 'replacement text ("" deletes the match)' },
    +        matchCase: { type: 'boolean', description: 'defaults to true' },
    +      },
    +      required: ['id', 'find', 'replace'],
    +    },
    +  },
       {
         name: 'read_notes',
         description:
    @@ -617,6 +653,7 @@ export const AGENT_TOOLS: AgentToolDef[] = [
           required: ['title', 'content'],
         },
       },
    +  MIND_MAP_TOOL,
     ]
     
     /**
    @@ -639,6 +676,10 @@ export interface AiHeaderFooterAccess {
     export interface AiDocExtras {
       styles?: AiStyleAccess
       watermark?: AiWatermarkAccess
    +  /** protection (read-only or enforced tracked changes) forbids accepting / rejecting revisions */
    +  revisionsLocked?: boolean
    +  /** tracked changes in notes, header / footer parts and textboxes */
    +  storyRevisions?: StoryRevisionAccess
     }
     
     export interface AiCommentsAccess {
    @@ -678,6 +719,8 @@ export interface ToolExecution {
       mutated: boolean
       /** short human-readable label for the chat activity chip */
       summary: string
    +  /** UI-only side channel (never enters the LLM context) */
    +  display?: ToolDisplay
     }
     
     const fail = (summary: string, output: string): ToolExecution => ({
    @@ -1025,7 +1068,11 @@ async function setPictureWatermark(
         ? { base64: url.slice(url.indexOf(',') + 1) }
         : await window.desktop.fetchImage(url)
       if (signal?.aborted) return fail(summary, 'stopped by the user; the watermark was not set')
    -  if (!fetched) return fail(summary, 'download failed (the image may not be accessible)')
    +  if (!fetched)
    +    return fail(
    +      summary,
    +      'download failed (the image host refused or the link expired); do not retry this URL — pick a different result from the image search or search again',
    +    )
       const mime = sniffImageMime(fetched.base64)
       if (!mime) return fail(summary, 'unsupported image format (only png/jpg/gif can be embedded)')
       let natural: { width: number; height: number }
    @@ -1072,7 +1119,11 @@ async function insertPicture(
         ? { base64: url.slice(url.indexOf(',') + 1) }
         : await window.desktop.fetchImage(url)
       if (signal?.aborted) return fail(summary, 'stopped by the user; the picture was not inserted')
    -  if (!fetched) return fail(summary, 'download failed (the image may not be accessible)')
    +  if (!fetched)
    +    return fail(
    +      summary,
    +      'download failed (the image host refused or the link expired); do not retry this URL — pick a different result from the image search or search again',
    +    )
       const mime = sniffImageMime(fetched.base64)
       if (!mime) return fail(summary, 'unsupported image format (only png/jpg/gif can be embedded)')
       let natural: { width: number; height: number }
    @@ -1117,7 +1168,11 @@ async function insertImageFromUrl(
       // never write after the user hit stop (the download may resolve long after the abort)
       if (signal?.aborted)
         return fail(labels.failLabel, 'stopped by the user; the image was not inserted')
    -  if (!fetched) return fail(labels.failLabel, 'download failed (the image may not be accessible)')
    +  if (!fetched)
    +    return fail(
    +      labels.failLabel,
    +      'download failed (the image host refused or the link expired); do not retry this URL — pick a different result from the image search or search again',
    +    )
       const mime = sniffImageMime(fetched.base64)
       if (!mime) {
         return fail(
    @@ -1137,22 +1192,14 @@ async function insertImageFromUrl(
         // freshness baseline stale, so only our own insertion may mark the doc
         // seen. Checked right before the write — there is no async gap after.
         const userEditedDuringFetch = editedExternally(editor)
    -    editor
    -      .chain()
    -      .focus()
    -      .insertContent({
    -        type: 'docProtected',
    -        attrs: {
    -          docxIndex: null,
    -          blockType: 'image',
    -          label: labels.blockLabel,
    -          imageDataUrl: dataUrl,
    -          imageWidthPx: w,
    -          imageHeightPx: h,
    -          genImage: { base64: fetched.base64, mime, widthPx: w, heightPx: h },
    -        },
    -      })
    -      .run()
    +    insertPictureAtSelection(editor, {
    +      dataUrl,
    +      base64: fetched.base64,
    +      mime,
    +      widthPx: w,
    +      heightPx: h,
    +      label: labels.blockLabel,
    +    })
         if (!userEditedDuringFetch) markDocSeen(editor)
         return {
           output: `Inserted the image (${w}×${h}px).`,
    @@ -1196,6 +1243,7 @@ export function executeTool(
         call.name === 'insert_image' ||
         call.name === 'insert_picture' ||
         call.name === 'generate_image' ||
    +    call.name === 'analyze_media' ||
         call.name === 'create_document'
       ) {
         return executeAsyncTool(editor, call, signal)
    @@ -1270,6 +1318,7 @@ async function writeDocument(
           return fail(t('aiSumWriteDocumentFailed'), e instanceof Error ? e.message : String(e))
         nodes = parseHtmlFragment(rendered, numIds)
       }
    +  nodes = resolveImageRefs(editor, nodes).nodes
       if (nodes.length === 0)
         return fail(
           t('aiSumWriteDocumentFailed'),
    @@ -1321,6 +1370,26 @@ function executeSyncTool(
             summary: t('aiSumReadDocContext'),
           }
     
    +    case 'generate_mindmap': {
    +      const summary = t('aiSumMindMap')
    +      const parsed = parseMindMapTree(call.input, editor.state.doc.childCount)
    +      if (!parsed.ok) return fail(summary, parsed.error)
    +      const focus = typeof call.input.focus === 'string' ? call.input.focus.trim() : ''
    +      const trimmedNote = parsed.trimmed
    +        ? ` (trimmed to the ${MIND_MAP_LIMITS.maxNodes}-node / depth-${MIND_MAP_LIMITS.maxDepth} limit)`
    +        : ''
    +      return {
    +        output:
    +          `Mind map rendered as an interactive card in the chat: ${parsed.nodeCount} nodes, depth ${parsed.depth}` +
    +          `${focus ? `, focused on "${focus.slice(0, MIND_MAP_LIMITS.maxFocusChars)}"` : ''}${trimmedNote}. ` +
    +          'The user can expand branches, click a node to ask about it, jump to the cited block, or export the map. ' +
    +          'Do not repeat the map content; reply with one short sentence.',
    +        mutated: false,
    +        summary,
    +        display: { kind: 'mindmap', tree: parsed.tree },
    +      }
    +    }
    +
         case 'read_blocks': {
           const range = validRange(editor, call.input.startBlockIndex, call.input.endBlockIndex)
           if (!range) return fail(t('aiSumReadBlocks'), rangeError(editor))
    @@ -1367,6 +1436,7 @@ function executeSyncTool(
           } catch (e) {
             return fail(t('aiSumInsertContent'), e instanceof Error ? e.message : String(e))
           }
    +      nodes = resolveImageRefs(editor, nodes).nodes
           if (nodes.length === 0)
             return fail(t('aiSumInsertContent'), 'html did not parse into any content blocks')
           const count = editor.state.doc.childCount
    @@ -1405,11 +1475,24 @@ function executeSyncTool(
           } catch (e) {
             return fail(t('aiSumReplaceContent'), e instanceof Error ? e.message : String(e))
           }
    +      const resolved = resolveImageRefs(editor, nodes)
    +      nodes = resolved.nodes
           if (nodes.length === 0)
             return fail(t('aiSumReplaceContent'), 'html did not parse into any content blocks')
    +      const had = countInlineImages(editor, range.start, range.end)
    +      const keptInRange = new Set(
    +        resolved.kept
    +          .filter((k) => k.block >= range.start && k.block <= range.end)
    +          .map((k) => `${k.block}.${k.index}`),
    +      ).size
    +      const lost = had - keptInRange
           replaceBlockRange(editor, range.start, range.end, nodes, track)
    +      const pictureNote =
    +        lost > 0
    +          ? ` ${lost} picture(s) that were in the replaced range are not in the new content and have been deleted${track ? ' (as a tracked change the user can reject)' : ' (the user can undo this)'}; tell the user if that was not intended. To keep pictures, include their  tags from read_blocks in the new HTML.`
    +          : ''
           return {
    -        output: `Replaced blocks ${range.start}-${range.end} with ${nodes.length} block(s); the new blocks kept the replaced blocks' formatting. Block indexes have changed; use get_document_context if needed.`,
    +        output: `Replaced blocks ${range.start}-${range.end} with ${nodes.length} block(s); the new blocks kept the replaced blocks' formatting.${pictureNote} Block indexes have changed; use get_document_context if needed.`,
             mutated: true,
             summary: t('aiSumReplacedBlocks', { start: range.start, end: range.end }),
           }
    @@ -1441,11 +1524,13 @@ function executeSyncTool(
           } catch (e) {
             return fail(t('aiSumReplaceSelection'), e instanceof Error ? e.message : String(e))
           }
    +      inline = resolveImageRefs(editor, inline).nodes
           if (inline.length === 0) {
             return fail(t('aiSumReplaceSelection'), 'html did not parse into any text')
           }
           const schema = editor.schema
    -      const fragmentStyled = inline.some((n) => (n.marks ?? []).length > 0)
    +      // a carried-over picture's own marks are not the model restyling the selection
    +      const fragmentStyled = inline.some((n) => n.type === 'text' && (n.marks ?? []).length > 0)
           const anchor = $from.parent.childAfter($from.parentOffset).node
           const inherited = (anchor?.marks ?? []).filter(
             (m) =>
    @@ -1459,7 +1544,9 @@ function executeSyncTool(
             return schema.text(n.text ?? '', marks)
           })
           const blockIndex = $from.index(0)
    -      const oldText = doc.textBetween(range.from, range.to, ' ', ' ')
    +      // the echo quotes what was replaced back to the model, so a selection
    +      // covering a withheld span would undo the withholding right here
    +      const oldText = redactTextBetween(editor.getJSON(), range.from, range.to, ' ')
           replaceInlineRange(editor, range.from, range.to, nodes, track)
           const newText = nodes.map((n) => n.textContent).join('')
           return {
    @@ -1622,7 +1709,7 @@ function executeSyncTool(
     
         case 'read_revisions':
           return {
    -        output: buildRevisionsContext(editor),
    +        output: buildRevisionsContext(editor, extras?.storyRevisions?.list()),
             mutated: false,
             summary: t('aiSumReadRevisions'),
           }
    @@ -1631,11 +1718,16 @@ function executeSyncTool(
         case 'reject_changes': {
           const mode = call.name === 'accept_changes' ? 'accept' : 'reject'
           const summary = t(mode === 'accept' ? 'aiSumAcceptChanges' : 'aiSumRejectChanges')
    +      if (extras?.revisionsLocked)
    +        return fail(
    +          summary,
    +          'document protection enforces tracked changes (or read-only editing): revisions cannot be accepted or rejected until the protection is removed',
    +        )
           const sel = validateSelector(call.input)
           if ('error' in sel) return fail(summary, sel.error)
    -      const r = applyRevisionSelection(editor, sel, mode)
    +      const r = applyRevisionSelection(editor, sel, mode, extras?.storyRevisions)
           if ('error' in r) return fail(summary, r.error)
    -      const left = listRevisionEntries(editor.state.doc)
    +      const left = listRevisionEntries(editor.state.doc, extras?.storyRevisions?.list())
           const verb = mode === 'accept' ? 'Accepted' : 'Rejected'
           return {
             output: `${verb} ${r.entries.length} tracked change(s) (${r.entries.map((e) => e.id).join(', ')}). ${left.length ? `Still ${describePending(left)}.` : 'No tracked changes remain.'}`,
    @@ -1750,6 +1842,43 @@ function executeSyncTool(
           }
         }
     
    +    case 'edit_note': {
    +      const summary = t('aiSumEditNote')
    +      if (!notes) return fail(summary, 'footnotes and endnotes are not available here')
    +      const id = String(call.input.id ?? '').trim()
    +      if (!id) return fail(summary, 'id must not be empty')
    +      const kinds: NoteKind[] =
    +        call.input.kind === undefined || call.input.kind === null
    +          ? ['footnote', 'endnote']
    +          : call.input.kind === 'footnote' || call.input.kind === 'endnote'
    +            ? [call.input.kind]
    +            : []
    +      if (kinds.length === 0) return fail(summary, 'kind must be "footnote" or "endnote"')
    +      const found = kinds
    +        .map((kind) => ({ kind, note: notes.list(kind).find((n) => n.id === id) }))
    +        .filter((hit): hit is { kind: NoteKind; note: NoteInfo } => hit.note !== undefined)
    +      if (found.length === 0)
    +        return fail(summary, `no note with id ${id}; call read_notes for the current ids`)
    +      if (found.length > 1)
    +        return fail(summary, `both a footnote and an endnote have id ${id}; pass kind`)
    +      const { kind, note } = found[0]!
    +      const find = typeof call.input.find === 'string' ? call.input.find : ''
    +      if (!find) return fail(summary, 'find must not be empty')
    +      const replace = call.input.replace == null ? '' : String(call.input.replace)
    +      const edited = editNoteText(note, find, replace, call.input.matchCase !== false)
    +      if (edited.count === 0)
    +        return fail(
    +          summary,
    +          `${kind} ${id} does not contain "${clipExcerpt(find)}"; the note was left as is`,
    +        )
    +      notes.replace(kind, id, edited.note)
    +      return {
    +        output: `Edited ${kind} ${id}: ${edited.count} occurrence${edited.count === 1 ? '' : 's'} of "${clipExcerpt(find)}" replaced.`,
    +        mutated: true,
    +        summary,
    +      }
    +    }
    +
         case 'read_notes': {
           const summary = t('aiSumReadNotes')
           if (!notes) return fail(summary, 'footnotes and endnotes are not available here')
    @@ -1831,7 +1960,10 @@ function executeSyncTool(
             return fail(summaryOf(), 'view must be "default", "first" or "even"')
           }
           if (typeof call.input.text !== 'string') return fail(summaryOf(), 'text must be a string')
    -      const text = call.input.text
    +      // a doubly escaped newline arrives as the two characters backslash-n; a footer must not show it literally
    +      const text = call.input.text.includes('\n')
    +        ? call.input.text
    +        : call.input.text.replace(/\\n/g, '\n')
           if (text.length > 2000) {
             return fail(summaryOf(), 'text is too long for a header/footer (2000 characters max)')
           }
    diff --git a/apps/docs/src/renderer/ai/transport.ts b/apps/docs/src/renderer/ai/transport.ts
    index 4fc4fd297..c56c10669 100644
    --- a/apps/docs/src/renderer/ai/transport.ts
    +++ b/apps/docs/src/renderer/ai/transport.ts
    @@ -14,5 +14,6 @@ export function createElectronTransport(getSettings: () => AiSettings): AgentTra
         creditsErrorText: () => t('aiCreditsExhausted'),
         networkErrorText: () => t('aiNetworkError'),
         overloadedErrorText: () => t('aiOverloadedError'),
    +    regionErrorText: () => t('aiRegionError'),
       })
     }
    diff --git a/apps/docs/src/renderer/author-name.ts b/apps/docs/src/renderer/author-name.ts
    new file mode 100644
    index 000000000..bdceb47ca
    --- /dev/null
    +++ b/apps/docs/src/renderer/author-name.ts
    @@ -0,0 +1,23 @@
    +import { t } from './i18n/locale'
    +
    +let current = ''
    +const listeners = new Set<() => void>()
    +
    +/** Settings → General user name; the generic label until the shell has answered */
    +export function authorName(): string {
    +  return current || t('editorDefaultAuthor')
    +}
    +
    +export function setAuthorName(name: string): void {
    +  const next = name.trim()
    +  if (next === current) return
    +  current = next
    +  for (const listener of listeners) listener()
    +}
    +
    +export function onAuthorNameChanged(listener: () => void): () => void {
    +  listeners.add(listener)
    +  return () => {
    +    listeners.delete(listener)
    +  }
    +}
    diff --git a/apps/docs/src/renderer/autocorrect-pref.ts b/apps/docs/src/renderer/autocorrect-pref.ts
    new file mode 100644
    index 000000000..55965a039
    --- /dev/null
    +++ b/apps/docs/src/renderer/autocorrect-pref.ts
    @@ -0,0 +1,65 @@
    +/** Persisted AutoCorrect switches (Tools → AutoCorrect Options…), read on every keystroke. */
    +export const AUTOCORRECT_KEY = 'aidocs.autocorrect'
    +
    +export const AUTOCORRECT_RULES = [
    +  'smartQuotes',
    +  'dashes',
    +  'autoLists',
    +  'symbols',
    +  'ordinals',
    +  'capitalize',
    +] as const
    +
    +export type AutocorrectRule = (typeof AUTOCORRECT_RULES)[number]
    +
    +// Word's defaults, except sentence capitalization: it misfires on CJK-mixed
    +// and technical text often enough that we leave it opt-in
    +export const AUTOCORRECT_DEFAULTS: Record = {
    +  smartQuotes: true,
    +  dashes: true,
    +  autoLists: true,
    +  symbols: true,
    +  ordinals: true,
    +  capitalize: false,
    +}
    +
    +function readStored(): Partial> {
    +  try {
    +    const raw = globalThis.localStorage?.getItem(AUTOCORRECT_KEY)
    +    if (!raw) return {}
    +    const parsed: unknown = JSON.parse(raw)
    +    return parsed && typeof parsed === 'object' ? (parsed as Record) : {}
    +  } catch {
    +    return {}
    +  }
    +}
    +
    +export function autocorrectPrefs(): Record {
    +  const stored = readStored()
    +  const out = { ...AUTOCORRECT_DEFAULTS }
    +  for (const rule of AUTOCORRECT_RULES) {
    +    if (typeof stored[rule] === 'boolean') out[rule] = stored[rule]
    +  }
    +  return out
    +}
    +
    +export function autocorrectEnabled(rule: AutocorrectRule): boolean {
    +  return autocorrectPrefs()[rule]
    +}
    +
    +export function setAutocorrectPref(rule: AutocorrectRule, on: boolean): void {
    +  const next = { ...readStored(), [rule]: on }
    +  try {
    +    globalThis.localStorage?.setItem(AUTOCORRECT_KEY, JSON.stringify(next))
    +  } catch {
    +    /* storage unavailable: the switch just doesn't persist */
    +  }
    +}
    +
    +export function resetAutocorrectPrefs(): void {
    +  try {
    +    globalThis.localStorage?.removeItem(AUTOCORRECT_KEY)
    +  } catch {
    +    /* ignore */
    +  }
    +}
    diff --git a/apps/docs/src/renderer/components/AutoCorrectDialog.tsx b/apps/docs/src/renderer/components/AutoCorrectDialog.tsx
    new file mode 100644
    index 000000000..56dc4ea8f
    --- /dev/null
    +++ b/apps/docs/src/renderer/components/AutoCorrectDialog.tsx
    @@ -0,0 +1,94 @@
    +/** Word's Tools ▸ AutoCorrect Options…: one switch per replace-as-you-type rule; applies immediately. */
    +import { useState } from 'react'
    +import {
    +  AUTOCORRECT_RULES,
    +  autocorrectPrefs,
    +  resetAutocorrectPrefs,
    +  setAutocorrectPref,
    +  type AutocorrectRule,
    +} from '../autocorrect-pref'
    +import { defaultPasteMode, setDefaultPasteMode, type PasteMode } from '../editor/paste-options'
    +import { useI18n, type StringKey } from '../i18n/locale'
    +import { useModalKeys } from './modal-keys'
    +
    +const PASTE_MODES: Array<[PasteMode, StringKey]> = [
    +  ['source', 'appPasteKeepSource'],
    +  ['merge', 'appPasteMergeFormat'],
    +  ['text', 'appPasteTextOnly'],
    +]
    +
    +const LABELS: Record = {
    +  smartQuotes: 'appAcSmartQuotes',
    +  dashes: 'appAcDashes',
    +  autoLists: 'appAcAutoLists',
    +  symbols: 'appAcSymbols',
    +  ordinals: 'appAcOrdinals',
    +  capitalize: 'appAcCapitalize',
    +}
    +
    +export function AutoCorrectDialog({ onClose }: { onClose: () => void }) {
    +  const { t } = useI18n()
    +  const modalKeys = useModalKeys(onClose)
    +  const [prefs, setPrefs] = useState(autocorrectPrefs)
    +  const [pasteMode, setPasteMode] = useState(defaultPasteMode)
    +
    +  const toggle = (rule: AutocorrectRule, on: boolean) => {
    +    setAutocorrectPref(rule, on)
    +    setPrefs(autocorrectPrefs())
    +  }
    +  const pickPasteMode = (mode: PasteMode) => {
    +    setDefaultPasteMode(mode)
    +    setPasteMode(mode)
    +  }
    +  const restore = () => {
    +    resetAutocorrectPrefs()
    +    setPrefs(autocorrectPrefs())
    +    pickPasteMode('source')
    +  }
    +
    +  return (
    +    
    e.target === e.currentTarget && onClose()} + > +
    +

    {t('appAcTitle')}

    +

    {t('appAcIntro')}

    + {AUTOCORRECT_RULES.map((rule) => ( + + ))} +

    {t('appAcUndoHint')}

    +

    {t('appAcPasteHeading')}

    +

    {t('appAcPasteFromOther')}

    + {PASTE_MODES.map(([mode, key]) => ( + + ))} +
    + + +
    +
    +
    + ) +} diff --git a/apps/docs/src/renderer/components/CommentsPanel.tsx b/apps/docs/src/renderer/components/CommentsPanel.tsx index 44d1b346b..b578c2c6c 100644 --- a/apps/docs/src/renderer/components/CommentsPanel.tsx +++ b/apps/docs/src/renderer/components/CommentsPanel.tsx @@ -1,26 +1,29 @@ -import { memo, useEffect, useMemo, useRef, useState } from 'react' +import { + memo, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, + type ReactNode, +} from 'react' import type { Node as PmNode } from '@tiptap/pm/model' -import type { CommentInfo } from '@hermesoffice/docx-engine' +import type { CommentImage, CommentInfo } from '@hermesoffice/docx-engine' import { useI18n } from '../i18n/locale' -import { IconComment, IconPencil, IconTrash } from './icons' +import { absoluteDate, authorInitials, authorInk } from '../editor/comment-card' +import { revWhen } from '../editor/margin-annotations' +import { commentImageFromFile } from '../review-actions' +import { IconCheck, IconComment, IconPicture } from './icons' -function formatDate(iso?: string): string { - if (!iso) return '' - const d = new Date(iso) - if (Number.isNaN(d.getTime())) return '' - return d.toLocaleString(undefined, { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - }) -} +/** the composer's thumbnail and the card's picture, as a displayable URL */ +const commentImageSrc = (image: CommentImage): string => `data:${image.mime};base64,${image.base64}` /** anchor text quoted from the document, to orient comments whose range spans paragraphs (one DOM pass for all threads) */ function collectAnchorTexts(): Map { const out = new Map() - for (const span of document.querySelectorAll('.ProseMirror .doc-comment')) { + for (const span of document.querySelectorAll( + '.ProseMirror:not(.mp-page) .doc-comment', + )) { const text = span.textContent ?? '' for (const id of (span.dataset.commentIds ?? '').split(' ')) { if (id) out.set(id, (out.get(id) ?? '') + text) @@ -30,7 +33,7 @@ function collectAnchorTexts(): Map { } function jumpTo(id: string) { - const spans = document.querySelectorAll('.ProseMirror .doc-comment') + const spans = document.querySelectorAll('.ProseMirror:not(.mp-page) .doc-comment') const targets = [...spans].filter((s) => (s.dataset.commentIds ?? '').split(' ').includes(id)) if (targets.length === 0) return targets[0].scrollIntoView({ behavior: 'smooth', block: 'center' }) @@ -42,9 +45,39 @@ function jumpTo(id: string) { } } +/** Word's modern comment header: avatar, author, relative time; thread actions on the right */ +function CommentHead({ + c, + unknownAuthor, + actions, +}: { + c: CommentInfo + unknownAuthor: string + actions?: ReactNode +}) { + const name = c.author || unknownAuthor + return ( +
    + + {authorInitials(c)} + + + {name} + {c.date && ( + + {revWhen(c.date)} + + )} + + {actions} +
    + ) +} + /** - * Word's comments pane: list + jump-to-anchor, a composer when New Comment was - * clicked (composing), and per-comment delete. + * Word's Comments pane: modern comment cards (avatar, author, relative time, + * quoted anchor, text, indented replies, inline Reply box), a composer when + * New Comment was clicked (composing), Resolve and a "…" menu per card. */ export const CommentsPanel = memo(function CommentsPanel({ comments, @@ -57,13 +90,14 @@ export const CommentsPanel = memo(function CommentsPanel({ onCancelNew, onDelete, onClose, + focus, }: { comments: CommentInfo[] /** current PM doc, used only as the anchor-scan cache key (doc unchanged ⇒ anchors unchanged) */ docNode: PmNode /** show the new-comment composer (selection captured by the caller) */ composing: boolean - onSubmitNew: (text: string) => void + onSubmitNew: (text: string, image?: CommentImage) => void /** Reply to a comment (anchor shares the parent comment's range) */ onReply: (parentId: string, text: string) => void /** Replace a comment's or reply's text in place; author, date and anchor stay */ @@ -73,15 +107,20 @@ export const CommentsPanel = memo(function CommentsPanel({ onCancelNew: () => void onDelete: (id: string) => void onClose: () => void + /** thread to highlight and scroll to (Review > Previous / Next, body anchor click); nonce re-fires + * for the same id; reply additionally focuses that thread's Reply box */ + focus?: { id: string; nonce: number; reply?: boolean } | null }) { const { t } = useI18n() + const paneRef = useRef(null) const [activeId, setActiveId] = useState(null) const [draft, setDraft] = useState('') + const [draftImage, setDraftImage] = useState(null) const draftRef = useRef(null) - const [replyTo, setReplyTo] = useState(null) - const [replyDraft, setReplyDraft] = useState('') + const [replyDrafts, setReplyDrafts] = useState>({}) const [editingId, setEditingId] = useState(null) const [editDraft, setEditDraft] = useState('') + const [menuId, setMenuId] = useState(null) // Word: resolved comments are collapsed/hidden by default const [showResolved, setShowResolved] = useState(false) @@ -91,23 +130,22 @@ export const CommentsPanel = memo(function CommentsPanel({ const openThreads = topLevel.filter((c) => !c.done) const resolvedThreads = topLevel.filter((c) => c.done) const repliesOf = (id: string) => comments.filter((c) => c.parentId === id) - const submitReply = () => { - const text = replyDraft.trim() - if (!text || !replyTo) return - onReply(replyTo, text) - setReplyDraft('') - setReplyTo(null) + + const submitReply = (id: string) => { + const text = (replyDrafts[id] ?? '').trim() + if (!text) return + onReply(id, text) + setReplyDrafts((d) => ({ ...d, [id]: '' })) } const startEdit = (c: CommentInfo) => { - // the pencil toggles: a second click cancels instead of resetting the draft + // Edit toggles: a second pick cancels instead of resetting the draft if (editingId === c.id) { setEditingId(null) return } setEditingId(c.id) setEditDraft(c.text) - setReplyTo(null) } const submitEdit = () => { @@ -135,200 +173,223 @@ export const CommentsPanel = memo(function CommentsPanel({ if (composing) draftRef.current?.focus() }, [composing]) + useEffect(() => { + if (!menuId) return + const close = (e: Event) => { + if (!(e.target instanceof Node) || !paneRef.current?.contains(e.target)) setMenuId(null) + else if (!(e.target as Element).closest('.comment-menu, .comment-more')) setMenuId(null) + } + const onKey = (e: globalThis.KeyboardEvent) => { + if (e.key === 'Escape') setMenuId(null) + } + document.addEventListener('pointerdown', close, true) + document.addEventListener('keydown', onKey, true) + return () => { + document.removeEventListener('pointerdown', close, true) + document.removeEventListener('keydown', onKey, true) + } + }, [menuId]) + + useEffect(() => { + if (!focus) return + setActiveId(focus.id) + if (comments.some((c) => c.id === focus.id && c.done)) setShowResolved(true) + requestAnimationFrame(() => { + const thread = paneRef.current?.querySelector( + `[data-comment-id="${CSS.escape(focus.id)}"]`, + ) + thread?.scrollIntoView({ block: 'nearest' }) + if (focus.reply) thread?.querySelector('.comment-reply-input')?.focus() + }) + // comments only matter at fire time; re-running on every edit would re-scroll + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [focus]) + const submit = () => { const text = draft.trim() if (!text) return - onSubmitNew(text) + onSubmitNew(text, draftImage ?? undefined) setDraft('') + setDraftImage(null) } - const renderThread = (c: CommentInfo) => { - const anchor = anchorTexts.get(c.id) ?? '' - const replies = repliesOf(c.id) - const head = ( -
    - {(c.initials || c.author || '?').slice(0, 2)} - {c.author || t('appUnknownAuthor')} - {formatDate(c.date)} - {c.done && {t('appResolved')}} - { - e.stopPropagation() - startEdit(c) - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.stopPropagation() - startEdit(c) - } - }} - > - - - 0 ? t('appDeleteCommentWithReplies') : t('appDeleteComment')} - role="button" - aria-label={t('appDeleteComment')} - tabIndex={0} - onClick={(e) => { - e.stopPropagation() - cancelEditIn([c.id, ...replies.map((r) => r.id)]) - onDelete(c.id) - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.stopPropagation() - cancelEditIn([c.id, ...replies.map((r) => r.id)]) - onDelete(c.id) - } - }} - > - - + /** a picked or pasted picture joins the draft; failures are silent (a comment needs no picture) */ + const stageImage = (file: File | null | undefined) => { + if (!file) return + void commentImageFromFile(file).then((image) => { + if (image) setDraftImage(image) + }) + } + + const editBox = ( +
    +