From 6e2fc17aad9a85a1c6a210b87d6fb8cf92488319 Mon Sep 17 00:00:00 2001 From: alfirus Date: Fri, 7 Aug 2026 11:23:10 +0800 Subject: [PATCH 01/22] feat(shell): dark/light/system theme + performance optimizations (#41) * feat(shell): add light/dark/system theme support Add a theme toggle to the account menu with three options: - Light: explicit light theme - Dark: explicit dark theme - System: follows OS prefers-color-scheme Changes: - Add UiTheme type and getTheme/setTheme IPC to home-api - Add theme persistence in app-settings.json - Add theme IPC handlers in main process and preload bridge - Add Theme row + flyout in AccountEntry (same UX as Language) - Apply data-theme attribute before first paint to avoid flash - Add dark mode CSS variables for all shell surfaces - Add dark mode overrides for 40+ hardcoded color selectors - Add dark mode treatment for onboarding modal - Add theme i18n strings in all 19 languages The theme toggle lives in the account popup menu between Language and Update Channel, using the same flyout pattern. * perf(shell): cache settings reads + consolidate CSS dark mode + fix button hover Performance improvements: #7: Cache settings reads - Add cachedUpdateChannel/cachedTheme module-level variables - currentUpdateChannel() and currentTheme() now cache like currentLang() - set handlers update the cache on write - Eliminates disk reads on every IPC call #11: Consolidate CSS dark mode selectors - Convert 20+ hardcoded colors to CSS custom properties - Remove ~300 lines of duplicated element-level dark mode overrides - All element rules now use variables, auto-switch via :root overrides - Single source of truth for dark mode palette #17: Replace filter+transform on button hover - Replace filter: brightness() with opacity transitions - Add specific hover states for btn-secondary and btn-danger - Avoids GPU compositing overhead from filter property --------- Co-authored-by: alfirus --- apps/shell/src/main/index.ts | 28 ++- apps/shell/src/preload/index.ts | 9 + apps/shell/src/renderer/src/Home.tsx | 131 +++++++++++++ apps/shell/src/renderer/src/home.css | 213 ++++++++++++++++----- apps/shell/src/renderer/src/main.tsx | 11 +- apps/shell/src/renderer/src/onboarding.css | 48 +++++ apps/shell/src/renderer/src/strings.ts | 76 ++++++++ apps/shell/src/renderer/src/tabbar.css | 49 +++++ apps/shell/src/shared/home-api.ts | 9 + 9 files changed, 517 insertions(+), 57 deletions(-) diff --git a/apps/shell/src/main/index.ts b/apps/shell/src/main/index.ts index 3f753d2..865cd72 100644 --- a/apps/shell/src/main/index.ts +++ b/apps/shell/src/main/index.ts @@ -125,7 +125,7 @@ import { requestPdfSaveAs, setPdfSaveAsInFlight, } from '../../../pdf/src/main/pdf-main' -import type { AccountLoginEvent, RecentEntry, RecentPage, RenameResult } from '../shared/home-api' +import type { AccountLoginEvent, RecentEntry, RecentPage, RenameResult, UiTheme } from '../shared/home-api' import { HOME_CHANNELS } from '../shared/home-api' import type { TabKind } from '../shared/tabs-api' import { TABS_CHANNELS } from '../shared/tabs-api' @@ -234,9 +234,22 @@ function persistLang(lang: Lang): void { writeAppSetting(APP_SETTINGS_PATH(), 'language', lang) } +let cachedUpdateChannel: UpdateChannel | null = null + function currentUpdateChannel(): UpdateChannel { + if (cachedUpdateChannel) return cachedUpdateChannel const saved = readAppSettings(APP_SETTINGS_PATH()).updateChannel - return isUpdateChannel(saved) ? saved : 'stable' + cachedUpdateChannel = isUpdateChannel(saved) ? saved : 'stable' + return cachedUpdateChannel +} + +let cachedTheme: UiTheme | null = null + +function currentTheme(): UiTheme { + if (cachedTheme) return cachedTheme + const saved = readAppSettings(APP_SETTINGS_PATH()).theme + cachedTheme = saved === 'light' || saved === 'dark' ? saved : 'system' + return cachedTheme } // ---- first-run onboarding ---- @@ -1725,6 +1738,7 @@ function registerHomeIpc(): void { ipcMain.handle(HOME_CHANNELS.setUpdateChannel, (_event, channel: unknown) => { if (!isUpdateChannel(channel) || channel === currentUpdateChannel()) return + cachedUpdateChannel = channel writeAppSetting(APP_SETTINGS_PATH(), 'updateChannel', channel) applyUpdateChannel(channel) }) @@ -1738,6 +1752,16 @@ function registerHomeIpc(): void { writeAppSetting(APP_SETTINGS_PATH(), 'onboardingSeen', true) }) + ipcMain.handle(HOME_CHANNELS.getTheme, (): UiTheme => currentTheme()) + + ipcMain.handle(HOME_CHANNELS.setTheme, (_event, theme: unknown) => { + if (theme !== 'light' && theme !== 'dark' && theme !== 'system') return + if (theme === currentTheme()) return + cachedTheme = theme + writeAppSetting(APP_SETTINGS_PATH(), 'theme', theme) + for (const wc of webContents.getAllWebContents()) wc.send('app:theme-changed', theme) + }) + ipcMain.handle(HOME_CHANNELS.openGenTeam, () => { shell.openExternal(GENTEAM_URL).catch(() => { // no browser handler available; nothing actionable for the user here diff --git a/apps/shell/src/preload/index.ts b/apps/shell/src/preload/index.ts index 626ee50..5e952ca 100644 --- a/apps/shell/src/preload/index.ts +++ b/apps/shell/src/preload/index.ts @@ -12,6 +12,7 @@ import type { ProjectSummaryEntry, TimelineEntryItem, UiLanguage, + UiTheme, } from '../shared/home-api' import { HOME_CHANNELS, PROJECT_CHANNELS } from '../shared/home-api' import type { TabsApi, TabSummary } from '../shared/tabs-api' @@ -155,6 +156,14 @@ const homeApi: HomeApi = { async setOnboardingSeen() { await ipcRenderer.invoke(HOME_CHANNELS.setOnboardingSeen) }, + async getTheme() { + const result: unknown = await ipcRenderer.invoke(HOME_CHANNELS.getTheme) + return result === 'dark' || result === 'light' ? result : 'system' + }, + async setTheme(theme) { + if (theme !== 'light' && theme !== 'dark' && theme !== 'system') throw new Error('Invalid theme.') + await ipcRenderer.invoke(HOME_CHANNELS.setTheme, theme) + }, async openGenTeam() { await ipcRenderer.invoke(HOME_CHANNELS.openGenTeam) }, diff --git a/apps/shell/src/renderer/src/Home.tsx b/apps/shell/src/renderer/src/Home.tsx index 7f2c3d6..f673d29 100644 --- a/apps/shell/src/renderer/src/Home.tsx +++ b/apps/shell/src/renderer/src/Home.tsx @@ -425,6 +425,14 @@ const CHANNEL_OPTIONS = [ { value: 'beta', labelKey: 'channelBeta' }, ] as const +const THEME_OPTIONS = [ + { value: 'light' as const, labelKey: 'themeLight' as const, icon: 'sun' }, + { value: 'dark' as const, labelKey: 'themeDark' as const, icon: 'moon' }, + { value: 'system' as const, labelKey: 'themeSystem' as const, icon: 'system' }, +] as const + +type ThemeValue = typeof THEME_OPTIONS[number]['value'] + function AccountEntry({ onStatusChange, }: { @@ -459,6 +467,11 @@ function AccountEntry({ const [chanFly, setChanFly] = useState<{ left: number; bottom: number } | null>(null) const chanRowRef = useRef(null) const chanCloseTimer = useRef(null) + // theme flyout: same pattern as language and channel flyouts + const [theme, setThemeState] = useState('system') + const [themeFly, setThemeFly] = useState<{ left: number; bottom: number } | null>(null) + const themeRowRef = useRef(null) + const themeCloseTimer = useRef(null) const [loggingOut, setLoggingOut] = useState(false) const [appVersion, setAppVersion] = useState('') @@ -471,6 +484,9 @@ function AccountEntry({ void window.aiOffice.getAppVersion?.().then((v) => { if (alive && v) setAppVersion(v) }) + void window.aiOffice.getTheme?.().then((th) => { + if (alive) setThemeState(th) + }) return () => { alive = false } @@ -552,6 +568,7 @@ function AccountEntry({ setMenuOpen(false) setLangFly(null) setChanFly(null) + setThemeFly(null) } const cancelLangFlyClose = () => { @@ -624,6 +641,38 @@ function AccountEntry({ } }, [chanFly]) + const cancelThemeFlyClose = () => { + if (themeCloseTimer.current !== null) { + window.clearTimeout(themeCloseTimer.current) + themeCloseTimer.current = null + } + } + + const openThemeFly = () => { + cancelThemeFlyClose() + const rect = themeRowRef.current?.getBoundingClientRect() + if (rect) setThemeFly({ left: rect.right - 2, bottom: window.innerHeight - rect.bottom }) + } + + const scheduleThemeFlyClose = () => { + cancelThemeFlyClose() + themeCloseTimer.current = window.setTimeout(() => setThemeFly(null), 200) + } + + useEffect(() => { + if (!themeFly) return + const close = (event: Event) => { + const target = event.target as Element | null + if (target instanceof Element && target.closest('.lang-flyout')) return + setThemeFly(null) + } + window.addEventListener('scroll', close, true) + return () => { + window.removeEventListener('scroll', close, true) + cancelThemeFlyClose() + } + }, [themeFly]) + const startLogin = () => { // clicking again while waiting = relaunch the login (main kills the stale CLI, so the new device code is the live one) setLoginError(null) @@ -658,6 +707,7 @@ function AccountEntry({ }) setLangFly(null) setChanFly(null) + setThemeFly(null) } return ( @@ -774,6 +824,87 @@ function AccountEntry({ )} +
+ + {themeFly && ( +
+ {THEME_OPTIONS.map((opt) => ( + + ))} +
+ )} +
*/ +[data-theme="dark"] { + --accent: #4a9eff; + --accent-hover: #3a8eef; + --surface: #1e1e1e; + --surface-subtle: #2a2a2a; + --border: #3a3a3a; + --text: #e4e4e4; + --text-muted: #8a8a8a; + --text-secondary: #a0a0a0; + --color-border-default: #3a3a3a; + --color-bg-overlay: rgb(0 0 0 / 60%); + --shadow-modal-strong: 0 20px 25px rgb(0 0 0 / 30%); + + /* Extended palette dark overrides */ + --bg-hover: #333; + --bg-hover-subtle: #333; + --bg-hover-strong: #444; + --bg-hover-accent: #444; + --bg-content: #1a1a1a; + --text-primary: #e4e4e4; + --text-tertiary: #a0a0a0; + --border-subtle: #3a3a3a; + --border-strong: #555; + --border-hover: #555; + --icon-muted: #666; + --avatar-bg: #333; + --badge-bg: #2a3a4a; + --checkbox-bg: #333; + --danger: #d13438; + --danger-bg: #3a2020; + --danger-border: #5a3030; + --timeline-user-bg: #2a2a3a; + --timeline-user-text: #b0b0c0; + + color-scheme: dark; +} + +/* System dark: no data-theme or data-theme="system" — follow OS preference */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]):not([data-theme="dark"]) { + --accent: #4a9eff; + --accent-hover: #3a8eef; + --surface: #1e1e1e; + --surface-subtle: #2a2a2a; + --border: #3a3a3a; + --text: #e4e4e4; + --text-muted: #8a8a8a; + --text-secondary: #a0a0a0; + --color-border-default: #3a3a3a; + --color-bg-overlay: rgb(0 0 0 / 60%); + --shadow-modal-strong: 0 20px 25px rgb(0 0 0 / 30%); + /* Extended palette dark overrides */ + --bg-hover: #333; + --bg-hover-subtle: #333; + --bg-hover-strong: #444; + --bg-hover-accent: #444; + --bg-content: #1a1a1a; + --text-primary: #e4e4e4; + --text-tertiary: #a0a0a0; + --border-subtle: #3a3a3a; + --border-strong: #555; + --border-hover: #555; + --icon-muted: #666; + --avatar-bg: #333; + --badge-bg: #2a3a4a; + --checkbox-bg: #333; + --danger: #d13438; + --danger-bg: #3a2020; + --danger-border: #5a3030; + --timeline-user-bg: #2a2a3a; + --timeline-user-text: #b0b0c0; + color-scheme: dark; + } +} + * { box-sizing: border-box; } @@ -113,17 +213,17 @@ body.vib .home { } .nav-item:hover { - background: #f5f5f5; + background: var(--bg-hover); } .nav-item.active { - background: #f5f5f5; + background: var(--bg-hover); font-weight: 600; } .nav-item svg { flex-shrink: 0; - color: #232425; + color: var(--text-primary); } .nav-label { @@ -157,13 +257,13 @@ body.vib .home { font: inherit; font-size: 14px; line-height: 22px; - color: #232425; + color: var(--text-primary); cursor: pointer; text-align: left; } .lang-menu-item:hover { - background: #f5f5f5; + background: var(--bg-hover); } .lang-menu-item.active { @@ -175,7 +275,7 @@ body.vib .home { position: relative; margin-top: auto; padding-top: 12px; - border-top: 1px solid #efefef; + border-top: 1px solid var(--border-subtle); } .account-btn { @@ -204,7 +304,7 @@ body.vib .home { width: 28px; height: 28px; border-radius: 50%; - background: #e8ebef; + background: var(--avatar-bg); color: var(--text-muted); font-size: 14px; font-weight: 700; @@ -280,13 +380,13 @@ body.vib .home { font-size: 13px; line-height: 20px; padding: 6px 8px; - color: #232425; + color: var(--text-primary); cursor: pointer; text-align: left; } .login-hint button:hover { - background: #f5f5f5; + background: var(--bg-hover); } .login-hint .login-hint-open, @@ -327,7 +427,7 @@ body.vib .home { .account-menu-divider { height: 1px; margin: 9px 0; - background: #efefef; + background: var(--border-subtle); } .account-menu-item { @@ -344,13 +444,13 @@ body.vib .home { font: inherit; font-size: 14px; line-height: 22px; - color: #232425; + color: var(--text-primary); cursor: pointer; text-align: left; } .account-menu-item:hover { - background: #f5f5f5; + background: var(--bg-hover); } .account-menu-item.danger { @@ -371,13 +471,13 @@ body.vib .home { padding: 6px 8px; font-size: 14px; line-height: 22px; - color: #232425; + color: var(--text-primary); user-select: text; } .account-menu-version svg { flex-shrink: 0; - color: #232425; + color: var(--text-primary); } .version-row-label { @@ -408,7 +508,7 @@ body.vib .home { .lang-row svg { flex-shrink: 0; - color: #232425; + color: var(--text-primary); } .lang-row-label { @@ -460,7 +560,7 @@ body.vib .home { min-width: 0; padding: 28px 36px 40px; overflow-y: auto; - background: #fafafa; + background: var(--bg-content); } .section-head { @@ -487,13 +587,13 @@ body.vib .home { font-weight: 700; letter-spacing: -0.01em; line-height: 1.3; - color: #232425; + color: var(--text-primary); } .hero-ask { display: block; margin-top: 0; - color: #232425; + color: var(--text-primary); font-weight: 500; } @@ -526,7 +626,7 @@ body.vib .home { } .quick-card:hover { - border-color: #d3d7dd; + border-color: var(--border-hover); box-shadow: 0 3px 12px rgb(0 0 0 / 8%); } @@ -584,7 +684,7 @@ body.vib .home { .ai-chip { padding: 1px 6px; border-radius: 4px; - background: #eaf0fd; + background: var(--badge-bg); color: var(--accent); font-size: 12px; font-weight: 700; @@ -634,7 +734,7 @@ body.vib .home { gap: 2px; padding: 2px; border-radius: 10px; - background: #ececec; + background: var(--checkbox-bg); } .filter-pill { @@ -713,12 +813,12 @@ body.vib .home { } .selection-action.danger { - color: #d13438; - border-color: #f0c8c9; + color: var(--danger); + border-color: var(--danger-border); } .selection-action.danger:hover { - background: #fdf3f3; + background: var(--danger-bg); } /* ---- file table ---- */ @@ -742,11 +842,11 @@ body.vib .home { .recent-columns { padding-top: 11px; padding-bottom: 11px; - border-bottom: 1px solid #efefef; + border-bottom: 1px solid var(--border-subtle); font-size: 12px; font-weight: 600; letter-spacing: 0.06em; - color: #909499; + color: var(--text-tertiary); } /* header label starts at the icon column so it lines up with the file badges */ @@ -765,7 +865,7 @@ body.vib .home { } .recent-list li + li { - border-top: 1px solid #efefef; + border-top: 1px solid var(--border-subtle); } .recent-item { @@ -786,7 +886,7 @@ body.vib .home { /* checked rows keep the same gray as the sidebar's active item */ .recent-row:has(.row-check:checked) .recent-item { - background: #f5f5f5; + background: var(--bg-hover); } .recent-item:focus-visible { @@ -804,7 +904,7 @@ body.vib .home { height: 14px; margin: 0; appearance: none; - border: 1px solid #d9d9d9; + border: 1px solid var(--border-strong); border-radius: 4px; background: var(--surface); cursor: pointer; @@ -845,12 +945,12 @@ body.vib .home { border: none; border-radius: 6px; background: none; - color: #b9bfc7; + color: var(--icon-muted); cursor: pointer; } .star-btn:hover { - background: #f0f2f5; + background: var(--bg-hover-subtle); } .recent-item:hover .star-btn, @@ -875,7 +975,7 @@ body.vib .home { .recent-path { font-size: 12px; - color: #909499; + color: var(--text-tertiary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -883,13 +983,13 @@ body.vib .home { .recent-time { font-size: 12px; - color: #909499; + color: var(--text-tertiary); white-space: nowrap; } .recent-size { font-size: 12px; - color: #909499; + color: var(--text-tertiary); white-space: nowrap; text-align: right; font-variant-numeric: tabular-nums; @@ -917,7 +1017,7 @@ body.vib .home { .more-btn:hover, .more-btn[aria-expanded='true'] { - background: #e8eaee; + background: var(--bg-hover-strong); color: var(--text); } @@ -961,17 +1061,17 @@ body.vib .home { } .row-menu button.danger { - color: #d13438; + color: var(--danger); } .row-menu button.danger:hover { - background: #fdf3f3; + background: var(--danger-bg); } .row-menu-divider { height: 1px; margin: 5px 6px; - background: #efefef; + background: var(--border-subtle); } /* ---- buttons + modal ---- */ @@ -991,11 +1091,11 @@ body.vib .home { /* design-system interaction states */ .btn:hover:not(:disabled) { - filter: brightness(0.92); + opacity: 0.9; } .btn:active:not(:disabled) { - filter: brightness(0.88); + opacity: 0.8; transform: scale(0.98); } @@ -1010,12 +1110,21 @@ body.vib .home { color: var(--text); } +.btn-secondary:hover:not(:disabled) { + background: var(--bg-hover); + border-color: var(--border-hover); +} + .btn-danger { background: var(--color-error); border: none; color: #fff; } +.btn-danger:hover:not(:disabled) { + background: #dc2626; +} + .modal-overlay { position: fixed; inset: 0; @@ -1080,7 +1189,7 @@ body.vib .home { .sidebar-divider { height: 1px; margin: 12px 0; - background: #efefef; + background: var(--border-subtle); } .proj-panel { @@ -1163,7 +1272,7 @@ body.vib .home { } .proj-item.active { - background: #f5f5f5; + background: var(--bg-hover); } .proj-item-main { @@ -1184,7 +1293,7 @@ body.vib .home { } .proj-item:hover .proj-item-main { - background: #f5f5f5; + background: var(--bg-hover); } .proj-item.active .proj-item-main { @@ -1193,7 +1302,7 @@ body.vib .home { .proj-item-icon { flex-shrink: 0; - color: #232425; + color: var(--text-primary); display: flex; align-items: center; } @@ -1267,7 +1376,7 @@ body.vib .home { .proj-more-btn:hover, .proj-more-btn[aria-expanded='true'] { - background: #e0e4ea; + background: var(--bg-hover-accent); color: var(--text); } @@ -1304,11 +1413,11 @@ body.vib .home { } .proj-menu button.danger { - color: #d13438; + color: var(--danger); } .proj-menu button.danger:hover { - background: #fdf3f3; + background: var(--danger-bg); } /* "move to project" submenu on a file row */ @@ -1404,7 +1513,7 @@ body.vib .home { text-align: center; margin: 0; padding-bottom: 60px; - color: #909499; + color: var(--text-tertiary); line-height: 1.7 !important; } @@ -1485,7 +1594,7 @@ body.vib .home { } .timeline-list .timeline-item + .timeline-item { - border-top: 1px solid #efefef; + border-top: 1px solid var(--border-subtle); } .timeline-item:hover { @@ -1506,12 +1615,12 @@ body.vib .home { } .timeline-role.user { - background: #eef1f5; - color: #5a6170; + background: var(--timeline-user-bg); + color: var(--timeline-user-text); } .timeline-role.assistant { - background: #eaf0fd; + background: var(--badge-bg); color: var(--accent); } diff --git a/apps/shell/src/renderer/src/main.tsx b/apps/shell/src/renderer/src/main.tsx index d39cb5f..8e3add7 100644 --- a/apps/shell/src/renderer/src/main.tsx +++ b/apps/shell/src/renderer/src/main.tsx @@ -10,14 +10,19 @@ import './tabbar.css' // editor views' translucent regions (e.g. slides thumbnail pane) show it if (navigator.platform.toLowerCase().includes('mac')) document.body.classList.add('vib') -// resolve the persisted language and the first-run flag before first paint so -// the UI never flashes (home showing briefly before the onboarding overlay) +// resolve the persisted language, first-run flag, and theme before first paint +// so the UI never flashes (home showing briefly before the onboarding overlay) void Promise.all([ window.aiOffice.getLanguage(), // if the flag is unreadable, skip onboarding rather than block the home screen window.aiOffice.onboardingSeen().catch(() => true), -]).then(([lang, onboardingSeen]) => { + window.aiOffice.getTheme().catch(() => 'system' as const), +]).then(([lang, onboardingSeen, theme]) => { document.documentElement.lang = htmlLang(lang) + // apply theme attribute before first paint to avoid flash + if (theme !== 'system') { + document.documentElement.setAttribute('data-theme', theme) + } createRoot(document.getElementById('root')!).render( diff --git a/apps/shell/src/renderer/src/onboarding.css b/apps/shell/src/renderer/src/onboarding.css index 2e5e981..4c67180 100644 --- a/apps/shell/src/renderer/src/onboarding.css +++ b/apps/shell/src/renderer/src/onboarding.css @@ -320,3 +320,51 @@ outline: 2px solid var(--onb-brand); outline-offset: 2px; } + +/* ── Dark mode onboarding overrides ──────────────────────── */ + +[data-theme="dark"] .onb-overlay { + --onb-btn-primary: #e4e4e4; + --onb-btn-primary-hover: #ccc; + --onb-text-primary: #e4e4e4; + --onb-text-secondary: #a0a0a0; + --onb-text-tertiary: #777; + --onb-bg-subtle: #333; + --onb-bg-panel: #1a2a3a; + --onb-border-default: #444; + --onb-border-strong: #555; +} + +[data-theme="dark"] .onb-card { + background: #2a2a2a; + box-shadow: + 0 24px 64px rgba(0, 0, 0, 0.5), + 0 2px 8px rgba(0, 0, 0, 0.3); +} + +[data-theme="dark"] .onb-join { + background: #2a2a2a; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]):not([data-theme="dark"]) .onb-overlay { + --onb-btn-primary: #e4e4e4; + --onb-btn-primary-hover: #ccc; + --onb-text-primary: #e4e4e4; + --onb-text-secondary: #a0a0a0; + --onb-text-tertiary: #777; + --onb-bg-subtle: #333; + --onb-bg-panel: #1a2a3a; + --onb-border-default: #444; + --onb-border-strong: #555; + } + :root:not([data-theme="light"]):not([data-theme="dark"]) .onb-card { + background: #2a2a2a; + box-shadow: + 0 24px 64px rgba(0, 0, 0, 0.5), + 0 2px 8px rgba(0, 0, 0, 0.3); + } + :root:not([data-theme="light"]):not([data-theme="dark"]) .onb-join { + background: #2a2a2a; + } +} diff --git a/apps/shell/src/renderer/src/strings.ts b/apps/shell/src/renderer/src/strings.ts index 4589b0c..ef9ce9a 100644 --- a/apps/shell/src/renderer/src/strings.ts +++ b/apps/shell/src/renderer/src/strings.ts @@ -121,6 +121,10 @@ export const strings = { updateChannel: '更新通道', channelStable: '稳定版', channelBeta: 'Beta 版', + theme: '主题', + themeLight: '浅色', + themeDark: '深色', + themeSystem: '跟随系统', // Dates today: '今天', yesterday: '昨天', @@ -263,6 +267,10 @@ export const strings = { updateChannel: 'Update Channel', channelStable: 'Stable', channelBeta: 'Beta', + theme: 'Theme', + themeLight: 'Light', + themeDark: 'Dark', + themeSystem: 'System', today: 'Today', yesterday: 'Yesterday', daysAgo: '{n}d ago', @@ -419,6 +427,10 @@ export const strings = { updateChannel: '更新チャネル', channelStable: '安定版', channelBeta: 'ベータ版', + theme: 'テーマ', + themeLight: 'ライト', + themeDark: 'ダーク', + themeSystem: 'システム', // Dates today: '今日', yesterday: '昨日', @@ -572,6 +584,10 @@ export const strings = { updateChannel: '업데이트 채널', channelStable: '안정 버전', channelBeta: '베타 버전', + theme: '테마', + themeLight: '라이트', + themeDark: '다크', + themeSystem: '시스템', // Dates today: '오늘', yesterday: '어제', @@ -730,6 +746,10 @@ export const strings = { updateChannel: 'Canal de mise à jour', channelStable: 'Stable', channelBeta: 'Bêta', + theme: 'Thème', + themeLight: 'Clair', + themeDark: 'Sombre', + themeSystem: 'Système', // Dates today: "Aujourd'hui", yesterday: 'Hier', @@ -890,6 +910,10 @@ export const strings = { updateChannel: 'Update-Kanal', channelStable: 'Stabil', channelBeta: 'Beta', + theme: 'Thema', + themeLight: 'Hell', + themeDark: 'Dunkel', + themeSystem: 'System', // Dates today: 'Heute', yesterday: 'Gestern', @@ -1049,6 +1073,10 @@ export const strings = { updateChannel: 'Canal de actualización', channelStable: 'Estable', channelBeta: 'Beta', + theme: 'Tema', + themeLight: 'Claro', + themeDark: 'Oscuro', + themeSystem: 'Sistema', // Dates today: 'Hoy', yesterday: 'Ayer', @@ -1202,6 +1230,10 @@ export const strings = { updateChannel: 'ช่องทางอัปเดต', channelStable: 'เสถียร', channelBeta: 'เบต้า', + theme: 'ธีม', + themeLight: 'สว่าง', + themeDark: 'มืด', + themeSystem: 'ตามระบบ', // Dates today: 'วันนี้', yesterday: 'เมื่อวาน', @@ -1356,6 +1388,10 @@ export const strings = { updateChannel: 'Saluran Pembaruan', channelStable: 'Stabil', channelBeta: 'Beta', + theme: 'Tema', + themeLight: 'Terang', + themeDark: 'Gelap', + themeSystem: 'Sistem', // Dates today: 'Hari ini', yesterday: 'Kemarin', @@ -1511,6 +1547,10 @@ export const strings = { updateChannel: 'Канал обновлений', channelStable: 'Стабильный', channelBeta: 'Бета', + theme: 'Тема', + themeLight: 'Светлая', + themeDark: 'Тёмная', + themeSystem: 'Системная', // Dates today: 'Сегодня', yesterday: 'Вчера', @@ -1664,6 +1704,10 @@ export const strings = { updateChannel: 'قناة التحديث', channelStable: 'مستقر', channelBeta: 'تجريبي', + theme: 'المظهر', + themeLight: 'فاتح', + themeDark: 'داكن', + themeSystem: 'النظام', // Dates today: 'اليوم', yesterday: 'أمس', @@ -1812,6 +1856,10 @@ export const strings = { updateChannel: 'Canal de atualização', channelStable: 'Estável', channelBeta: 'Beta', + theme: 'Tema', + themeLight: 'Claro', + themeDark: 'Escuro', + themeSystem: 'Sistema', today: 'Hoje', yesterday: 'Ontem', daysAgo: 'há {n} dias', @@ -1958,6 +2006,10 @@ export const strings = { updateChannel: 'Canale di aggiornamento', channelStable: 'Stabile', channelBeta: 'Beta', + theme: 'Tema', + themeLight: 'Chiaro', + themeDark: 'Scuro', + themeSystem: 'Sistema', today: 'Oggi', yesterday: 'Ieri', daysAgo: '{n} giorni fa', @@ -2103,6 +2155,10 @@ export const strings = { updateChannel: 'Kanał aktualizacji', channelStable: 'Stabilny', channelBeta: 'Beta', + theme: 'Motyw', + themeLight: 'Jasny', + themeDark: 'Ciemny', + themeSystem: 'Systemowy', today: 'Dzisiaj', yesterday: 'Wczoraj', daysAgo: '{n} dni temu', @@ -2249,6 +2305,10 @@ export const strings = { updateChannel: 'Updatekanaal', channelStable: 'Stabiel', channelBeta: 'Bèta', + theme: 'Thema', + themeLight: 'Licht', + themeDark: 'Donker', + themeSystem: 'Systeem', today: 'Vandaag', yesterday: 'Gisteren', daysAgo: '{n} dagen geleden', @@ -2394,6 +2454,10 @@ export const strings = { updateChannel: 'Saluran Kemas Kini', channelStable: 'Stabil', channelBeta: 'Beta', + theme: 'Tema', + themeLight: 'Cerah', + themeDark: 'Gelap', + themeSystem: 'Sistem', today: 'Hari ini', yesterday: 'Semalam', daysAgo: '{n} hari lalu', @@ -2536,6 +2600,10 @@ export const strings = { updateChannel: 'ערוץ עדכונים', channelStable: 'יציב', channelBeta: 'בטא', + theme: 'עיצוב', + themeLight: 'בהיר', + themeDark: 'כהה', + themeSystem: 'מערכת', today: 'היום', yesterday: 'אתמול', daysAgo: 'לפני {n} ימים', @@ -2681,6 +2749,10 @@ export const strings = { updateChannel: 'अपडेट चैनल', channelStable: 'स्थिर', channelBeta: 'बीटा', + theme: 'विषय', + themeLight: 'हल्का', + themeDark: 'गहरा', + themeSystem: 'सिस्टम', today: 'आज', yesterday: 'कल', daysAgo: '{n} दिन पहले', @@ -2821,6 +2893,10 @@ export const strings = { updateChannel: '更新通道', channelStable: '穩定版', channelBeta: 'Beta 版', + theme: '主題', + themeLight: '淺色', + themeDark: '深色', + themeSystem: '跟隨系統', today: '今天', yesterday: '昨天', daysAgo: '{n} 天前', diff --git a/apps/shell/src/renderer/src/tabbar.css b/apps/shell/src/renderer/src/tabbar.css index 057b956..545545f 100644 --- a/apps/shell/src/renderer/src/tabbar.css +++ b/apps/shell/src/renderer/src/tabbar.css @@ -274,3 +274,52 @@ .app-frame-content { padding-top: 40px; } + +/* ── Dark mode tab bar overrides ─────────────────────────── */ + +[data-theme="dark"] .tab-bar { + --tabstrip-bg: #2a2a2a; + --tabstrip-text: #b0b0b0; + --tabstrip-text-active: #e4e4e4; + --tab-separator: rgba(255, 255, 255, 0.15); +} + +[data-theme="dark"] .tab-item.active .tab-plate { + background: var(--surface); +} + +[data-theme="dark"] .tab-item:not(.active):hover .tab-plate { + background: rgba(255, 255, 255, 0.08); +} + +[data-theme="dark"] .tab-close:hover { + background: rgba(255, 255, 255, 0.12); +} + +[data-theme="dark"] .tab-new-btn:hover, +[data-theme="dark"] .tab-overflow-btn:hover { + background: rgba(255, 255, 255, 0.1); +} + +/* System dark: same overrides when following OS preference */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]):not([data-theme="dark"]) .tab-bar { + --tabstrip-bg: #2a2a2a; + --tabstrip-text: #b0b0b0; + --tabstrip-text-active: #e4e4e4; + --tab-separator: rgba(255, 255, 255, 0.15); + } + :root:not([data-theme="light"]):not([data-theme="dark"]) .tab-item.active .tab-plate { + background: var(--surface); + } + :root:not([data-theme="light"]):not([data-theme="dark"]) .tab-item:not(.active):hover .tab-plate { + background: rgba(255, 255, 255, 0.08); + } + :root:not([data-theme="light"]):not([data-theme="dark"]) .tab-close:hover { + background: rgba(255, 255, 255, 0.12); + } + :root:not([data-theme="light"]):not([data-theme="dark"]) .tab-new-btn:hover, + :root:not([data-theme="light"]):not([data-theme="dark"]) .tab-overflow-btn:hover { + background: rgba(255, 255, 255, 0.1); + } +} diff --git a/apps/shell/src/shared/home-api.ts b/apps/shell/src/shared/home-api.ts index 16c3d8a..9fc110e 100644 --- a/apps/shell/src/shared/home-api.ts +++ b/apps/shell/src/shared/home-api.ts @@ -22,6 +22,9 @@ export type UiLanguage = | 'hi' | 'zh-TW' +/** UI theme preference */ +export type UiTheme = 'light' | 'dark' | 'system' + /** a recent file entry shown on the home screen; type derives from the extension */ export interface RecentEntry { path: string @@ -109,6 +112,10 @@ export interface HomeApi { onboardingSeen(): Promise /** mark the first-run onboarding as done so it never shows again */ setOnboardingSeen(): Promise + /** current UI theme preference (persisted in userData/app-settings.json) */ + getTheme(): Promise + /** switch + persist the UI theme; broadcasts 'app:theme-changed' to all web contents */ + setTheme(theme: UiTheme): Promise /** open the GenTeam community page in the default browser */ openGenTeam(): Promise /** locally stored full cloud project list (instant; null when no store or logged out) */ @@ -232,6 +239,8 @@ export const HOME_CHANNELS = { getAppVersion: 'home:get-app-version', onboardingSeen: 'home:onboarding-seen', setOnboardingSeen: 'home:set-onboarding-seen', + getTheme: 'home:get-theme', + setTheme: 'home:set-theme', openGenTeam: 'home:open-genteam', cloudProjects: 'home:cloud-projects', cloudProjectsCached: 'home:cloud-projects-cached', From b3cab3944190222c00fe85ee0cf727d078264cb5 Mon Sep 17 00:00:00 2001 From: merrick-2002 Date: Fri, 7 Aug 2026 14:44:01 +0800 Subject: [PATCH 02/22] Sync snapshot (2026-08-07) (#48) - New AI Markdown app - the fifth suite app, with local image assets and ribbon AI entry points - Shell: dark/light/system theme (imports public PR #41) with lint fix and dark-mode onboarding polish - Docs: Word rendering fidelity fixes, 4x faster repagination, Shape Format contextual tab, auto-refreshing TOC page numbers - Slides: built-in standard layouts, anchored zoom and jump-free editing, selectable Draw pen thickness - Sheets: Insert Equation/Checkbox/Timeline, editing polish with undo for manual inserts and localized save errors - Bump pdfjs-dist to 6.2.108 and js-yaml to 4.3.1 Co-authored-by: GenOffice --- .gitignore | 3 + apps/docs/src/renderer/App.tsx | 153 +- apps/docs/src/renderer/components/Ribbon.tsx | 204 +- .../components/ribbon-format-state.ts | 15 + .../src/renderer/components/ribbon-tabs.tsx | 66 +- apps/docs/src/renderer/doc-style-css.ts | 139 +- apps/docs/src/renderer/editor/convert.ts | 41 +- apps/docs/src/renderer/editor/extensions.ts | 58 +- .../src/renderer/editor/pagination-gaps.ts | 31 +- .../src/renderer/editor/protected-render.ts | 17 +- apps/docs/src/renderer/editor/shape-draw.ts | 51 +- apps/docs/src/renderer/editor/shape-svg.ts | 24 +- apps/docs/src/renderer/editor/toc-refresh.ts | 58 + apps/docs/src/renderer/file-actions.ts | 16 +- apps/docs/src/renderer/i18n/strings-ribbon.ts | 152 ++ apps/docs/src/renderer/line-metrics.ts | 81 +- apps/docs/src/renderer/pagination.ts | 64 +- apps/docs/src/renderer/styles.css | 69 +- apps/docs/tests/line-factor-live.test.ts | 4 +- apps/docs/tests/line-metrics.test.ts | 4 +- apps/docs/tests/pagination.test.ts | 24 + apps/docs/tests/shape-draw.test.ts | 18 + apps/docs/tests/shape-gallery.test.ts | 8 +- apps/docs/tests/shape-insert.test.ts | 42 + apps/docs/tests/toc-auto-update.test.ts | 119 + apps/markdown/electron.vite.config.ts | 37 + apps/markdown/package.json | 49 + apps/markdown/src/main/atomic-write.ts | 46 + apps/markdown/src/main/index.ts | 3 + apps/markdown/src/main/markdown-main.ts | 773 +++++++ apps/markdown/src/preload/index.ts | 66 + apps/markdown/src/renderer/App.tsx | 395 ++++ apps/markdown/src/renderer/ai/AiPanel.tsx | 840 +++++++ .../src/renderer/ai/markdown-skill.ts | 46 + apps/markdown/src/renderer/ai/search-skill.ts | 49 + apps/markdown/src/renderer/ai/tools.ts | 302 +++ apps/markdown/src/renderer/ai/transport.ts | 16 + .../src/renderer/assets/send-enter-off.png | Bin 0 -> 4247 bytes .../src/renderer/assets/send-enter-on.png | Bin 0 -> 3742 bytes .../src/renderer/assets/send-stop.png | Bin 0 -> 2875 bytes .../renderer/components/FrontmatterPanel.tsx | 32 + .../src/renderer/components/Ribbon.tsx | 477 ++++ .../src/renderer/components/SlashMenu.tsx | 81 + .../src/renderer/components/TableMenu.tsx | 132 ++ .../src/renderer/components/icons.tsx | 131 ++ .../src/renderer/editor/CodeBlockView.tsx | 76 + .../src/renderer/editor/ToggleView.tsx | 46 + .../src/renderer/editor/aiHighlight.ts | 55 + .../src/renderer/editor/blockDragHandle.ts | 229 ++ .../src/renderer/editor/blockKeymap.ts | 126 ++ apps/markdown/src/renderer/editor/callout.ts | 71 + .../src/renderer/editor/extensions.ts | 58 + .../src/renderer/editor/localImage.ts | 110 + .../src/renderer/editor/slashCommand.ts | 200 ++ apps/markdown/src/renderer/editor/toggle.ts | 81 + apps/markdown/src/renderer/env.d.ts | 13 + .../src/renderer/export/docxExport.ts | 301 +++ .../markdown/src/renderer/export/printHtml.ts | 86 + apps/markdown/src/renderer/i18n/locale.tsx | 64 + apps/markdown/src/renderer/i18n/strings.ts | 1978 ++++++++++++++++ apps/markdown/src/renderer/index.html | 16 + apps/markdown/src/renderer/main.tsx | 15 + .../markdown/src/renderer/markdown/docText.ts | 73 + apps/markdown/src/renderer/styles.css | 2001 +++++++++++++++++ apps/markdown/src/shared/ipc.ts | 131 ++ apps/markdown/tests/ai-tools.test.ts | 151 ++ apps/markdown/tests/doc-text.test.ts | 96 + apps/markdown/tests/docx-export.test.ts | 142 ++ apps/markdown/tests/markdown-nodes.test.ts | 143 ++ apps/markdown/tests/slash-and-image.test.ts | 205 ++ apps/markdown/tsconfig.json | 9 + apps/markdown/vite.renderer.config.ts | 30 + apps/markdown/vitest.config.ts | 9 + apps/pdf/package.json | 2 +- apps/pdf/src/renderer/App.tsx | 5 +- apps/pdf/src/renderer/styles.css | 18 +- apps/sheets/docs/compatibility.md | 3 +- apps/sheets/electron.vite.config.ts | 1 + apps/sheets/package.json | 1 + apps/sheets/src/domain/pivot-grouping.ts | 2 +- apps/sheets/src/domain/pivot-timeline.ts | 66 + apps/sheets/src/gateway/xlsx-cf.ts | 171 +- apps/sheets/src/gateway/xlsx-dv.ts | 65 +- apps/sheets/src/gateway/xlsx-structure.ts | 484 ++-- apps/sheets/src/main/sheets-main.ts | 2 + apps/sheets/src/preload/index.ts | 14 + apps/sheets/src/renderer/App.tsx | 165 +- apps/sheets/src/renderer/EquationDialog.tsx | 167 ++ apps/sheets/src/renderer/ExcelShell.tsx | 53 +- apps/sheets/src/renderer/TimelinePanel.tsx | 156 ++ apps/sheets/src/renderer/app-constants.ts | 14 +- .../sheets/src/renderer/data-tools-actions.ts | 78 +- apps/sheets/src/renderer/edit-journal.ts | 92 +- apps/sheets/src/renderer/formula-closure.ts | 55 +- apps/sheets/src/renderer/i18n/strings-app.ts | 982 +++++++- .../src/renderer/i18n/strings-dialogs.ts | 190 ++ .../src/renderer/page-layout-actions.ts | 21 +- apps/sheets/src/renderer/pivot-actions.ts | 156 +- apps/sheets/src/renderer/ribbon-actions.ts | 74 +- apps/sheets/src/renderer/save-actions.ts | 40 +- apps/sheets/src/renderer/styles.css | 113 +- apps/sheets/src/renderer/univer-sync.ts | 14 + apps/sheets/src/renderer/view-transform.ts | 182 +- apps/sheets/src/renderer/visual-actions.ts | 15 +- apps/sheets/src/shared/desktop-api.ts | 13 + apps/sheets/src/types/docx-engine-math.d.ts | 12 + apps/sheets/tests/edit-journal.test.ts | 23 + apps/sheets/tests/pivot-timeline.test.ts | 65 + .../tests/save-error-localization.test.ts | 35 + apps/sheets/tests/view-transform.test.ts | 161 +- apps/sheets/tests/xlsx-cf.test.ts | 449 +++- apps/sheets/tests/xlsx-dv.test.ts | 136 +- apps/sheets/tests/xlsx-structure.test.ts | 126 ++ apps/sheets/tsconfig.json | 3 +- apps/shell/electron-builder.cjs | 24 +- apps/shell/src/main/assets/menu-md.png | Bin 0 -> 418 bytes apps/shell/src/main/assets/menu-md@2x.png | Bin 0 -> 774 bytes apps/shell/src/main/index.ts | 282 ++- apps/shell/src/main/tab-manager.ts | 49 +- apps/shell/src/preload/index.ts | 7 +- apps/shell/src/renderer/src/Home.tsx | 29 +- apps/shell/src/renderer/src/TabBar.tsx | 11 + .../shell/src/renderer/src/assets/file-md.svg | 5 + apps/shell/src/renderer/src/home.css | 22 +- apps/shell/src/renderer/src/onboarding.css | 17 +- apps/shell/src/renderer/src/strings.ts | 42 +- apps/shell/src/renderer/src/tabbar.css | 24 +- apps/shell/src/shared/home-api.ts | 3 + apps/shell/src/shared/tabs-api.ts | 2 +- apps/slides/src/main/slides-main.ts | 39 +- apps/slides/src/renderer/App.tsx | 277 ++- apps/slides/src/renderer/SlideCanvas.tsx | 339 ++- apps/slides/src/renderer/TextEditOverlay.tsx | 340 ++- apps/slides/src/renderer/ai/AiPanel.tsx | 2 +- .../src/renderer/assets/icon-translate.png | Bin 1413 -> 1410 bytes .../slides/src/renderer/components/Ribbon.tsx | 108 +- .../src/renderer/components/RibbonHomeTab.tsx | 117 +- .../renderer/components/RibbonInsertTab.tsx | 67 +- apps/slides/src/renderer/components/icons.tsx | 23 + .../src/renderer/components/ribbon-shared.tsx | 93 +- .../src/renderer/i18n/strings-ribbon.ts | 152 ++ apps/slides/src/renderer/style-actions.ts | 4 +- apps/slides/src/renderer/styles.css | 51 +- apps/slides/src/renderer/zoom-preview.ts | 5 + apps/slides/src/shared/ipc.ts | 3 + apps/slides/tests/canvas-pixel-ratio.test.ts | 23 +- apps/slides/tests/edit-fidelity.test.ts | 31 + apps/slides/tests/font-size-step.test.ts | 105 + apps/slides/tests/stage-refit-follow.test.ts | 12 + e2e/home.spec.ts | 5 +- e2e/markdown-tab.spec.ts | 167 ++ e2e/sheets-edit-polish.spec.ts | 179 ++ e2e/sheets-insert-tier2.spec.ts | 209 ++ e2e/sheets-move-rows.spec.ts | 106 + package-lock.json | 812 ++++++- package.json | 8 +- packages/docx-engine/package.json | 3 +- packages/docx-engine/src/generate.ts | 67 +- packages/docx-engine/src/index.ts | 2 + packages/docx-engine/src/parse.ts | 287 ++- packages/docx-engine/src/theme.ts | 25 +- packages/docx-engine/src/types.ts | 26 +- packages/docx-engine/tests/line-shape.test.ts | 38 + .../docx-engine/tests/table-style.test.ts | 20 + .../tests/watermark-theme-sources.test.ts | 31 +- packages/file-parse/package.json | 2 +- packages/file-parse/src/pdf.ts | 86 +- packages/pptx-engine/src/builtin-layouts.ts | 238 ++ packages/pptx-engine/src/index.ts | 41 +- packages/pptx-engine/src/layout.ts | 52 +- .../pptx-engine/tests/builtin-layouts.test.ts | 135 ++ packages/pptx-render/src/render-tree.ts | 4 + packages/pptx-render/src/text-layout.ts | 2 + packages/ui/src/AiComposer.tsx | 4 +- 174 files changed, 19392 insertions(+), 1340 deletions(-) create mode 100644 apps/docs/src/renderer/editor/toc-refresh.ts create mode 100644 apps/docs/tests/toc-auto-update.test.ts create mode 100644 apps/markdown/electron.vite.config.ts create mode 100644 apps/markdown/package.json create mode 100644 apps/markdown/src/main/atomic-write.ts create mode 100644 apps/markdown/src/main/index.ts create mode 100644 apps/markdown/src/main/markdown-main.ts create mode 100644 apps/markdown/src/preload/index.ts create mode 100644 apps/markdown/src/renderer/App.tsx create mode 100644 apps/markdown/src/renderer/ai/AiPanel.tsx create mode 100644 apps/markdown/src/renderer/ai/markdown-skill.ts create mode 100644 apps/markdown/src/renderer/ai/search-skill.ts create mode 100644 apps/markdown/src/renderer/ai/tools.ts create mode 100644 apps/markdown/src/renderer/ai/transport.ts create mode 100644 apps/markdown/src/renderer/assets/send-enter-off.png create mode 100644 apps/markdown/src/renderer/assets/send-enter-on.png create mode 100644 apps/markdown/src/renderer/assets/send-stop.png create mode 100644 apps/markdown/src/renderer/components/FrontmatterPanel.tsx create mode 100644 apps/markdown/src/renderer/components/Ribbon.tsx create mode 100644 apps/markdown/src/renderer/components/SlashMenu.tsx create mode 100644 apps/markdown/src/renderer/components/TableMenu.tsx create mode 100644 apps/markdown/src/renderer/components/icons.tsx create mode 100644 apps/markdown/src/renderer/editor/CodeBlockView.tsx create mode 100644 apps/markdown/src/renderer/editor/ToggleView.tsx create mode 100644 apps/markdown/src/renderer/editor/aiHighlight.ts create mode 100644 apps/markdown/src/renderer/editor/blockDragHandle.ts create mode 100644 apps/markdown/src/renderer/editor/blockKeymap.ts create mode 100644 apps/markdown/src/renderer/editor/callout.ts create mode 100644 apps/markdown/src/renderer/editor/extensions.ts create mode 100644 apps/markdown/src/renderer/editor/localImage.ts create mode 100644 apps/markdown/src/renderer/editor/slashCommand.ts create mode 100644 apps/markdown/src/renderer/editor/toggle.ts create mode 100644 apps/markdown/src/renderer/env.d.ts create mode 100644 apps/markdown/src/renderer/export/docxExport.ts create mode 100644 apps/markdown/src/renderer/export/printHtml.ts create mode 100644 apps/markdown/src/renderer/i18n/locale.tsx create mode 100644 apps/markdown/src/renderer/i18n/strings.ts create mode 100644 apps/markdown/src/renderer/index.html create mode 100644 apps/markdown/src/renderer/main.tsx create mode 100644 apps/markdown/src/renderer/markdown/docText.ts create mode 100644 apps/markdown/src/renderer/styles.css create mode 100644 apps/markdown/src/shared/ipc.ts create mode 100644 apps/markdown/tests/ai-tools.test.ts create mode 100644 apps/markdown/tests/doc-text.test.ts create mode 100644 apps/markdown/tests/docx-export.test.ts create mode 100644 apps/markdown/tests/markdown-nodes.test.ts create mode 100644 apps/markdown/tests/slash-and-image.test.ts create mode 100644 apps/markdown/tsconfig.json create mode 100644 apps/markdown/vite.renderer.config.ts create mode 100644 apps/markdown/vitest.config.ts create mode 100644 apps/sheets/src/domain/pivot-timeline.ts create mode 100644 apps/sheets/src/renderer/EquationDialog.tsx create mode 100644 apps/sheets/src/renderer/TimelinePanel.tsx create mode 100644 apps/sheets/src/types/docx-engine-math.d.ts create mode 100644 apps/sheets/tests/pivot-timeline.test.ts create mode 100644 apps/sheets/tests/save-error-localization.test.ts create mode 100644 apps/shell/src/main/assets/menu-md.png create mode 100644 apps/shell/src/main/assets/menu-md@2x.png create mode 100644 apps/shell/src/renderer/src/assets/file-md.svg create mode 100644 apps/slides/src/renderer/zoom-preview.ts create mode 100644 apps/slides/tests/font-size-step.test.ts create mode 100644 e2e/markdown-tab.spec.ts create mode 100644 e2e/sheets-edit-polish.spec.ts create mode 100644 e2e/sheets-insert-tier2.spec.ts create mode 100644 e2e/sheets-move-rows.spec.ts create mode 100644 packages/pptx-engine/src/builtin-layouts.ts create mode 100644 packages/pptx-engine/tests/builtin-layouts.test.ts diff --git a/.gitignore b/.gitignore index 76f080e..26795bd 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ electron-builder.env /*.zip /windows/ +# Playwright artifacts +/test-results/ + .cursor/rules/git-remote.mdc __pycache__/ /notes/ diff --git a/apps/docs/src/renderer/App.tsx b/apps/docs/src/renderer/App.tsx index 47b2bf7..e3d5cc5 100644 --- a/apps/docs/src/renderer/App.tsx +++ b/apps/docs/src/renderer/App.tsx @@ -55,6 +55,7 @@ import { effectiveTopPx, effectiveBottomPx, formatPageNumber, + visiblePageCount, type SectionGeom, type SectionHfHeights, type PageSlice, @@ -101,6 +102,7 @@ import { } from './editor/active-editor' import type { CompareEntry } from './editor/compare' import { collectHeadings } from './editor/headings' +import { applyTocPageDisplays } from './editor/toc-refresh' import { setSelectionAlign } from './editor/direction' import { @@ -113,7 +115,7 @@ import { InkOverlay } from './components/InkOverlay' import { collectRevisions, gotoRevision, type TrackChangesStorage } from './editor/revisions' import { NavPane } from './components/NavPane' import { Ruler } from './components/Ruler' -import { docLineFactor, docThemeCss } from './doc-style-css' +import { docBodyFont, docLineFactor, docThemeCss } from './doc-style-css' import { isDocDirty } from './doc-dirty' import { EMPTY_HF_VARIANTS, @@ -164,6 +166,13 @@ const _IS_MAC = navigator.platform.toLowerCase().includes('mac') const twipsToPx = (twips: number) => (twips / 1440) * 96 +/** tiny stable string hash for decoration keys */ +function hashStr(str: string): number { + let h = 0 + for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) | 0 + return h +} + const EMPTY_BLOCKS: Block[] = [] // O(doc) derivations cached by PM doc reference: caret moves and unrelated @@ -1492,7 +1501,8 @@ export function App() { } return { mBlocks: blocks, slices: s, secs: live } }) - const nums = secs.length > 1 ? pageNumbers(slices, secs) : slices.map((_, i) => i + 1) + // same page-number algorithm as the footer path (w:pgNumType start offsets apply to single-section docs too) + const nums = secs.length > 0 ? pageNumbers(slices, secs) : slices.map((_, i) => i + 1) const byEl = new Map(mBlocks.filter((b) => b.el).map((b) => [b.el as HTMLElement, b.top])) const pages: number[] = [] for (const h of collectHeadings(editor.state.doc)) { @@ -1537,15 +1547,18 @@ export function App() { const scrollRect = scroller.getBoundingClientRect() const origin = pmRect.top + mTopPx * factor const midScreen = Math.min(scrollRect.top + scrollRect.height / 2, pmRect.bottom) - // slices use gapless virtual coordinates; subtract the page-gap height above the midpoint (including mid-paragraph inline gaps) + // slices use gapless virtual coordinates; subtract the page-gap height above the midpoint + // (including mid-paragraph inline gaps and repeated-header clone rows, which carry + // page-repeat-header but not page-gap) let gapAbove = 0 - for (const gap of pm.querySelectorAll('.page-gap')) { + for (const gap of pm.querySelectorAll('.page-gap, .page-repeat-header')) { const r = gap.getBoundingClientRect() if (r.top < midScreen) gapAbove += Math.min(r.height, midScreen - r.top) } const midY = (midScreen - origin - gapAbove) / factor - const current = pageAt(slices, midY) - const total = slices.length + // visible-page numbering so the status bar and F9 NUMPAGES agree with the gap widgets + const current = visiblePageCount(slices, pageAt(slices, midY)) + const total = visiblePageCount(slices) setPageInfo((prev) => prev.current === current && prev.total === total ? prev : { current, total }, ) @@ -1553,12 +1566,17 @@ export function App() { const remeasure = () => { const pm = pmEl() if (!pm) return + const tStart = performance.now() + let tMeasure = 0 + let tSlice = 0 // a columned canvas measures + slices in the single-flow measuring state (fillLineBoxes // also reads the DOM for line sampling, so it must share the state); display-state DOM // reads like gap positioning happen outside the measuring state const measured = measureSingleFlow(pm, () => { + const t0 = performance.now() const origin = pm.getBoundingClientRect().top + mTopPx * factor const { blocks, totalHeight } = measureBlocks(pm, origin, factor) + tMeasure = performance.now() - t0 // multi-section: assign blocks to sections by docxIndex; each section has its own content height / forced breaks. // liveSections: when a section-break block is deleted, that section merges into the next in real time (effective before saving) const secList = sections.length > 0 ? liveSections(sections, blocks) : null @@ -1572,6 +1590,7 @@ export function App() { ) const flowH = withEndnotes?.totalHeight ?? totalHeight const hfHs = secList ? hfHeightsOf(secList) : null + const t1 = performance.now() const s = secList ? sliceWithLineSplit( blocks, @@ -1587,6 +1606,7 @@ export function App() { factor, blockMetaOf, ) + tSlice = performance.now() - t1 return { blocks, secList, hfHs, s } }) const { blocks, secList, hfHs } = measured @@ -1603,9 +1623,36 @@ export function App() { : slices.length, ) } + // Auto-refresh TOC page numbers from the fresh slicing, like Word's field + // update. Gated on dirtyRef — our pagination approximates Word's, so a + // pristine open keeps the file's numbers. History-exempt: a field result + // change is not an edit to undo. + if (editor && dirtyRef.current && slices.length > 0) { + const nums = secList ? pageNumbers(slices, secList) : slices.map((_, n) => n + 1) + const byEl = new Map(blocks.filter((b) => b.el).map((b) => [b.el as HTMLElement, b.top])) + const headings = collectHeadings(editor.state.doc) + // formatted with the owning section's pgNumType, like the header/footer numbers + const displays = headings.map((h) => { + const dom = editor.view.nodeDOM(h.pos) as HTMLElement | null + const top = dom ? byEl.get(dom) : undefined + if (top === undefined) return undefined + const idx = Math.min(Math.max(pageAt(slices, top + 1), 1), nums.length) + const fmt = + secList?.[Math.min(slices[idx - 1].section, secList.length - 1)]?.pageNumberFmt + return formatPageNumber(nums[idx - 1] ?? idx, fmt) + }) + const tr = editor.state.tr + if (applyTocPageDisplays(editor.state.doc, tr, headings, displays)) { + tr.setMeta('addToHistory', false) + editor.view.dispatch(tr) + } + } // M4 always-on pagination in the canvas: render page gaps before page-leading blocks // (print view only; gaps don't count as content — measureBlocks subtracts them, so // slice results are gap-independent and refresh is idempotent) + let tGapsBuild: number | undefined + let tSetGaps: number | undefined + const tGaps0 = performance.now() if (editor) { const gaps: PageGapSpec[] = [] const gapIds = new Set() @@ -1662,6 +1709,7 @@ export function App() { ) const hfSig = (v: HeaderFooter | null | undefined) => v ? `${v.text}·${v.pageNumber ? 1 : 0}·${v.paras?.length ?? 0}` : '' + const visiblePages = visiblePageCount(slices) slices.slice(1).forEach((slice, k) => { // an even/odd section's zero-height blank page shares its start with the following page: draw only one gap band on the canvas if (slice.start === slices[k].start) return @@ -1689,7 +1737,7 @@ export function App() { value: gapFooter.value ?? { text: '' }, images: gapFooter.images, pageNo: pageNoTextOf(k), - pageTotal: slices.length, + pageTotal: visiblePages, }) el.style.top = 'auto' el.style.bottom = `${GAP_BAND + metrics.marginTop + box.footerDist}px` @@ -1703,7 +1751,7 @@ export function App() { value: gapHeader.value ?? { text: '' }, images: gapHeader.images, pageNo: pageNoTextOf(k + 1), - pageTotal: slices.length, + pageTotal: visiblePages, }) el.style.bottom = 'auto' el.style.top = `calc(100% - ${Math.max(0, metrics.marginTop - box.headerDist)}px)` @@ -1716,7 +1764,7 @@ export function App() { hfEls, // key must cover everything baked into the widgets (both pages' // formatted numbers + total count), or stale PAGE/NUMPAGES survive reuse - hfKey: `${pageNoTextOf(k)}·${pageNoTextOf(k + 1)}·${slices.length}·${hfSig(gapFooter.value)}·${hfSig(gapHeader.value)}`, + hfKey: `${pageNoTextOf(k)}·${pageNoTextOf(k + 1)}·${visiblePages}·${hfSig(gapFooter.value)}·${hfSig(gapHeader.value)}`, } : {} // previous page's footnotes: rendered into the top of the gap (page-bottom area), with the gap enlarged by the reserved height. @@ -1764,7 +1812,8 @@ export function App() { const elTop = b.el.getBoundingClientRect().top let matched = false for (const tr of Array.from(b.el.querySelectorAll('tr')).filter( - (r) => !r.closest('.doc-nested-table'), + (r) => + !r.closest('.doc-nested-table') && !r.classList.contains('page-repeat-header'), )) { const trTop = tr.getBoundingClientRect().top const gapsAbove = gapRects.reduce((s, g) => (g.top <= trTop ? s + g.height : s), 0) @@ -1773,9 +1822,48 @@ export function App() { matched = true try { const $pos = editor.view.state.doc.resolve(editor.view.posAtDOM(tr, 0)) + // w:tblHeader repetition: the engine reserved slice.repeatHeader.height + // at the top of this page's column, so cloning the source header rows + // below the gap fills exactly that space. Clones are decorations — + // page-gap-inline keeps them out of the virtual coordinates. + let repeatHeaderEls: HTMLElement[] | undefined + if (slice.repeatHeader) { + const tableEl = tr.closest('table') + const srcRows = tableEl + ? (Array.from(tableEl.querySelectorAll(':scope > tbody > tr')).filter( + (r) => + !r.classList.contains('page-gap') && + !r.classList.contains('page-repeat-header'), + ) as HTMLElement[]) + : [] + const els: HTMLElement[] = [] + let acc = 0 + for (const row of srcRows) { + if (acc >= slice.repeatHeader.height - 1.5) break + const clone = row.cloneNode(true) as HTMLElement + clone.classList.add('page-gap-inline', 'page-repeat-header') + clone.setAttribute('contenteditable', 'false') + els.push(clone) + acc += row.getBoundingClientRect().height / factor + } + if (els.length > 0) repeatHeaderEls = els + } for (let d = $pos.depth; d > 0; d--) { if ($pos.node(d).type.name === 'docTableRow') { - gaps.push({ pos: $pos.before(d), kind: 'table', metrics }) + gaps.push({ + pos: $pos.before(d), + kind: 'table', + metrics, + ...hfProps, + ...(repeatHeaderEls + ? { + repeatHeaderEls, + // content signature: header edits with unchanged height + // must still rebuild the widgets (same rule as hfKey) + repeatHeaderKey: `${repeatHeaderEls.length}-${Math.round(slice.repeatHeader!.height)}-${hashStr(repeatHeaderEls.map((e) => e.innerHTML).join('§'))}`, + } + : {}), + }) break } } @@ -1789,6 +1877,7 @@ export function App() { // inline cut point (page break mid-line): the cut falls in a line's band gap; locate the first line after it and insert a zero-height dashed marker const anchor = nextLineAnchor(b.el, slice.start - b.top, factor) const pos = anchor ? posFromAnchor(editor.view, anchor) : undefined + // no hfProps: a zero-height cut marker can't host header/footer strips if (pos != null) gaps.push({ pos, kind: 'cut', metrics }) } return @@ -1810,8 +1899,36 @@ export function App() { }) markShown() }) + // TOC page numbers: the file's cached PAGEREF results are stale (generators + // write them against a layout that never matches; Word silently refreshes on + // open, we never write back). Backfill the display from the live layout — + // DOM-only, inside contenteditable=false subtrees the save path never reads. + const tocLines = pm.querySelectorAll('.doc-toc-line[data-toc-anchor]') + if (tocLines.length > 0) { + const anchorIdx = new Map() + for (const b of parsed.blocks) { + if (b.docxIndex == null) continue + for (const a of b.hiddenBookmarks ?? []) anchorIdx.set(a, b.docxIndex) + } + const topByIdx = new Map() + for (const b of blocks) { + if (b.docxIndex != null) topByIdx.set(b.docxIndex, b.top) + } + for (const el of tocLines) { + const idx = anchorIdx.get(el.getAttribute('data-toc-anchor') ?? '') + const top = idx === undefined ? undefined : topByIdx.get(idx) + if (top === undefined) continue + const pageEl = el.querySelector('.doc-toc-page') + // pageAt is 1-based; pageNoTextOf indexes nums/slices 0-based + const pageIdx = Math.max(0, Math.min(pageAt(slices, top + 1) - 1, slices.length - 1)) + if (pageEl && slices.length > 0) pageEl.textContent = pageNoTextOf(pageIdx) + } + } } + tGapsBuild = performance.now() - tGaps0 + const tSet0 = performance.now() setPageGaps(editor.view, gaps) + tSetGaps = performance.now() - tSet0 // the last page paints as a full sheet like the ones above it: // extend the canvas to that page's paper bottom, measured from the last gap const gapEls = pm.querySelectorAll('.page-gap') @@ -1831,7 +1948,19 @@ export function App() { ;(window as unknown as Record).__pageDebug = { slices, blocks: blocks.map((b) => ({ top: b.top, height: b.height, docxIndex: b.docxIndex })), + remeasureMs: performance.now() - tStart, + measureMs: tMeasure, + sliceMs: tSlice, + gapsBuildMs: tGapsBuild, + setGapsMs: tSetGaps, } + requestAnimationFrame(() => { + const tf = performance.now() + requestAnimationFrame(() => { + const dbg = (window as unknown as Record>).__pageDebug + if (dbg) dbg.frameMs = performance.now() - tf + }) + }) setGapNoteIds((prev) => { if (prev.size === gapIds.size && [...gapIds].every((id) => prev.has(id))) return prev return gapIds @@ -2479,7 +2608,7 @@ export function App() { )} {/* Theme CSS comes from live state, so a Design ▸ Themes/Fonts/Colors pick shows on the page immediately instead of only in the saved file */} - {doc && } + {doc && } {colFlow && viewMode === 'print' && ( // columns (sectPr w:cols): column gap follows the document's w:space; measuring-columns // is the single-flow measuring state (columns removed, content-box width = column width, diff --git a/apps/docs/src/renderer/components/Ribbon.tsx b/apps/docs/src/renderer/components/Ribbon.tsx index 11f75db..44f2bac 100644 --- a/apps/docs/src/renderer/components/Ribbon.tsx +++ b/apps/docs/src/renderer/components/Ribbon.tsx @@ -24,6 +24,7 @@ import type { SectionSettings, SourceInfo, StyleInfo, + TextboxDisplay, ThemeColors, ThemeFonts, } from '@genoffice/docx-engine' @@ -240,7 +241,12 @@ const TABS = ( ) as readonly string[] const TABLE_TABS = ['tableDesign', 'tableLayout'] as const const IMAGE_TABS = ['pictureFormat'] as const -type RibbonTab = (typeof TABS)[number] | (typeof TABLE_TABS)[number] | (typeof IMAGE_TABS)[number] +const SHAPE_TABS = ['shapeFormat'] as const +type RibbonTab = + | (typeof TABS)[number] + | (typeof TABLE_TABS)[number] + | (typeof IMAGE_TABS)[number] + | (typeof SHAPE_TABS)[number] // tab values double as internal-state / external tabRequest keys; translated for display via these string keys const TAB_LABEL_KEYS: Record = { @@ -256,6 +262,7 @@ const TAB_LABEL_KEYS: Record = { tableDesign: 'ribbonTabTableDesign', tableLayout: 'ribbonTabTableLayout', pictureFormat: 'ribbonTabPictureFormat', + shapeFormat: 'ribbonTabShapeFormat', } /** CSS px per cm at 96dpi (size inputs display in centimeters) */ @@ -367,6 +374,77 @@ const COLORS: Array<{ nameKey: StringKey; hex: string }> = [ { nameKey: 'ribbonColorPurple', hex: '7030A0' }, ] +/** Theme + standard color palette for shape fill/outline (Shape Format tab) */ +function ShapeColorPalette({ + current, + noneLabel, + onPick, +}: { + current: string | null + noneLabel: string + onPick: (hex: string | null) => void +}) { + const { t } = useI18n() + return ( +
+ +
{t('ribbonThemeColorsSection')}
+
+ {THEME_COLORS.map((c) => ( +
+
+ {THEME_COLOR_SHADES.flatMap((row, rowIndex) => + row.map((hex, columnIndex) => ( +
+
{t('ribbonStandardColors')}
+
+ {COLORS.map((c) => ( +
+ +
+ ) +} + /** Word text highlight colors (OOXML named values) */ const HIGHLIGHTS = [ 'yellow', @@ -664,6 +742,45 @@ function RibbonInner({ } }, [inImage]) + // ---- Shape Format (contextual tab when a floating box is selected, same mechanism) ---- + const inShape = !sub && fs.textboxSelected + const shapeIsLine = !!fs.shapePrst?.startsWith('line') + const wasInShape = useRef(false) + + useEffect(() => { + if (inShape && !wasInShape.current) { + wasInShape.current = true + setDropdown(null) + setTab('shapeFormat') + } else if (!inShape && wasInShape.current) { + wasInShape.current = false + setDropdown(null) + setTab((current) => (current === 'shapeFormat' ? lastRegularTab.current : current)) + } + }, [inShape]) + + /** apply fill/outline to the selected floating box (first box of the node) */ + const setShapeStyle = (patch: { fill?: string | null; borderColor?: string | null }) => { + if (!canEdit) return + const attrs = editor.getAttributes('docProtected') + const boxes = attrs?.textboxes as TextboxDisplay[] | null + if (!Array.isArray(boxes) || boxes.length === 0) return + const box = { ...boxes[0] } + if ('fill' in patch) { + if (patch.fill) box.fill = patch.fill + else delete box.fill + } + if ('borderColor' in patch) { + if (patch.borderColor) box.borderColor = patch.borderColor + else delete box.borderColor + } + editor + .chain() + .focus() + .updateAttributes('docProtected', { textboxes: [box, ...boxes.slice(1)] }) + .run() + } + /** * Replace the selected image's bytes (shared by Replace Picture / remove background / crop). * The original image's patch-save only supports size/alignment/wrap; swapping bytes must go @@ -1322,12 +1439,95 @@ function RibbonInner({ {t(TAB_LABEL_KEYS[imageTab])} ))} + {inShape && + SHAPE_TABS.map((shapeTab) => ( + + ))} {trailingActions}
- {tab === 'pictureFormat' && inImage ? ( + {tab === 'shapeFormat' && inShape ? ( +
+
+
+ {!shapeIsLine && ( +
+ + {dropdown === 'shapeFill' && ( + { + setShapeStyle({ fill: hex }) + setDropdown(null) + }} + /> + )} +
+ )} +
+ + {dropdown === 'shapeOutline' && ( + { + setShapeStyle({ borderColor: hex }) + setDropdown(null) + }} + /> + )} +
+
+
{t('ribbonGroupShapeStyles')}
+
+
+ ) : tab === 'pictureFormat' && inImage ? (
{/* ---- Adjust: remove background / crop / replace picture ---- */}
diff --git a/apps/docs/src/renderer/components/ribbon-format-state.ts b/apps/docs/src/renderer/components/ribbon-format-state.ts index 2137622..98e3848 100644 --- a/apps/docs/src/renderer/components/ribbon-format-state.ts +++ b/apps/docs/src/renderer/components/ribbon-format-state.ts @@ -31,6 +31,9 @@ export interface RibbonFormatState { imageHeightPx: number | null imageHasDocxIndex: boolean textboxSelected: boolean + shapeFill: string | null + shapeBorderColor: string | null + shapePrst: string | null cellKey: number | null cellHeightCm: number | null cellWidthCm: number | null @@ -76,6 +79,9 @@ export const EMPTY_FORMAT_STATE: RibbonFormatState = { imageHeightPx: null, imageHasDocxIndex: false, textboxSelected: false, + shapeFill: null, + shapeBorderColor: null, + shapePrst: null, cellKey: null, cellHeightCm: null, cellWidthCm: null, @@ -189,6 +195,15 @@ export function computeFormatState( imageHeightPx: num(protAttrs.imageHeightPx), imageHasDocxIndex: protAttrs.docxIndex != null, textboxSelected: Array.isArray(protAttrs.textboxes) && protAttrs.textboxes.length > 0, + shapeFill: Array.isArray(protAttrs.textboxes) + ? str((protAttrs.textboxes[0] as { fill?: string } | undefined)?.fill) + : null, + shapeBorderColor: Array.isArray(protAttrs.textboxes) + ? str((protAttrs.textboxes[0] as { borderColor?: string } | undefined)?.borderColor) + : null, + shapePrst: Array.isArray(protAttrs.textboxes) + ? str((protAttrs.textboxes[0] as { prst?: string } | undefined)?.prst) + : null, cellKey, cellHeightCm, cellWidthCm, diff --git a/apps/docs/src/renderer/components/ribbon-tabs.tsx b/apps/docs/src/renderer/components/ribbon-tabs.tsx index a60b903..95d7923 100644 --- a/apps/docs/src/renderer/components/ribbon-tabs.tsx +++ b/apps/docs/src/renderer/components/ribbon-tabs.tsx @@ -2,14 +2,17 @@ import { useState } from 'react' import type { Editor, JSONContent } from '@tiptap/core' import { SHAPE_GALLERY_GROUPS, wordArtSolidColor, type WordArtPreset } from '@genoffice/ui' import { + buildLineParagraphXml, buildShapeParagraphXml, buildTextboxParagraphXml, buildWordArtParagraphXml, + LINE_KINDS, type HeaderFooter, type TextboxDisplay, } from '@genoffice/docx-engine' import type { DocsTabInfo } from '../../shared/ipc' import { tableModelToPmNode } from '../editor/convert' +import { isStraightLineKind } from '../editor/shape-svg' import type { InkTool } from '../editor/ink' import { t, useI18n, type StringKey } from '../i18n/locale' import iconEditor from '../assets/icon-editor.png' @@ -222,13 +225,11 @@ export function insertTextboxAt(editor: Editor): void { } /** - * Shape gallery for the picker dropdown: the cross-app shared groups (slides - * parity), minus Lines — the page renderer draws shapes as filled clipped - * boxes and cannot show stroke-only connectors yet. + * Shape gallery for the picker dropdown: the full cross-app shared groups + * (slides parity). Line/connector kinds insert stroke-only wps shapes + * (buildLineParagraphXml); filled presets insert prstGeom shapes. */ -export const DOC_SHAPE_GROUPS = SHAPE_GALLERY_GROUPS.filter( - (g) => g.groupKey !== 'ribbonShapeGroupLines', -) +export const DOC_SHAPE_GROUPS = SHAPE_GALLERY_GROUPS const DOC_SHAPES = DOC_SHAPE_GROUPS.flatMap((g) => g.shapes) @@ -259,6 +260,7 @@ export function insertShapeAt( prst: string, opts?: { widthEmu?: number; heightEmu?: number; atPos?: number }, ): number | null { + if (prst in LINE_KINDS) return insertLineAt(editor, prst, opts) const widthEmu = opts?.widthEmu ?? 1800000 const heightEmu = opts?.heightEmu ?? 1080000 const xml = buildShapeParagraphXml({ @@ -294,6 +296,58 @@ export function insertShapeAt( return editor.chain().focus().insertContentAt(position, content).run() ? position : null } +/** Word's horizontal-line extent (12 px grab band); straight lines always save this cy. */ +const LINE_HEIGHT_EMU = 114300 + +/** + * Insert a floating stroke-only line/connector (noFill wps:wsp) at the cursor + * or an explicit position. Straight kinds ignore the drawn height and land as + * a level line (the docx model stores Word's zero-ish-height extent); bent and + * curved connectors keep the drawn box. + */ +function insertLineAt( + editor: Editor, + kind: string, + opts?: { widthEmu?: number; heightEmu?: number; atPos?: number }, +): number | null { + const widthEmu = opts?.widthEmu ?? 1800000 + const heightEmu = isStraightLineKind(kind) ? LINE_HEIGHT_EMU : (opts?.heightEmu ?? 1080000) + const xml = buildLineParagraphXml({ + kind, + widthEmu, + heightEmu, + id: Math.floor(Math.random() * 900000) + 100000, + colorHex: '000000', + }) + // Mirror what parse.ts' lineBoxOf yields on reopen: read-only display box, + // stroke color on borderColor, zero insets. + const textbox: TextboxDisplay = { + borderColor: '000000', + widthPx: Math.round(widthEmu / 9525), + heightPx: Math.round(heightEmu / 9525), + prst: kind, + paras: [], + readOnly: true, + insetTopPx: 0, + insetRightPx: 0, + insetBottomPx: 0, + insetLeftPx: 0, + } + const content = { + type: 'docProtected', + attrs: { + docxIndex: null, + blockType: 'passthrough', + label: t('ribbonShapeLabel', { name: shapeLabel(kind) }), + genXml: xml, + textboxes: [textbox], + }, + } + const { $from } = editor.state.selection + const position = opts?.atPos ?? ($from.depth > 0 ? $from.after(1) : editor.state.selection.to) + return editor.chain().focus().insertContentAt(position, content).run() ? position : null +} + /** ~7.5 cm × 2 cm default size for WordArt in EMU */ const WORDART_WIDTH_EMU = 2700000 const WORDART_HEIGHT_EMU = 720000 diff --git a/apps/docs/src/renderer/doc-style-css.ts b/apps/docs/src/renderer/doc-style-css.ts index 29f238e..0a2adc6 100644 --- a/apps/docs/src/renderer/doc-style-css.ts +++ b/apps/docs/src/renderer/doc-style-css.ts @@ -1,5 +1,11 @@ -import type { ParsedDocFull, ThemeColors, ThemeFonts } from '@genoffice/docx-engine' -import { cssFontFamily, cssLineHeight, lineHeightFactor, textHasCjk } from './line-metrics' +import type { ParsedDocFull, StyleDisplay, ThemeColors, ThemeFonts } from '@genoffice/docx-engine' +import { + cssDualFontFamily, + cssFontFamily, + cssLineHeight, + lineHeightFactor, + textHasCjk, +} from './line-metrics' /** * CSS for the document theme (Design ▸ Themes / Fonts / Colors). Kept separate from @@ -10,10 +16,13 @@ import { cssFontFamily, cssLineHeight, lineHeightFactor, textHasCjk } from './li export function docThemeCss( fonts: ThemeFonts | null | undefined, colors: ThemeColors | null | undefined, + bodyFontDeclared = false, ): string { const rules: string[] = [] - if (fonts?.minor) { - // Body font: the theme's minor latin face (docDefaults still wins for runs that name a font) + if (fonts?.minor && !bodyFontDeclared) { + // Body font from the theme's minor latin face — only when neither Normal nor + // docDefaults names one (a declared body font supersedes the theme, and + // docStyleCss already resolved theme references into it) rules.push(`.doc-page { font-family:${cssFontFamily(fonts.minor)} }`) } if (fonts?.major) { @@ -49,26 +58,80 @@ export function docLineFactor(parsed: ParsedDocFull, hasCjk: boolean): number { const dd = parsed.docDefaults return hasCjk ? lineHeightFactor(dd?.eastAsiaFont ?? '宋体') - : lineHeightFactor(dd?.asciiFont ?? 'Calibri') + : lineHeightFactor(docBodyFont(parsed) ?? 'Calibri') +} + +/** the w:default="1" paragraph style's display (Word's baseline for un-styled paragraphs) */ +export function defaultParaDisplay(parsed: ParsedDocFull): StyleDisplay | undefined { + for (const info of parsed.styles.values()) { + if (info.isDefault && info.type === 'paragraph' && info.display) return info.display + } + return undefined +} + +/** Latin body font the document declares (Normal style or docDefaults, theme refs + * resolved). Ascii slot first — StyleDisplay.font is eastAsia-first and would drag + * the Latin line factor / theme override onto the CJK face. */ +export function docBodyFont(parsed: ParsedDocFull): string | undefined { + const normal = defaultParaDisplay(parsed) + return normal?.fontAscii ?? normal?.font ?? parsed.docDefaults?.asciiFont } export function docStyleCss(parsed: ParsedDocFull): string { const rules: string[] = [] const dd = parsed.docDefaults + // Word applies the w:default="1" paragraph style (Normal) to every paragraph + // without a w:pStyle, so its display merges into the document baseline here + // ([data-style] rules only reach explicitly styled paragraphs). + const normal = defaultParaDisplay(parsed) { const decls: string[] = [] // Paragraph level also overrides this variable per paragraph's text (blockAttrs // at parse time + live decorations in LineFactorExtension). const factor = docLineFactor(parsed, docHasCjk(parsed)) decls.push(`--doc-line-factor:${factor}`) - decls.push(`font-family:${cssFontFamily(dd?.asciiFont ?? 'Calibri')}`) - if (dd?.sizeHalfPoints) decls.push(`font-size:${dd.sizeHalfPoints / 2}pt`) - if (dd?.color) decls.push(`color:#${dd.color}`) - if (dd?.bold) decls.push('font-weight:600') - if (dd?.italic) decls.push('font-style:italic') - const lh = cssLineHeight(dd?.lineRule, dd?.lineRawTwips, dd?.lineSpacing) + // Latin factor for per-paragraph overrides (blockAttrs): pure-Western paragraphs + // follow the body font's real single-line metric instead of a flat 1.2 + decls.push(`--doc-line-factor-latin:${lineHeightFactor(docBodyFont(parsed) ?? 'Calibri')}`) + // dual-slot baseline: Latin families first, then the East Asian chain + const baseAscii = normal?.fontAscii ?? dd?.asciiFont + const baseEa = normal?.font ?? dd?.eastAsiaFont + decls.push( + `font-family:${ + baseAscii && baseEa && baseAscii !== baseEa + ? cssDualFontFamily(baseAscii, baseEa) + : cssFontFamily(baseEa ?? baseAscii ?? 'Calibri') + }`, + ) + const sizeHalf = normal?.sizeHalfPoints ?? dd?.sizeHalfPoints + if (sizeHalf) decls.push(`font-size:${sizeHalf / 2}pt`) + const color = normal?.color ?? dd?.color + if (color) decls.push(`color:#${color}`) + if (normal?.bold ?? dd?.bold) decls.push('font-weight:600') + if (normal?.italic ?? dd?.italic) decls.push('font-style:italic') + const lh = + cssLineHeight(normal?.lineRule, normal?.lineRawTwips, normal?.lineSpacing) ?? + cssLineHeight(dd?.lineRule, dd?.lineRawTwips, dd?.lineSpacing) decls.push(`line-height:${lh ?? `calc(${factor} * 1)`}`) rules.push(`.doc-page { ${decls.join(';')} }`) + // docDefaults paragraph spacing is Word's real fallback (the static stylesheet's + // 8pt is a guess); declared per block so --doc-line-factor set inline on a + // paragraph re-evaluates the line-height var (it wouldn't through inheritance) + const blockSel = + '.doc-page p, .doc-page .doc-li, .doc-page h1, .doc-page h2, .doc-page h3, .doc-page h4, .doc-page h5, .doc-page h6' + const blockDecls = [ + `margin-top:${((normal?.spaceBeforeTwips ?? dd?.spaceBeforeTwips ?? 0) / 20).toFixed(1)}pt`, + `margin-bottom:${((normal?.spaceAfterTwips ?? dd?.spaceAfterTwips ?? 160) / 20).toFixed(1)}pt`, + `line-height:${lh ?? `calc(${factor} * 1)`}`, + ] + rules.push(`${blockSel} { ${blockDecls.join(';')} }`) + // Normal's first-line indent applies to plain body paragraphs (not lists — + // their geometry runs on --li-left/--li-hang) + if ((normal?.indentFirstLineTwips ?? 0) > 0) { + rules.push( + `.doc-page p { text-indent:${((normal!.indentFirstLineTwips as number) / 20).toFixed(1)}pt }`, + ) + } } // table styles: tables carrying data-tbl-style are colored by style (explicit cell shading // is inline style and naturally overrides these rules; parse gives exact display after save) @@ -77,18 +140,38 @@ export function docStyleCss(parsed: ParsedDocFull): string { if (info.type !== 'table' || !t) continue const sel = `.doc-page table[data-tbl-style="${CSS.escape(info.styleId)}"]` if (t.fill) rules.push(`${sel} td, ${sel} th { background:#${t.fill} }`) + // band1 = first data row after the header → even nth-child when a header row exists if (t.band1Fill) { - rules.push(`${sel} tr:nth-child(odd):not(:first-child) td { background:#${t.band1Fill} }`) + rules.push(`${sel} tr:nth-child(even) td { background:#${t.band1Fill} }`) } if (t.band2Fill) { - rules.push(`${sel} tr:nth-child(even):not(:first-child) td { background:#${t.band2Fill} }`) + rules.push(`${sel} tr:nth-child(odd):not(:first-child) td { background:#${t.band2Fill} }`) } if (t.firstRow) { const decls: string[] = [] if (t.firstRow.fill) decls.push(`background:#${t.firstRow.fill}`) if (t.firstRow.bold) decls.push('font-weight:600') if (t.firstRow.color) decls.push(`color:#${t.firstRow.color}`) - if (decls.length > 0) rules.push(`${sel} tr:first-child td { ${decls.join(';')} }`) + if (decls.length > 0) + rules.push(`${sel} tr:first-child td, ${sel} tr:first-child th { ${decls.join(';')} }`) + } + if (t.paraSpacing) { + // Word precedence: paragraph style (Normal) > table style pPr > docDefaults — + // emit only the table-style values Normal doesn't declare itself + const ps = t.paraSpacing + const decls: string[] = [] + if (ps.beforeTwips !== undefined && normal?.spaceBeforeTwips === undefined) + decls.push(`margin-top:${(ps.beforeTwips / 20).toFixed(1)}pt`) + if (ps.afterTwips !== undefined && normal?.spaceAfterTwips === undefined) + decls.push(`margin-bottom:${(ps.afterTwips / 20).toFixed(1)}pt`) + const psLh = cssLineHeight(ps.lineRule, ps.lineRawTwips, ps.lineSpacing) + const normalLh = cssLineHeight(normal?.lineRule, normal?.lineRawTwips, normal?.lineSpacing) + if (psLh && !normalLh) decls.push(`line-height:${psLh}`) + if (decls.length > 0) { + rules.push( + `${sel} td p, ${sel} th p, ${sel} td .doc-li, ${sel} th .doc-li { ${decls.join(';')} }`, + ) + } } } for (const info of parsed.styles.values()) { @@ -104,7 +187,17 @@ export function docStyleCss(parsed: ParsedDocFull): string { `text-decoration:${[d.underline && 'underline', d.strike && 'line-through'].filter(Boolean).join(' ')}`, ) } - if (d.font) decls.push(`font-family:${cssFontFamily(d.font)}`) + if (d.font) { + decls.push( + `font-family:${ + d.fontAscii && d.fontAscii !== d.font + ? cssDualFontFamily(d.fontAscii, d.font) + : cssFontFamily(d.font) + }`, + ) + } else if (d.fontAscii) { + decls.push(`font-family:${cssFontFamily(d.fontAscii)}`) + } if (d.charSpacingTwips) decls.push(`letter-spacing:${d.charSpacingTwips / 20}pt`) const styleLh = cssLineHeight(d.lineRule, d.lineRawTwips, d.lineSpacing) if (styleLh) decls.push(`line-height:${styleLh}`) @@ -116,8 +209,20 @@ export function docStyleCss(parsed: ParsedDocFull): string { if (d.indentRightTwips) decls.push(`margin-right:${(d.indentRightTwips / 20).toFixed(1)}pt`) if (d.indentFirstLineTwips) decls.push(`text-indent:${(d.indentFirstLineTwips / 20).toFixed(1)}pt`) - if (decls.length === 0) continue - rules.push(`.doc-page [data-style="${CSS.escape(info.styleId)}"] { ${decls.join(';')} }`) + if (d.align) decls.push(`text-align:${d.align}`) + // the static sheet guesses italic for h4-h6 (Word's built-in defaults); + // a real style definition without w:i means upright + if (info.headingLevel && info.headingLevel >= 4 && !d.italic) decls.push('font-style:normal') + if (decls.length > 0) { + rules.push(`.doc-page [data-style="${CSS.escape(info.styleId)}"] { ${decls.join(';')} }`) + } + // w:contextualSpacing: consecutive same-style paragraphs swallow the spacing + // between them (ListParagraph/ListBullet carry this — Word lists are tight) + if (d.contextualSpacing) { + const s = `[data-style="${CSS.escape(info.styleId)}"]` + rules.push(`.doc-page ${s}:has(+ ${s}) { margin-bottom:0 }`) + rules.push(`.doc-page ${s} + ${s} { margin-top:0 }`) + } } return rules.join('\n') } diff --git a/apps/docs/src/renderer/editor/convert.ts b/apps/docs/src/renderer/editor/convert.ts index 483d909..4f0c250 100644 --- a/apps/docs/src/renderer/editor/convert.ts +++ b/apps/docs/src/renderer/editor/convert.ts @@ -11,6 +11,8 @@ import { type CellTextsPatch, patchDrawingExtent, patchTextboxSizes, + patchShapeStyles, + type ShapeStylePatch, type TextboxSizePatch, patchTextboxParas, generateTableModelXml, @@ -794,6 +796,7 @@ export function pmDocToSavePlan(doc: PmNode, originalBlocks: Block[]): SavePlan const tableTexts = tableTextsPatch(node, original) const textboxTexts = textboxParasPatch(node, original) const textboxSizes = textboxSizesPatch(node, original) + const textboxStyles = textboxStylesPatch(node, original) const textboxOffsetX = node.attrs?.imageOffsetXEmu != null ? Number(node.attrs.imageOffsetXEmu) : undefined const textboxOffsetY = @@ -842,13 +845,14 @@ export function pmDocToSavePlan(doc: PmNode, originalBlocks: Block[]): SavePlan changedCount++ pushBlock({ kind: 'xml', xml: patchTableCellTexts(original.originalXml, tableTexts) }) } else if ( - (textboxTexts || textboxSizes || textboxPositionChanged) && + (textboxTexts || textboxSizes || textboxStyles || textboxPositionChanged) && original.originalXml ) { changedCount++ let xml = original.originalXml if (textboxTexts) xml = patchTextboxParas(xml, textboxTexts) if (textboxSizes) xml = patchTextboxSizes(xml, textboxSizes) + if (textboxStyles) xml = patchShapeStyles(xml, textboxStyles) if (textboxPositionChanged) { const wrap = (node.attrs?.imageWrap as ImageWrap | null) ?? original.imageWrap ?? 'square-left' @@ -894,6 +898,13 @@ export function pmDocToSavePlan(doc: PmNode, originalBlocks: Block[]): SavePlan table.rows.map((row) => row.map((cell) => cell.paras)), ) } + // editor-generated TOC lines: write the auto-refreshed page number back + // (right only — the title is already in the genXml, and a left+right + // patch bails out entirely when the title text nodes don't line up) + const genField = node.attrs?.fieldDisplay as FieldDisplay | null + if (genField?.kind === 'tocLine' && genField.right) { + xml = patchFieldParagraphXml(xml, { right: genField.right }) + } // patch textbox paragraphs for newly-inserted textboxes/shapes with text const genTextboxes = node.attrs?.textboxes as TextboxDisplay[] | null const hasNonEmptyTextbox = @@ -919,6 +930,11 @@ export function pmDocToSavePlan(doc: PmNode, originalBlocks: Block[]): SavePlan }, ]) } + if (genBox) { + xml = patchShapeStyles(xml, [ + { fillHex: genBox.fill ?? null, borderHex: genBox.borderColor ?? null }, + ]) + } // apply wrap changes for floating textboxes/shapes const genWrap = node.attrs?.imageWrap as ImageWrap | null const genOffsetX = @@ -1295,6 +1311,25 @@ function textboxSizesPatch(node: PmNode, original: Block): (TextboxSizePatch | n return changed ? sizes : null } +function textboxStylesPatch(node: PmNode, original: Block): (ShapeStylePatch | null)[] | null { + const current = node.attrs?.textboxes as TextboxDisplay[] | null + const initial = original.textboxes + if (!current || !initial || current.length !== initial.length) return null + let changed = false + const styles = current.map((box, index) => { + const fillHex = + (box.fill ?? null) !== (initial[index].fill ?? null) ? (box.fill ?? null) : undefined + const borderHex = + (box.borderColor ?? null) !== (initial[index].borderColor ?? null) + ? (box.borderColor ?? null) + : undefined + if (fillHex === undefined && borderHex === undefined) return null + changed = true + return { fillHex, borderHex } + }) + return changed ? styles : null +} + function fieldTextPatch(node: PmNode, original: Block): FieldTextPatch | null { const current = node.attrs?.fieldDisplay as FieldDisplay | null const initial = original.fieldDisplay @@ -1349,8 +1384,8 @@ function nodeFormat(node: PmNode): ParaFormat | undefined { if (node.attrs?.indentLeft) format.indentLeft = Number(node.attrs.indentLeft) if (node.attrs?.indentRight) format.indentRight = Number(node.attrs.indentRight) if (node.attrs?.indentFirstLine) format.indentFirstLine = Number(node.attrs.indentFirstLine) - if (node.attrs?.spaceBefore) format.spaceBefore = Number(node.attrs.spaceBefore) - if (node.attrs?.spaceAfter) format.spaceAfter = Number(node.attrs.spaceAfter) + if (node.attrs?.spaceBefore != null) format.spaceBefore = Number(node.attrs.spaceBefore) + if (node.attrs?.spaceAfter != null) format.spaceAfter = Number(node.attrs.spaceAfter) if (node.attrs?.pageBreakBefore) format.pageBreakBefore = true if (node.attrs?.bidi) format.bidi = true if (node.attrs?.shadingFill) format.shadingFill = String(node.attrs.shadingFill) diff --git a/apps/docs/src/renderer/editor/extensions.ts b/apps/docs/src/renderer/editor/extensions.ts index 5751daa..7e9874a 100644 --- a/apps/docs/src/renderer/editor/extensions.ts +++ b/apps/docs/src/renderer/editor/extensions.ts @@ -160,10 +160,15 @@ function blockAttrs( if (node.attrs.align) { styles.push(`text-align:${node.attrs.align === 'distribute' ? 'justify' : node.attrs.align}`) } - // the line-height factor follows paragraph content (approximating Word's max-of-inline-fonts line height): - // paragraphs containing CJK get 1.3, pure-Western ones 1.2; empty paragraphs inherit the document-level variable + // the line-height factor follows paragraph content (approximating Word's max-of-inline-fonts + // line height): CJK paragraphs get the CJK factor, pure-Western ones the document's + // font-aware Latin factor (doc-style-css sets --doc-line-factor-latin per body font) if (node.textContent) { - styles.push(`--doc-line-factor:${textHasCjk(node.textContent) ? 1.3 : 1.2}`) + styles.push( + `--doc-line-factor:${ + textHasCjk(node.textContent) ? 1.3 : 'var(--doc-line-factor-latin,1.2)' + }`, + ) } const lh = cssLineHeight( (node.attrs.lineRule as 'auto' | 'atLeast' | 'exact' | null) ?? undefined, @@ -188,8 +193,11 @@ function blockAttrs( if (listGeometry && firstLine < 0) styles.push(`--li-hang:${-firstLine / 20}pt`) else styles.push(`text-indent:${firstLine / 20}pt`) } - if (node.attrs.spaceBefore) styles.push(`margin-top:${Number(node.attrs.spaceBefore) / 20}pt`) - if (node.attrs.spaceAfter) styles.push(`margin-bottom:${Number(node.attrs.spaceAfter) / 20}pt`) + // explicit 0 must still emit (w:after="0" overrides the style/docDefaults margin) + if (node.attrs.spaceBefore != null) + styles.push(`margin-top:${Number(node.attrs.spaceBefore) / 20}pt`) + if (node.attrs.spaceAfter != null) + styles.push(`margin-bottom:${Number(node.attrs.spaceAfter) / 20}pt`) if (node.attrs.shadingFill) styles.push(`background-color:#${node.attrs.shadingFill}`) if (node.attrs.borders) { const borders = String(node.attrs.borders) @@ -597,7 +605,7 @@ function lineFactorDecos(doc: PmNode): DecorationSet { } decos.push( Decoration.node(pos, pos + node.nodeSize, { - style: `--doc-line-factor:${cjk ? 1.3 : 1.2}`, + style: `--doc-line-factor:${cjk ? 1.3 : 'var(--doc-line-factor-latin,1.2)'}`, }), ) } @@ -721,7 +729,7 @@ const tableCellAttrs = { } /** One OOXML border → CSS border value; 'none' means explicitly borderless */ -function borderLineCss( +export function borderLineCss( b: { style: string; szEighths?: number; color?: string } | undefined | null, ): string | null { if (!b) return null @@ -763,6 +771,10 @@ function tableCellHtml(node: PmNode): Record { : node.attrs.textDirection === 'btLr' ? 'writing-mode:sideways-lr' : '', + // font-weight before background: jsdom's CSSOM drops the background getter + // when font-weight follows it (order is irrelevant to real browsers) + node.attrs.bold ? 'font-weight:600' : '', + node.attrs.color ? `color:#${node.attrs.color}` : '', node.attrs.fill ? `background:#${node.attrs.fill}` : '', node.attrs.align ? `text-align:${node.attrs.align}` : '', node.attrs.vAlign && node.attrs.vAlign !== 'top' @@ -797,16 +809,16 @@ export type TableBordersAttr = Partial< > /** - * Table-level w:tblBorders → outer frame on the table element + inner-line CSS variables - * (td takes inner lines via --doc-b-h/--doc-b-v; border-collapse lets the outer frame win - * on edge cells). Undeclared = keep the default grid lines. + * Table-level w:tblBorders → CSS variables consumed by edge/inside cell rules + * (--doc-b-t/r/b/l on edge cells beat the inside lines even when the frame is + * explicitly none). Undeclared = no borders, matching Word's printed output. */ export function tableBordersCss(b: TableBordersAttr | null): string[] { if (!b) return [] const styles: string[] = [] + const edge = { top: 't', right: 'r', bottom: 'b', left: 'l' } as const for (const side of ['top', 'right', 'bottom', 'left'] as const) { - const v = borderLineCss(b[side]) - if (v) styles.push(`border-${side}:${v}`) + styles.push(`--doc-b-${edge[side]}:${borderLineCss(b[side]) ?? 'none'}`) } styles.push(`--doc-b-h:${borderLineCss(b.insideH) ?? 'none'}`) styles.push(`--doc-b-v:${borderLineCss(b.insideV) ?? 'none'}`) @@ -871,18 +883,18 @@ export const DocTable = Node.create({ // A colgroup with normalized percentages defines the column grid whenever the // pct list matches the grid, so a table clamped to the content box compresses // its columns proportionally instead of overflowing via fixed td px widths. - let firstRowSpans = false let firstRowCols = 0 node.firstChild?.forEach((cell) => { - const span = Number(cell.attrs.colspan) || 1 - if (span > 1) firstRowSpans = true - firstRowCols += span + firstRowCols += Number(cell.attrs.colspan) || 1 }) - const pct = node.attrs.colWidthsPct as number[] | null - if ( - pct?.length && - (firstRowSpans || (pct.length === firstRowCols && pct.every((w) => w > 0))) - ) { + const rawPct = node.attrs.colWidthsPct as number[] | null + if (rawPct?.length) { + // zero-width grid slots get a small floor, short grids pad with the average — + // dropping the whole colgroup falls back to fixed-layout even splitting, which + // is always worse than an approximate grid + const pct = rawPct.map((w) => (w > 0 ? w : 0.5)) + const avg = pct.reduce((sum, w) => sum + w, 0) / pct.length + while (pct.length < firstRowCols) pct.push(avg) const total = pct.reduce((sum, w) => sum + w, 0) || 100 return [ 'table', @@ -2307,8 +2319,8 @@ const TextboxParagraph = Node.create({ node.attrs.indentLeft ? `margin-left:${Number(node.attrs.indentLeft) / 20}pt` : '', node.attrs.indentRight ? `margin-right:${Number(node.attrs.indentRight) / 20}pt` : '', node.attrs.indentFirstLine ? `text-indent:${Number(node.attrs.indentFirstLine) / 20}pt` : '', - node.attrs.spaceBefore ? `margin-top:${Number(node.attrs.spaceBefore) / 20}pt` : '', - node.attrs.spaceAfter ? `margin-bottom:${Number(node.attrs.spaceAfter) / 20}pt` : '', + node.attrs.spaceBefore != null ? `margin-top:${Number(node.attrs.spaceBefore) / 20}pt` : '', + node.attrs.spaceAfter != null ? `margin-bottom:${Number(node.attrs.spaceAfter) / 20}pt` : '', node.attrs.shadingFill ? `background-color:#${node.attrs.shadingFill}` : '', ] .filter(Boolean) diff --git a/apps/docs/src/renderer/editor/pagination-gaps.ts b/apps/docs/src/renderer/editor/pagination-gaps.ts index 308fd71..4777df9 100644 --- a/apps/docs/src/renderer/editor/pagination-gaps.ts +++ b/apps/docs/src/renderer/editor/pagination-gaps.ts @@ -91,12 +91,18 @@ export type PageGapSpec = { /** Previous page's footer / next page's header (ready-made positioned .page-gap-hf elements) and their content signature */ hfEls?: HTMLElement[] hfKey?: string + /** w:tblHeader repetition: cloned header rows rendered right after a table gap + * (the slicing engine already reserved their height on the new page) */ + repeatHeaderEls?: HTMLElement[] + repeatHeaderKey?: string } & ({ el: HTMLElement } | { pos: number; kind?: 'inline' | 'table' | 'cut' }) /** Rebuild all page gaps (an empty list clears them); each gap carries its own margins (sections differ) */ export function setPageGaps(view: EditorView, gaps: PageGapSpec[]): void { const decos: Decoration[] = [] + let ordinal = -1 for (const gap of gaps) { + ordinal++ const { metrics, notes, hfEls } = gap let pos: number let kind: 'block' | 'inline' | 'table' | 'cut' @@ -119,19 +125,38 @@ export function setPageGaps(view: EditorView, gaps: PageGapSpec[]): void { () => { const el = makeGapEl(metrics, kind) if (notes) el.appendChild(notes) - // header/footer strips only fit the full-width gap variants (a table-row - // gap has no reliable absolute-positioning context; cut markers have no height) if (hfEls && (kind === 'block' || kind === 'inline')) { for (const hf of hfEls) el.appendChild(hf) + } else if (hfEls && kind === 'table') { + // table-row gaps position their strips inside the absolutely-filled cell; + // only zero-height cut markers still can't carry them + const fill = el.querySelector('.page-gap-table-fill') + if (fill) for (const hf of hfEls) fill.appendChild(hf) } return el }, { side: -1, - key: `page-gap-${kind[0]}-${pos}-${mKey}${gap.notesKey ? `-${gap.notesKey}` : ''}${gap.hfKey ? `-${gap.hfKey}` : ''}`, + // keyed by page ordinal, NOT pos: edits above a gap shift its mapped + // position without changing the page, so an ordinal key lets sameGaps + // skip the dispatch entirely and lets PM reuse the widget DOM when a + // dispatch does happen + key: `page-gap-${kind[0]}-${ordinal}-${mKey}${gap.notesKey ? `-${gap.notesKey}` : ''}${gap.hfKey ? `-${gap.hfKey}` : ''}`, }, ), ) + // repeated header rows (w:tblHeader) directly after the table gap: one widget per + // cloned tr, side 0 so they land between the gap (side -1) and the split row + if (kind === 'table' && gap.repeatHeaderEls?.length) { + gap.repeatHeaderEls.forEach((rowEl, i) => { + decos.push( + Decoration.widget(pos, () => rowEl, { + side: 0, + key: `page-gap-rh-${pos}-${i}-${gap.repeatHeaderKey ?? ''}`, + }), + ) + }) + } } const next = DecorationSet.create(view.state.doc, decos) const prev = key.getState(view.state) diff --git a/apps/docs/src/renderer/editor/protected-render.ts b/apps/docs/src/renderer/editor/protected-render.ts index 5801176..d83be53 100644 --- a/apps/docs/src/renderer/editor/protected-render.ts +++ b/apps/docs/src/renderer/editor/protected-render.ts @@ -24,6 +24,7 @@ import { DomSpec, ProtectedContentEditor, TableBordersAttr, + borderLineCss, cellPadCss, preventProtectedLineBreak, protectedText, @@ -537,10 +538,19 @@ export function renderTableSpec(model: TableModel): DomSpec { : cell.textDirection === 'btLr' ? 'writing-mode:sideways-lr' : '', - cell.fill ? `background:#${cell.fill}` : '', cell.color ? `color:#${cell.color}` : '', cell.bold ? 'font-weight:600' : '', + cell.fill ? `background:#${cell.fill}` : '', cell.align ? `text-align:${cell.align}` : '', + cell.vAlign && cell.vAlign !== 'top' + ? `vertical-align:${cell.vAlign === 'center' ? 'middle' : 'bottom'}` + : '', + // w:tcBorders — nested/read-only tables get no default gridlines, so + // per-cell borders are the only line source for style-less documents + ...(['top', 'left', 'bottom', 'right'] as const).map((side) => { + const v = borderLineCss(cell.borders?.[side]) + return v ? `border-${side}:${v}` : '' + }), ...(['top', 'left', 'bottom', 'right'] as const).map((side) => cell.cellMarTwips?.[side] !== undefined ? `padding-${side}:${(cell.cellMarTwips[side]! / 15).toFixed(1)}px` @@ -562,7 +572,10 @@ export function renderTableSpec(model: TableModel): DomSpec { if (content.length === 0) content.push('\u00a0') tds.push(['td', tdAttrs, ...content]) }) - return ['tr', {}, ...tds] + const trAttrs: Record = {} + const rh = model.rowHeightsTwips?.[ri] + if (rh) trAttrs.style = `height:${((rh / 1440) * 96).toFixed(1)}px` + return ['tr', trAttrs, ...tds] }) const tableChildren: unknown[] = [] diff --git a/apps/docs/src/renderer/editor/shape-draw.ts b/apps/docs/src/renderer/editor/shape-draw.ts index 7ed68bf..55a56be 100644 --- a/apps/docs/src/renderer/editor/shape-draw.ts +++ b/apps/docs/src/renderer/editor/shape-draw.ts @@ -8,6 +8,8 @@ */ import type { Editor } from '@tiptap/core' import { shapeClipCss } from '@genoffice/ui' +import { LINE_KINDS } from '@genoffice/docx-engine' +import { isStraightLineKind, shapeBackgroundImage } from './shape-svg' const EMU_PER_PX = 9525 /** Word's predefined single-click insert size: 1x1 inch. */ @@ -48,6 +50,20 @@ export function resolveDrawRect( } } +/** + * Viewport y where the inserted box's top edge should land. Straight lines + * collapse to Word's 12px grab band with the stroke at its vertical center, + * while the ghost previewed the stroke at the drag rect's vertical center — + * so the band is centered on that line instead of pinned to rect.y. + */ +export function commitTargetY( + rect: DrawRectPx, + boxHeightPx: number, + straightLine: boolean, +): number { + return straightLine ? rect.y + rect.h / 2 - boxHeightPx / 2 : rect.y +} + /** Drawn rect (viewport px) → shape extent in EMU, floored at 1px so the OOXML stays valid. */ export function drawRectToEmu( rect: DrawRectPx, @@ -97,6 +113,8 @@ export function startShapeDrawMode( return el ? parseFloat(getComputedStyle(el).zoom || '1') || 1 : 1 } + const isLine = prst in LINE_KINDS + const updateGhost = () => { if (!start || !cur) return if (Math.hypot(cur.x - start.x, cur.y - start.y) <= CLICK_THRESHOLD_PX) return @@ -106,16 +124,33 @@ export function startShapeDrawMode( ghost.style.position = 'fixed' ghost.style.zIndex = '9999' ghost.style.pointerEvents = 'none' - // Ghost of the default Office-blue shape the gesture will insert - ghost.style.background = 'rgba(68,114,196,0.45)' - ghost.style.border = '1px solid #2F5496' + if (!isLine) { + // Ghost of the default Office-blue shape the gesture will insert + ghost.style.background = 'rgba(68,114,196,0.45)' + ghost.style.border = '1px solid #2F5496' + } ghost.style.boxSizing = 'border-box' document.body.appendChild(ghost) } - // Recomputed per move: path()-clipped presets are size-dependent - const clip = shapeClipCss(prst, r.w, r.h) - ghost.style.clipPath = clip?.clipPath ?? '' - ghost.style.borderRadius = clip?.borderRadius ?? '' + if (isLine) { + // Stroke-only preview of the line/connector the gesture will insert + // (straight kinds land level, so the ghost draws them level too) + const image = shapeBackgroundImage( + prst, + Math.max(8, r.w), + Math.max(8, r.h), + undefined, + '000000', + ) + ghost.style.backgroundImage = image ?? '' + ghost.style.backgroundSize = '100% 100%' + ghost.style.backgroundRepeat = 'no-repeat' + } else { + // Recomputed per move: path()-clipped presets are size-dependent + const clip = shapeClipCss(prst, r.w, r.h) + ghost.style.clipPath = clip?.clipPath ?? '' + ghost.style.borderRadius = clip?.borderRadius ?? '' + } ghost.style.left = `${r.x}px` ghost.style.top = `${r.y}px` ghost.style.width = `${r.w}px` @@ -148,7 +183,7 @@ export function startShapeDrawMode( if (!box) return const at = box.getBoundingClientRect() const dx = (rect.x - at.left) / zoom - const dy = (rect.y - at.top) / zoom + const dy = (commitTargetY(rect, at.height, isStraightLineKind(prst)) - at.top) / zoom if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return view.dispatch( view.state.tr.setNodeMarkup(insertedAt, undefined, { diff --git a/apps/docs/src/renderer/editor/shape-svg.ts b/apps/docs/src/renderer/editor/shape-svg.ts index 8256d57..34244c8 100644 --- a/apps/docs/src/renderer/editor/shape-svg.ts +++ b/apps/docs/src/renderer/editor/shape-svg.ts @@ -113,10 +113,10 @@ export function shapePreviewPathD(prst: string, w: number, h: number): string | } /** - * Shape visual as CSS background properties (data-URI SVG at the box's pixel - * size, insets included so the stroke isn't clipped at the edges). + * Shape visual as a CSS background-image url() (data-URI SVG at the box's + * pixel size, insets included so the stroke isn't clipped at the edges). */ -export function shapeBackgroundCss( +export function shapeBackgroundImage( prst: string, w: number, h: number, @@ -142,8 +142,18 @@ export function shapeBackgroundCss( `` + parts.join('') + '' - return ( - `background-image:url("data:image/svg+xml,${encodeURIComponent(svg)}");` + - 'background-size:100% 100%;background-repeat:no-repeat' - ) + return `url("data:image/svg+xml,${encodeURIComponent(svg)}")` +} + +/** Same visual as full CSS background properties (for style strings). */ +export function shapeBackgroundCss( + prst: string, + w: number, + h: number, + fillHex?: string, + borderHex?: string, +): string | null { + const image = shapeBackgroundImage(prst, w, h, fillHex, borderHex) + if (!image) return null + return `background-image:${image};background-size:100% 100%;background-repeat:no-repeat` } diff --git a/apps/docs/src/renderer/editor/toc-refresh.ts b/apps/docs/src/renderer/editor/toc-refresh.ts new file mode 100644 index 0000000..f4b5bf6 --- /dev/null +++ b/apps/docs/src/renderer/editor/toc-refresh.ts @@ -0,0 +1,58 @@ +import type { Node as PmNode } from '@tiptap/pm/model' +import type { Transaction } from '@tiptap/pm/state' + +import type { HeadingRef } from './headings' + +/** + * Backfill tocLine fieldDisplay pages from freshly measured heading pages. + * + * `displays` is aligned with `headings` (document order): one formatted page + * string per heading, or undefined when the heading was not measured + * (unmeasured entries keep their tocLine untouched instead of shifting later + * pages up). The caller formats via the owning section's pgNumType, so + * Roman/letter/dashed numbers survive the refresh. Duplicate titles keep + * their own entries — the Nth tocLine with a title takes the Nth same-titled + * heading's page (extras reuse the last one, matching a stale TOC with more + * lines than headings). + * + * Returns true when `tr` gained at least one node update. + */ +export function applyTocPageDisplays( + doc: PmNode, + tr: Transaction, + headings: HeadingRef[], + displays: Array, +): boolean { + const pagesOfTitle = new Map>() + headings.forEach((h, i) => { + const key = h.text.trim() + const list = pagesOfTitle.get(key) + if (list) list.push(displays[i]) + else pagesOfTitle.set(key, [displays[i]]) + }) + if (pagesOfTitle.size === 0) return false + let changed = false + const seen = new Map() + doc.forEach((node, offset) => { + if (node.type.name !== 'docProtected') return + const field = node.attrs.fieldDisplay as { + kind?: string + left?: string + right?: string + } | null + if (field?.kind !== 'tocLine') return + const key = (field.left ?? '').trim() + const list = pagesOfTitle.get(key) + if (!list) return + const nth = seen.get(key) ?? 0 + seen.set(key, nth + 1) + const right = list[Math.min(nth, list.length - 1)] + if (right === undefined || (field.right ?? '') === right) return + tr.setNodeMarkup(offset, undefined, { + ...node.attrs, + fieldDisplay: { ...field, right }, + }) + changed = true + }) + return changed +} diff --git a/apps/docs/src/renderer/file-actions.ts b/apps/docs/src/renderer/file-actions.ts index 621320f..24c072c 100644 --- a/apps/docs/src/renderer/file-actions.ts +++ b/apps/docs/src/renderer/file-actions.ts @@ -194,8 +194,12 @@ export async function loadFile( ctx.setPageColor(readPageColor(parsed)) ctx.setPageColorDirty(false) ctx.setHeader( - parsed.headerText || parsed.headerParas?.length - ? { text: parsed.headerText ?? '', paras: parsed.headerParas ?? undefined } + parsed.headerText || parsed.headerHasPageNumber || parsed.headerParas?.length + ? { + text: parsed.headerText ?? '', + pageNumber: parsed.headerHasPageNumber, + paras: parsed.headerParas ?? undefined, + } : null, ) ctx.setHeaderDirty(false) @@ -639,8 +643,12 @@ async function saveOnce(ctx: FileActionContext, saveAs: boolean, auto: boolean): ctx.setPageColor(readPageColor(reparsed)) ctx.setPageColorDirty(false) ctx.setHeader( - reparsed.headerText || reparsed.headerParas?.length - ? { text: reparsed.headerText ?? '', paras: reparsed.headerParas ?? undefined } + reparsed.headerText || reparsed.headerHasPageNumber || reparsed.headerParas?.length + ? { + text: reparsed.headerText ?? '', + pageNumber: reparsed.headerHasPageNumber, + paras: reparsed.headerParas ?? undefined, + } : null, ) ctx.setHeaderDirty(false) diff --git a/apps/docs/src/renderer/i18n/strings-ribbon.ts b/apps/docs/src/renderer/i18n/strings-ribbon.ts index e24e957..56e0e17 100644 --- a/apps/docs/src/renderer/i18n/strings-ribbon.ts +++ b/apps/docs/src/renderer/i18n/strings-ribbon.ts @@ -19,6 +19,14 @@ export const ribbonStrings = defineStrings({ ribbonTabTableDesign: '表格设计', ribbonTabTableLayout: '表格布局', ribbonTabPictureFormat: '图片格式', + ribbonTabShapeFormat: '形状格式', + ribbonGroupShapeStyles: '形状样式', + ribbonShapeFill: '形状填充', + ribbonShapeFillTip: '填充所选形状的颜色', + ribbonShapeOutline: '形状轮廓', + ribbonShapeOutlineTip: '设置所选形状的轮廓颜色', + ribbonNoFill: '无填充', + ribbonNoOutline: '无轮廓', // Picture Format ribbonRemoveBg: '去除背景', ribbonRemoveBgTip: '去除背景:基于颜色容差抠图(替换为透明 PNG)', @@ -676,6 +684,14 @@ export const ribbonStrings = defineStrings({ ribbonTabTableDesign: 'Table Design', ribbonTabTableLayout: 'Table Layout', ribbonTabPictureFormat: 'Picture Format', + ribbonTabShapeFormat: 'Shape Format', + ribbonGroupShapeStyles: 'Shape Styles', + ribbonShapeFill: 'Shape Fill', + ribbonShapeFillTip: 'Fill the selected shape with a color', + ribbonShapeOutline: 'Shape Outline', + ribbonShapeOutlineTip: 'Pick the outline color of the selected shape', + ribbonNoFill: 'No Fill', + ribbonNoOutline: 'No Outline', ribbonRemoveBg: 'Remove Background', ribbonRemoveBgTip: 'Remove background: color-tolerance cutout (replaced with a transparent PNG)', @@ -1326,6 +1342,14 @@ export const ribbonStrings = defineStrings({ ribbonTabTableDesign: 'テーブル デザイン', ribbonTabTableLayout: 'テーブル レイアウト', ribbonTabPictureFormat: '図の形式', + ribbonTabShapeFormat: '図形の書式', + ribbonGroupShapeStyles: '図形のスタイル', + ribbonShapeFill: '図形の塗りつぶし', + ribbonShapeFillTip: '選択した図形を色で塗りつぶします', + ribbonShapeOutline: '図形の枠線', + ribbonShapeOutlineTip: '選択した図形の枠線の色を選択します', + ribbonNoFill: '塗りつぶしなし', + ribbonNoOutline: '枠線なし', ribbonRemoveBg: '背景の削除', ribbonRemoveBgTip: '背景の削除:色の許容差に基づく切り抜き(透明 PNG に置き換え)', ribbonCrop: 'トリミング', @@ -1996,6 +2020,14 @@ export const ribbonStrings = defineStrings({ ribbonTabTableDesign: '테이블 디자인', ribbonTabTableLayout: '테이블 레이아웃', ribbonTabPictureFormat: '그림 서식', + ribbonTabShapeFormat: '도형 서식', + ribbonGroupShapeStyles: '도형 스타일', + ribbonShapeFill: '도형 채우기', + ribbonShapeFillTip: '선택한 도형을 색으로 채웁니다', + ribbonShapeOutline: '도형 윤곽선', + ribbonShapeOutlineTip: '선택한 도형의 윤곽선 색을 선택합니다', + ribbonNoFill: '채우기 없음', + ribbonNoOutline: '윤곽선 없음', ribbonRemoveBg: '배경 제거', ribbonRemoveBgTip: '배경 제거: 색 허용 오차 기반 추출(투명 PNG로 대체)', ribbonCrop: '자르기', @@ -2663,6 +2695,14 @@ export const ribbonStrings = defineStrings({ ribbonTabTableDesign: 'Création de tableau', ribbonTabTableLayout: 'Disposition du tableau', ribbonTabPictureFormat: "Format de l'image", + ribbonTabShapeFormat: 'Format de la forme', + ribbonGroupShapeStyles: 'Styles de formes', + ribbonShapeFill: 'Remplissage de forme', + ribbonShapeFillTip: 'Remplir la forme sélectionnée avec une couleur', + ribbonShapeOutline: 'Contour de forme', + ribbonShapeOutlineTip: 'Choisir la couleur du contour de la forme sélectionnée', + ribbonNoFill: 'Aucun remplissage', + ribbonNoOutline: 'Sans contour', ribbonRemoveBg: "Supprimer l'arrière-plan", ribbonRemoveBgTip: "Supprimer l'arrière-plan : détourage par tolérance de couleur (remplacé par un PNG transparent)", @@ -3323,6 +3363,14 @@ export const ribbonStrings = defineStrings({ ribbonTabTableDesign: 'Tabellenentwurf', ribbonTabTableLayout: 'Tabellenlayout', ribbonTabPictureFormat: 'Bildformat', + ribbonTabShapeFormat: 'Formformat', + ribbonGroupShapeStyles: 'Formenarten', + ribbonShapeFill: 'Fülleffekt', + ribbonShapeFillTip: 'Die ausgewählte Form mit einer Farbe füllen', + ribbonShapeOutline: 'Formkontur', + ribbonShapeOutlineTip: 'Konturfarbe der ausgewählten Form auswählen', + ribbonNoFill: 'Keine Füllung', + ribbonNoOutline: 'Keine Kontur', ribbonRemoveBg: 'Hintergrund entfernen', ribbonRemoveBgTip: 'Hintergrund entfernen: Freistellen per Farbtoleranz (durch transparentes PNG ersetzt)', @@ -3983,6 +4031,14 @@ export const ribbonStrings = defineStrings({ ribbonTabTableDesign: 'Diseño de tabla', ribbonTabTableLayout: 'Disposición de tabla', ribbonTabPictureFormat: 'Formato de imagen', + ribbonTabShapeFormat: 'Formato de forma', + ribbonGroupShapeStyles: 'Estilos de forma', + ribbonShapeFill: 'Relleno de forma', + ribbonShapeFillTip: 'Rellenar la forma seleccionada con un color', + ribbonShapeOutline: 'Contorno de forma', + ribbonShapeOutlineTip: 'Elegir el color de contorno de la forma seleccionada', + ribbonNoFill: 'Sin relleno', + ribbonNoOutline: 'Sin contorno', ribbonRemoveBg: 'Quitar fondo', ribbonRemoveBgTip: 'Quitar fondo: recorte por tolerancia de color (reemplazado por un PNG transparente)', @@ -4646,6 +4702,14 @@ export const ribbonStrings = defineStrings({ ribbonTabTableDesign: 'การออกแบบตาราง', ribbonTabTableLayout: 'เค้าโครงตาราง', ribbonTabPictureFormat: 'รูปแบบรูปภาพ', + ribbonTabShapeFormat: 'รูปแบบรูปร่าง', + ribbonGroupShapeStyles: 'สไตล์รูปร่าง', + ribbonShapeFill: 'เติมสีรูปร่าง', + ribbonShapeFillTip: 'เติมสีให้รูปร่างที่เลือก', + ribbonShapeOutline: 'เส้นกรอบรูปร่าง', + ribbonShapeOutlineTip: 'เลือกสีเส้นกรอบของรูปร่างที่เลือก', + ribbonNoFill: 'ไม่เติมสี', + ribbonNoOutline: 'ไม่มีเส้นกรอบ', ribbonRemoveBg: 'เอาพื้นหลังออก', ribbonRemoveBgTip: 'เอาพื้นหลังออก: ตัดภาพตามค่าความคลาดเคลื่อนของสี (แทนที่ด้วย PNG โปร่งใส)', ribbonCrop: 'ครอบตัด', @@ -5290,6 +5354,14 @@ export const ribbonStrings = defineStrings({ ribbonTabTableDesign: 'Desain Tabel', ribbonTabTableLayout: 'Tata Letak Tabel', ribbonTabPictureFormat: 'Format Gambar', + ribbonTabShapeFormat: 'Format Bentuk', + ribbonGroupShapeStyles: 'Gaya Bentuk', + ribbonShapeFill: 'Isian Bentuk', + ribbonShapeFillTip: 'Isi bentuk yang dipilih dengan warna', + ribbonShapeOutline: 'Kerangka Bentuk', + ribbonShapeOutlineTip: 'Pilih warna kerangka bentuk yang dipilih', + ribbonNoFill: 'Tanpa Isian', + ribbonNoOutline: 'Tanpa Kerangka', ribbonRemoveBg: 'Hapus Latar Belakang', ribbonRemoveBgTip: 'Hapus latar belakang: pemotongan berdasarkan toleransi warna (diganti dengan PNG transparan)', @@ -5943,6 +6015,14 @@ export const ribbonStrings = defineStrings({ ribbonTabTableDesign: 'Конструктор таблиц', ribbonTabTableLayout: 'Макет таблицы', ribbonTabPictureFormat: 'Формат рисунка', + ribbonTabShapeFormat: 'Формат фигуры', + ribbonGroupShapeStyles: 'Стили фигур', + ribbonShapeFill: 'Заливка фигуры', + ribbonShapeFillTip: 'Залить выбранную фигуру цветом', + ribbonShapeOutline: 'Контур фигуры', + ribbonShapeOutlineTip: 'Выбрать цвет контура выбранной фигуры', + ribbonNoFill: 'Нет заливки', + ribbonNoOutline: 'Нет контура', ribbonRemoveBg: 'Удалить фон', ribbonRemoveBgTip: 'Удалить фон: вырезание по допуску цвета (заменяется прозрачным PNG)', ribbonCrop: 'Обрезка', @@ -6598,6 +6678,14 @@ export const ribbonStrings = defineStrings({ ribbonTabTableDesign: 'تصميم الجدول', ribbonTabTableLayout: 'تخطيط الجدول', ribbonTabPictureFormat: 'تنسيق الصورة', + ribbonTabShapeFormat: 'تنسيق الشكل', + ribbonGroupShapeStyles: 'أنماط الأشكال', + ribbonShapeFill: 'تعبئة الشكل', + ribbonShapeFillTip: 'تعبئة الشكل المحدد بلون', + ribbonShapeOutline: 'المخطط التفصيلي للشكل', + ribbonShapeOutlineTip: 'اختيار لون المخطط التفصيلي للشكل المحدد', + ribbonNoFill: 'بلا تعبئة', + ribbonNoOutline: 'بلا مخطط تفصيلي', ribbonRemoveBg: 'إزالة الخلفية', ribbonRemoveBgTip: 'إزالة الخلفية: اقتصاص حسب تفاوت اللون (تُستبدل بصورة PNG شفافة)', ribbonCrop: 'اقتصاص', @@ -7243,6 +7331,14 @@ export const ribbonStrings = defineStrings({ ribbonTabTableDesign: 'Design da Tabela', ribbonTabTableLayout: 'Layout da Tabela', ribbonTabPictureFormat: 'Formato da Imagem', + ribbonTabShapeFormat: 'Formato da Forma', + ribbonGroupShapeStyles: 'Estilos de Forma', + ribbonShapeFill: 'Preenchimento da Forma', + ribbonShapeFillTip: 'Preencher a forma selecionada com uma cor', + ribbonShapeOutline: 'Contorno da Forma', + ribbonShapeOutlineTip: 'Escolher a cor do contorno da forma selecionada', + ribbonNoFill: 'Sem Preenchimento', + ribbonNoOutline: 'Sem Contorno', ribbonRemoveBg: 'Remover Plano de Fundo', ribbonRemoveBgTip: 'Remover plano de fundo: recorte por tolerância de cor (substituído por um PNG transparente)', @@ -7896,6 +7992,14 @@ export const ribbonStrings = defineStrings({ ribbonTabTableDesign: 'Progettazione tabella', ribbonTabTableLayout: 'Layout tabella', ribbonTabPictureFormat: 'Formato immagine', + ribbonTabShapeFormat: 'Formato forma', + ribbonGroupShapeStyles: 'Stili forma', + ribbonShapeFill: 'Riempimento forma', + ribbonShapeFillTip: 'Riempi la forma selezionata con un colore', + ribbonShapeOutline: 'Contorno forma', + ribbonShapeOutlineTip: 'Scegli il colore del contorno della forma selezionata', + ribbonNoFill: 'Nessun riempimento', + ribbonNoOutline: 'Nessun contorno', ribbonRemoveBg: 'Rimuovi sfondo', ribbonRemoveBgTip: 'Rimuovi sfondo: ritaglio in base alla tolleranza del colore (sostituito con un PNG trasparente)', @@ -8555,6 +8659,14 @@ export const ribbonStrings = defineStrings({ ribbonTabTableDesign: 'Projekt tabeli', ribbonTabTableLayout: 'Układ tabeli', ribbonTabPictureFormat: 'Formatowanie obrazu', + ribbonTabShapeFormat: 'Formatowanie kształtu', + ribbonGroupShapeStyles: 'Style kształtów', + ribbonShapeFill: 'Wypełnienie kształtu', + ribbonShapeFillTip: 'Wypełnij zaznaczony kształt kolorem', + ribbonShapeOutline: 'Kontury kształtu', + ribbonShapeOutlineTip: 'Wybierz kolor konturu zaznaczonego kształtu', + ribbonNoFill: 'Brak wypełnienia', + ribbonNoOutline: 'Brak konturu', ribbonRemoveBg: 'Usuń tło', ribbonRemoveBgTip: 'Usuń tło: wycinanie na podstawie tolerancji koloru (zastąpione przezroczystym plikiem PNG)', @@ -9209,6 +9321,14 @@ export const ribbonStrings = defineStrings({ ribbonTabTableDesign: 'Tabelontwerp', ribbonTabTableLayout: 'Tabelindeling', ribbonTabPictureFormat: 'Afbeeldingsopmaak', + ribbonTabShapeFormat: 'Vormopmaak', + ribbonGroupShapeStyles: 'Vormstijlen', + ribbonShapeFill: 'Opvulling van vorm', + ribbonShapeFillTip: 'De geselecteerde vorm met een kleur vullen', + ribbonShapeOutline: 'Omtrek van vorm', + ribbonShapeOutlineTip: 'De omtrekkleur van de geselecteerde vorm kiezen', + ribbonNoFill: 'Geen opvulling', + ribbonNoOutline: 'Geen omtrek', ribbonRemoveBg: 'Achtergrond verwijderen', ribbonRemoveBgTip: 'Achtergrond verwijderen: uitsnijden op basis van kleurtolerantie (vervangen door een transparante PNG)', @@ -9867,6 +9987,14 @@ export const ribbonStrings = defineStrings({ ribbonTabTableDesign: 'Reka Bentuk Jadual', ribbonTabTableLayout: 'Tataletak Jadual', ribbonTabPictureFormat: 'Format Gambar', + ribbonTabShapeFormat: 'Format Bentuk', + ribbonGroupShapeStyles: 'Gaya Bentuk', + ribbonShapeFill: 'Isian Bentuk', + ribbonShapeFillTip: 'Isi bentuk yang dipilih dengan warna', + ribbonShapeOutline: 'Rangka Bentuk', + ribbonShapeOutlineTip: 'Pilih warna rangka bentuk yang dipilih', + ribbonNoFill: 'Tiada Isian', + ribbonNoOutline: 'Tiada Rangka', ribbonRemoveBg: 'Alih Keluar Latar Belakang', ribbonRemoveBgTip: 'Alih keluar latar belakang: potongan berdasarkan toleransi warna (digantikan dengan PNG lutsinar)', @@ -10521,6 +10649,14 @@ export const ribbonStrings = defineStrings({ ribbonTabTableDesign: 'עיצוב טבלה', ribbonTabTableLayout: 'פריסת טבלה', ribbonTabPictureFormat: 'עיצוב תמונה', + ribbonTabShapeFormat: 'עיצוב צורה', + ribbonGroupShapeStyles: 'סגנונות צורה', + ribbonShapeFill: 'מילוי צורה', + ribbonShapeFillTip: 'מילוי הצורה שנבחרה בצבע', + ribbonShapeOutline: 'מתאר צורה', + ribbonShapeOutlineTip: 'בחירת צבע המתאר של הצורה שנבחרה', + ribbonNoFill: 'ללא מילוי', + ribbonNoOutline: 'ללא מתאר', ribbonRemoveBg: 'הסרת רקע', ribbonRemoveBgTip: 'הסרת רקע: חיתוך לפי סבילות צבע (מוחלף ב-PNG שקוף)', ribbonCrop: 'חיתוך', @@ -11162,6 +11298,14 @@ export const ribbonStrings = defineStrings({ ribbonTabTableDesign: 'तालिका डिज़ाइन', ribbonTabTableLayout: 'तालिका लेआउट', ribbonTabPictureFormat: 'चित्र स्वरूप', + ribbonTabShapeFormat: 'आकृति स्वरूप', + ribbonGroupShapeStyles: 'आकृति शैलियाँ', + ribbonShapeFill: 'आकृति भरण', + ribbonShapeFillTip: 'चयनित आकृति को रंग से भरें', + ribbonShapeOutline: 'आकृति रूपरेखा', + ribbonShapeOutlineTip: 'चयनित आकृति की रूपरेखा का रंग चुनें', + ribbonNoFill: 'कोई भरण नहीं', + ribbonNoOutline: 'कोई रूपरेखा नहीं', ribbonRemoveBg: 'पृष्ठभूमि हटाएँ', ribbonRemoveBgTip: 'पृष्ठभूमि हटाएँ: रंग सहनशीलता के आधार पर कटआउट (पारदर्शी PNG से बदला जाता है)', @@ -11817,6 +11961,14 @@ export const ribbonStrings = defineStrings({ ribbonTabTableDesign: '表格設計', ribbonTabTableLayout: '表格版面配置', ribbonTabPictureFormat: '圖片格式', + ribbonTabShapeFormat: '圖形格式', + ribbonGroupShapeStyles: '圖形樣式', + ribbonShapeFill: '圖案填滿', + ribbonShapeFillTip: '以色彩填滿選取的圖形', + ribbonShapeOutline: '圖案外框', + ribbonShapeOutlineTip: '選擇選取圖形的外框色彩', + ribbonNoFill: '無填滿', + ribbonNoOutline: '無外框', ribbonRemoveBg: '移除背景', ribbonRemoveBgTip: '移除背景:依色彩容許度去背(取代為透明 PNG)', ribbonCrop: '裁剪', diff --git a/apps/docs/src/renderer/line-metrics.ts b/apps/docs/src/renderer/line-metrics.ts index 874e177..4a62e85 100644 --- a/apps/docs/src/renderer/line-metrics.ts +++ b/apps/docs/src/renderer/line-metrics.ts @@ -120,7 +120,21 @@ export function lineHeightFactor(fontFamily: string): number { ) { return 1.3 } - // Western and default (Calibri, Arial, Times, Liberation, etc.) + // Western single-line factors follow each font's hhea metrics (Word's single + // spacing): a 4% surplus per line cascades into whole-paragraph pagination + // drift, so the big Office faces get their real values. + if (f.includes('times') || f.includes('liberation serif')) return 1.15 + if (f.includes('georgia')) return 1.14 + if (f.includes('cambria') || f.includes('caladea')) return 1.17 + if ( + f === 'arial' || + f.startsWith('arial ') || + f.includes('helvetica') || + f.includes('liberation sans') + ) + return 1.15 + if (f.includes('calibri') || f.includes('carlito')) return 1.22 + // default (Lato, Segoe, unknown Western) return 1.2 } @@ -217,6 +231,39 @@ export function computeLineHeight( * breaks aligned with Word and the offline pagination model. CJK families fall * back to macOS equivalents (CJK width is always 1em, so this is mostly glyph appearance). */ +/** + * Whether a font family actually resolves on this machine. document.fonts.check + * is useless in Chromium (true for any unknown system-ish family), so this + * measures a mixed-script sample against both generic fallbacks: a family that + * changes neither width doesn't exist. + */ +const fontAvailableCache = new Map() +export function isFontAvailable(font: string): boolean { + if (typeof document === 'undefined') return false + const cached = fontAvailableCache.get(font) + if (cached !== undefined) return cached + let available = false + try { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + if (ctx) { + const sample = '한글あア中文abcWXYmm123' + const quoted = `"${font.replace(/"/g, '')}"` + const widthWith = (family: string) => { + ctx.font = `32px ${family}` + return ctx.measureText(sample).width + } + available = + widthWith(`${quoted}, monospace`) !== widthWith('monospace') || + widthWith(`${quoted}, serif`) !== widthWith('serif') + } + } catch { + /* headless/test environments treat every font as missing */ + } + fontAvailableCache.set(font, available) + return available +} + export function cssFontFamily(font: string): string { const f = font.toLowerCase() const chain = (...families: string[]) => @@ -262,6 +309,31 @@ export function cssFontFamily(font: string): string { const TC_SANS = ['Microsoft JhengHei', 'PingFang TC', 'Heiti TC', 'Noto Sans TC'] const TC_SERIF = ['PMingLiU', 'MingLiU', 'Songti TC', 'Noto Serif TC'] const nfkc = font.normalize('NFKC') + // Word substitutes a *missing* East Asian font with the locale's default face — + // a serif (Mincho/Batang) — regardless of the requested font's classification. + // Classic Windows faces (Malgun/Meiryo/Yu Gothic...) have solid macOS + // equivalents in the chains and keep their classification. + const missingLocally = () => !isFontAvailable(font) + // Noto CJK / Source Han / Nanum regional variants route by suffix; the generic + // fallback tail below would otherwise land them on the bundled Simplified-only subset + const cjkVariant = /^(?:noto|source han) (sans|serif)(?: cjk)? ?(jp|kr|k\b|tc|tw|hk)/i.exec(nfkc) + if (cjkVariant) { + const serif = /serif/i.test(cjkVariant[1]) || missingLocally() + const region = cjkVariant[2].toLowerCase() + const chainFor = + region === 'jp' + ? serif + ? JA_SERIF + : JA_SANS + : region === 'kr' || region === 'k' + ? serif + ? KO_SERIF + : KO_SANS + : serif + ? TC_SERIF + : TC_SANS + return `${chain(font, ...chainFor)},${serif ? 'serif' : 'sans-serif'}` + } if ( /[぀-ヿ]|mincho|meiryo|hiragino|osaka|yugoth|yu (gothic|mincho)|ms (ui )?p?(gothic|mincho)|明朝|biz ud|kozuka|小塚/i.test( nfkc, @@ -275,7 +347,12 @@ export function cssFontFamily(font: string): string { nfkc, ) ) { - const serif = /batang|바탕|myeongjo|myungjo|명조|gungsuh|궁서/i.test(nfkc) + // vendor faces (Nanum...) missing locally follow Word's Batang-ward substitution; + // Windows core faces (Malgun/Gulim/Dotum) map cleanly to the sans chain + const knownCore = /malgun|맑은|gulim|굴림|dotum|돋움|apple (sd )?gothic/i.test(nfkc) + const serif = + /batang|바탕|myeongjo|myungjo|명조|gungsuh|궁서/i.test(nfkc) || + (!knownCore && missingLocally()) return `${chain(font, ...(serif ? KO_SERIF : KO_SANS))},${serif ? 'serif' : 'sans-serif'}` } if ( diff --git a/apps/docs/src/renderer/pagination.ts b/apps/docs/src/renderer/pagination.ts index 8284914..157c82b 100644 --- a/apps/docs/src/renderer/pagination.ts +++ b/apps/docs/src/renderer/pagination.ts @@ -1155,6 +1155,19 @@ export function pageAt(slices: PageSlice[], y: number): number { return page } +/** + * Pages the user can see: an even/odd-section parity blank shares its start with the + * neighbouring slice and draws no page, so NUMPAGES, the status bar, and the gap + * header/footer widgets all count only distinct slice starts (up to `upTo` slices). + */ +export function visiblePageCount(slices: PageSlice[], upTo = slices.length): number { + let n = 0 + for (let i = 0; i < Math.min(upTo, slices.length); i++) { + if (i === 0 || slices[i].start !== slices[i - 1].start) n++ + } + return n +} + export interface MeasuredContent { blocks: BlockBox[] totalHeight: number @@ -1296,6 +1309,30 @@ export function sliceWithLineSplit( * Table blocks → tableRows (tr boundaries; never cuts into text lines inside cells); text blocks → lineBoxes. * Returns whether any block was filled (true means the caller must re-slice). */ +/** + * DOM line/row sampling is the hot path of repeated repagination: the set of + * page-crossing blocks is stable across edits, so raw samples are cached by + * element identity plus a cheap content/geometry signature. Entries drop with + * their element (WeakMap) or when the signature stops matching. + */ +const lineSampleCache = new WeakMap< + HTMLElement, + { sig: string; boundaries?: number[]; rows?: TableRowBox[] } +>() + +function lineSampleSig(el: HTMLElement, textH: number): string { + // djb2 over the text: equal-length edits must still invalidate + const text = el.textContent ?? '' + let h = 5381 + for (let i = 0; i < text.length; i++) h = ((h << 5) + h + text.charCodeAt(i)) | 0 + // width guards width-only reflows; descendant count guards nested (e.g. table + // cell) structure changes that keep the direct-child count. A stale miss only + // costs one re-sample, so quantization errs toward invalidating. + const w = el.getBoundingClientRect().width + const nodes = el.getElementsByTagName('*').length + return `${Math.round(textH * 4)}:${Math.round(w * 4)}:${nodes}:${h}` +} + export function fillLineBoxes( blocks: BlockBox[], geoms: SectionGeom[], @@ -1326,9 +1363,16 @@ export function fillLineBoxes( // line boxes tile only the text area (block height includes the merged-in space-after, which lines must not cover) const textH = block.height - (block.spaceAfterPx ?? 0) + const sig = lineSampleSig(block.el, textH) + const cached = lineSampleCache.get(block.el) + const hit = cached?.sig === sig ? cached : null if (block.el.querySelector('tr')) { - const rows = domTableRows(block.el, textH, zoomFactor) + // flags mutate the rows, so cached rows are cloned per use + const rows = hit?.rows + ? hit.rows.map((r) => ({ ...r })) + : domTableRows(block.el, textH, zoomFactor) + if (!hit?.rows) lineSampleCache.set(block.el, { sig, rows: rows.map((r) => ({ ...r })) }) if (rows.length > 0) { const flags = block.docxIndex !== undefined ? metaOf?.(block.docxIndex)?.tableRowFlags : undefined @@ -1342,7 +1386,11 @@ export function fillLineBoxes( } continue } - const boundaries = domLineBoundaries(block.el, zoomFactor) + // synthesized over-page cuts below mutate the list, so cached entries are copied out + const boundaries = hit?.boundaries + ? [...hit.boundaries] + : domLineBoundaries(block.el, zoomFactor) + if (!hit?.boundaries) lineSampleCache.set(block.el, { sig, boundaries: [...boundaries] }) if (boundaries.length === 0 && block.height > contentH) { // over-page block with no text lines (e.g. a large image): synthesize cut points at page height, equivalent to hard pixel cuts for (let y = contentH; y < block.height; y += contentH) boundaries.push(y) @@ -1428,8 +1476,16 @@ function domTableRows(el: HTMLElement, blockHeight: number, zoomFactor: number): ) const gapAbove = (top: number) => gaps.reduce((s, g) => (g.top <= top ? s + g.height : s), 0) const elTop = el.getBoundingClientRect().top - // take only the outer table's rows: trs of nested tables inside cells (.doc-nested-table) are in-row content, not page-split units - const trs = Array.from(el.querySelectorAll('tr')).filter((tr) => !tr.closest('.doc-nested-table')) + // take only the outer table's real rows: trs of nested tables inside cells + // (.doc-nested-table) are in-row content, and decoration rows (page gaps / + // repeated tblHeader clones) are not page-split units — counting them would + // add phantom boundaries and shift the tableRowFlags index alignment + const trs = Array.from(el.querySelectorAll('tr')).filter( + (tr) => + !tr.closest('.doc-nested-table') && + !tr.classList.contains('page-gap') && + !tr.classList.contains('page-repeat-header'), + ) const tops: number[] = [] for (const tr of trs) { const trTop = tr.getBoundingClientRect().top diff --git a/apps/docs/src/renderer/styles.css b/apps/docs/src/renderer/styles.css index 58436eb..45c92c2 100644 --- a/apps/docs/src/renderer/styles.css +++ b/apps/docs/src/renderer/styles.css @@ -11,7 +11,7 @@ --hover: #f3f2f1; --pressed: #edebe9; /* selected/pressed plate: neutral gray (slides/sheets/pdf ribbon parity) */ - --active-bg: #e2e0de; + --active-bg: #eaeaea; --canvas: #e6e6e6; --ai-highlight: #fff3c4; --ai-highlight-border: #f2b900; @@ -477,7 +477,7 @@ button { /* Neutral gray, not the Word-blue --active-bg: the Genspark entry is the same control across docs/sheets/slides, so its open state matches their ribbons. */ .rb-big.ai-entry.active { - background: #ebebeb; + background: #eaeaea; } .rb-big.ai-entry:hover:not(:disabled) .rb-big-icon, @@ -2645,6 +2645,8 @@ th.cell-rev-del { font-size: 11pt; line-height: 1.35; color: #000; + /* Word's autoSpaceDE/DN (on by default): 1/8em gap between CJK and Latin/digits */ + text-autospace: normal; } .workspace-dark { @@ -2718,7 +2720,9 @@ th.cell-rev-del { position: relative; height: auto; padding: 0; - border: 0; + /* !important: the td edge-border rules (--doc-b-l/r on first/last-child) come + later in the sheet and would otherwise frame the page gutter */ + border: 0 !important; background: var(--page-bg, var(--surface)); } @@ -3180,11 +3184,12 @@ th.cell-rev-del { .doc-table th { position: relative; box-sizing: border-box; - /* default grid lines; tables with w:tblBorders override via --doc-b-h/--doc-b-v, the outer frame wins through the table element's border collapse */ - border-top: var(--doc-b-h, 1px solid #808080); - border-bottom: var(--doc-b-h, 1px solid #808080); - border-left: var(--doc-b-v, 1px solid #808080); - border-right: var(--doc-b-v, 1px solid #808080); + /* undeclared borders render as none (Word's on-screen gridlines are non-printing + view furniture, not part of the document); w:tblBorders drives --doc-b-* */ + border-top: var(--doc-b-h, none); + border-bottom: var(--doc-b-h, none); + border-left: var(--doc-b-v, none); + border-right: var(--doc-b-v, none); /* Word default cell margins: 108 twips (7.2px) sides / 0 top-bottom; w:tblCellMar overrides via table-level variables */ padding: var(--doc-cell-pad, 0 7.2px); vertical-align: top; @@ -3194,8 +3199,38 @@ th.cell-rev-del { overflow-wrap: normal; } +/* repeated header rows after a table page break (w:tblHeader): pure decoration — + not selectable, not editable, invisible to hit-testing. display beats the + .page-gap-inline inline-block (that class only marks them as measurement gaps). */ +.doc-table tr.page-repeat-header { + display: table-row; + user-select: none; + pointer-events: none; +} + +/* outer edges: table-level outer borders beat the inside-line variables even when the + frame is explicitly none (border-collapse would otherwise let inside lines win) */ +.doc-table tr:first-child > td, +.doc-table tr:first-child > th { + border-top: var(--doc-b-t, var(--doc-b-h, none)); +} +.doc-table tr:last-child > td, +.doc-table tr:last-child > th { + border-bottom: var(--doc-b-b, var(--doc-b-h, none)); +} +.doc-table td:first-child, +.doc-table th:first-child { + border-left: var(--doc-b-l, var(--doc-b-v, none)); +} +.doc-table td:last-child, +.doc-table th:last-child { + border-right: var(--doc-b-r, var(--doc-b-v, none)); +} + +/* header cells follow the document model (cell bold/align attrs), not the UA defaults */ .doc-table th { - font-weight: 600; + font-weight: inherit; + text-align: inherit; } .doc-table .selectedCell { @@ -5068,7 +5103,7 @@ body.docs-crop-active box-shadow var(--transition-fast); } -/* textarea: 15px / 21px fixed line-height, auto-grow band +/* textarea: 16px / 24px fixed line-height (chat body size), auto-grow band * (resting height matches the sheets panel, 25% taller than default) */ .ai-input-box textarea { display: block; @@ -5077,15 +5112,17 @@ body.docs-crop-active border: none; background: none; border-radius: var(--radius-16) var(--radius-16) 0 0; - padding: 12px 14px 4px; - font-size: 15px; - font-weight: 500; + /* vertical gaps live on margin, not padding: textarea padding scrolls away + * with the content, so clipped lines would sit flush against chips/footer */ + padding: 0 14px; + margin: 12px 0 4px; + font-size: 16px; font-family: inherit; - line-height: 21px; + line-height: 24px; outline: none; color: var(--color-text-primary); - min-height: 64px; - max-height: 147px; + min-height: 48px; + max-height: 168px; overflow-y: auto; } diff --git a/apps/docs/tests/line-factor-live.test.ts b/apps/docs/tests/line-factor-live.test.ts index b64bbb4..0657bd9 100644 --- a/apps/docs/tests/line-factor-live.test.ts +++ b/apps/docs/tests/line-factor-live.test.ts @@ -28,7 +28,7 @@ describe('live line-height factor decorations', () => { editor.destroy() }) - it('replacing CJK text with Western text switches the factor back to 1.2', () => { + it('replacing CJK text with Western text switches back to the Latin factor', () => { const editor = new Editor({ element: document.createElement('div'), extensions: editorExtensions, @@ -41,7 +41,7 @@ describe('live line-height factor decorations', () => { editor.commands.setTextSelection({ from: 1, to: 3 }) editor.commands.insertContent('latin') - expect(factorOf(editor)).toBe('1.2') + expect(factorOf(editor)).toBe('var(--doc-line-factor-latin,1.2)') editor.destroy() }) diff --git a/apps/docs/tests/line-metrics.test.ts b/apps/docs/tests/line-metrics.test.ts index fc8d240..2ccca01 100644 --- a/apps/docs/tests/line-metrics.test.ts +++ b/apps/docs/tests/line-metrics.test.ts @@ -28,11 +28,11 @@ const TWIPS_TO_PX = 96 / 1440 describe('HeuristicMetrics', () => { const m = new HeuristicMetrics() - it('natural line height of 12pt text is about 14.4px (1.2em)', () => { + it('natural line height of 12pt Arial follows its hhea factor (1.15em)', () => { const fontSizePx = 12 * (96 / 72) // 16px const style = { fontFamily: 'Arial', fontSizePx, bold: false, italic: false } const metrics = m.metrics(style) - expect(metrics.lineHeight).toBeCloseTo(fontSizePx * 1.2, 1) + expect(metrics.lineHeight).toBeCloseTo(fontSizePx * 1.15, 1) expect(metrics.ascent).toBeCloseTo(fontSizePx * 0.8, 1) expect(metrics.descent).toBeCloseTo(fontSizePx * 0.2, 1) }) diff --git a/apps/docs/tests/pagination.test.ts b/apps/docs/tests/pagination.test.ts index c931a1f..1f3faae 100644 --- a/apps/docs/tests/pagination.test.ts +++ b/apps/docs/tests/pagination.test.ts @@ -11,6 +11,7 @@ import { insertParityBlanks, lineBreakBoundaries, pageAt, + visiblePageCount, liveSections, pageNumbers, pageStartBlocks, @@ -245,6 +246,29 @@ describe('pageAt', () => { }) }) +describe('visiblePageCount', () => { + // insertParityBlanks puts the zero-height blank before the real page, sharing its start + const withBlank = [ + { start: 0, end: 800, section: 0 }, + { start: 800, end: 800, section: 0 }, + { start: 800, end: 1600, section: 1 }, + { start: 1600, end: 2000, section: 1 }, + ] + + it('counts parity-blank pairs once so NUMPAGES matches the drawn pages', () => { + expect(visiblePageCount(withBlank)).toBe(3) + expect(visiblePageCount([{ start: 0, end: 800, section: 0 }])).toBe(1) + expect(visiblePageCount([])).toBe(0) + }) + + it('maps a physical pageAt index to its visible page number', () => { + // y=900 lands on physical slice 3 (the real page after the blank) = visible page 2 + expect(visiblePageCount(withBlank, pageAt(withBlank, 900))).toBe(2) + expect(visiblePageCount(withBlank, pageAt(withBlank, 0))).toBe(1) + expect(visiblePageCount(withBlank, pageAt(withBlank, 1700))).toBe(3) + }) +}) + describe('pageStartBlocks', () => { it('returns the index of the first block on each non-first page', () => { const blocks = [block(0, 700), block(700, 200), block(900, 100)] diff --git a/apps/docs/tests/shape-draw.test.ts b/apps/docs/tests/shape-draw.test.ts index 72e1740..0eb8829 100644 --- a/apps/docs/tests/shape-draw.test.ts +++ b/apps/docs/tests/shape-draw.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest' import { DEFAULT_SHAPE_EMU, + commitTargetY, drawRectToEmu, resolveDrawRect, } from '../src/renderer/editor/shape-draw' @@ -48,3 +49,20 @@ describe('single-click default', () => { expect(DEFAULT_SHAPE_EMU).toBe(914400) }) }) + +describe('commitTargetY', () => { + const rect = { x: 10, y: 100, w: 200, h: 80 } + + it('anchors regular shapes at the drag rect top', () => { + expect(commitTargetY(rect, 80, false)).toBe(100) + }) + + it('centers the straight-line grab band on the ghost stroke (drag rect vertical center)', () => { + // Ghost drew the level stroke at y=140; the 12px band lands at 140 - 6. + expect(commitTargetY(rect, 12, true)).toBe(134) + }) + + it('centers the band on the click point for click inserts (h=0)', () => { + expect(commitTargetY({ x: 10, y: 100, w: 0, h: 0 }, 12, true)).toBe(94) + }) +}) diff --git a/apps/docs/tests/shape-gallery.test.ts b/apps/docs/tests/shape-gallery.test.ts index c34e112..2e9447d 100644 --- a/apps/docs/tests/shape-gallery.test.ts +++ b/apps/docs/tests/shape-gallery.test.ts @@ -3,10 +3,10 @@ import { describe, expect, it } from 'vitest' import { SHAPE_GALLERY_GROUPS } from '../../../packages/ui/src/shape-gallery' import { shapeBackgroundCss, shapePreviewPathD } from '../src/renderer/editor/shape-svg' -// The docs gallery (ribbon-tabs' DOC_SHAPE_GROUPS) is the shared groups minus Lines -const galleryPrsts = SHAPE_GALLERY_GROUPS.filter( - (group) => group.groupKey !== 'ribbonShapeGroupLines', -).flatMap((group) => group.shapes.map((shape) => shape.prst)) +// The docs gallery (ribbon-tabs' DOC_SHAPE_GROUPS) is the full shared set, Lines included +const galleryPrsts = SHAPE_GALLERY_GROUPS.flatMap((group) => + group.shapes.map((shape) => shape.prst), +) describe('shape gallery', () => { it('every gallery prst has preview and background geometry', () => { diff --git a/apps/docs/tests/shape-insert.test.ts b/apps/docs/tests/shape-insert.test.ts index 45d7f26..86a76f7 100644 --- a/apps/docs/tests/shape-insert.test.ts +++ b/apps/docs/tests/shape-insert.test.ts @@ -267,6 +267,48 @@ describe('shape insertion', () => { editor.destroy() }) + it('inserts a line arrow as a stroke-only connector and round-trips it', async () => { + const { editor, parsed } = await openBlankDoc() + insertShapeAt(editor, 'lineArrow') + + const box = editor.state.doc.lastChild?.attrs.textboxes?.[0] as TextboxDisplay + expect(box.prst).toBe('lineArrow') + expect(box.readOnly).toBe(true) + expect(box.fill).toBeUndefined() + + const plan = pmDocToSavePlan(editor.getJSON() as PmNode, parsed.blocks) + const xmlBlock = plan.saveBlocks.find((b) => b.kind === 'xml') as + { kind: 'xml'; xml: string } | undefined + expect(xmlBlock?.xml).toContain('prst="straightConnector1"') + expect(xmlBlock?.xml).toContain('') + expect(xmlBlock?.xml).toContain('') + + const saved = await saveDocx(parsed, plan.saveBlocks) + const reparsed = await parseDocx(saved) + const block = reparsed.blocks.find((b) => b.textboxes?.length) + expect(block?.textboxes?.[0].prst).toBe('lineArrow') + expect(block?.textboxes?.[0].readOnly).toBe(true) + editor.destroy() + }) + + it('straight lines ignore the drawn height; bent connectors keep it', async () => { + const { editor, parsed } = await openBlankDoc() + insertShapeAt(editor, 'line', { widthEmu: 2700000, heightEmu: 1800000 }) + insertShapeAt(editor, 'lineBent', { widthEmu: 1800000, heightEmu: 1350000 }) + + const plan = pmDocToSavePlan(editor.getJSON() as PmNode, parsed.blocks) + const saved = await saveDocx(parsed, plan.saveBlocks) + const reparsed = await parseDocx(saved) + const boxes = reparsed.blocks.flatMap((b) => b.textboxes ?? []) + const straight = boxes.find((b) => b.prst === 'line') + const bent = boxes.find((b) => b.prst === 'lineBent') + // 114300 EMU = 12 px grab band + expect(straight?.heightPx).toBe(12) + expect(straight?.widthPx).toBe(Math.round(2700000 / 9525)) + expect(bent?.heightPx).toBe(Math.round(1350000 / 9525)) + editor.destroy() + }) + it('moves a shape with its handle and persists the floating position', async () => { const { editor, parsed } = await openBlankDoc() insertShapeAt(editor, 'ellipse') diff --git a/apps/docs/tests/toc-auto-update.test.ts b/apps/docs/tests/toc-auto-update.test.ts new file mode 100644 index 0000000..f037105 --- /dev/null +++ b/apps/docs/tests/toc-auto-update.test.ts @@ -0,0 +1,119 @@ +/** + * TOC page-number persistence: an editor-generated TOC line whose fieldDisplay + * page was auto-refreshed after repagination must save the refreshed number + * (the genXml still carries the number from generation time). + */ +import { describe, expect, it } from 'vitest' +import { Editor } from '@tiptap/core' +import { generateTocFieldXml, parseDocx } from '@genoffice/docx-engine' +import { buildDocx } from '../../../packages/docx-engine/tests/helpers/build-docx' +import { blocksToPmDoc, pmDocToSavePlan, type PmNode } from '../src/renderer/editor/convert' +import { editorExtensions } from '../src/renderer/editor/extensions' +import { applyTocPageDisplays } from '../src/renderer/editor/toc-refresh' + +async function openBlankDoc() { + const source = await buildDocx({ bodyXml: 'Body text' }) + const parsed = await parseDocx(source) + const editor = new Editor({ + element: document.createElement('div'), + extensions: editorExtensions, + content: blocksToPmDoc(parsed.blocks) as never, + }) + return { editor, parsed } +} + +describe('TOC auto page refresh persistence', () => { + it('saves the refreshed page number of a generated TOC line', async () => { + const { editor, parsed } = await openBlankDoc() + const [xml] = generateTocFieldXml([{ level: 1, text: 'Chapter One', pageNo: 1 }]) + editor.commands.insertContentAt(0, { + type: 'docProtected', + attrs: { + docxIndex: null, + blockType: 'passthrough', + label: 'TOC line', + genXml: xml, + // repagination auto-refresh moved the heading to page 3 + fieldDisplay: { kind: 'tocLine', left: 'Chapter One', right: '3', level: 1 }, + }, + }) + const plan = pmDocToSavePlan(editor.getJSON() as PmNode, parsed.blocks) + const xmlBlock = plan.saveBlocks.find( + (b) => b.kind === 'xml' && b.xml.includes('Chapter One'), + ) as { xml: string } | undefined + expect(xmlBlock).toBeDefined() + expect(xmlBlock!.xml).toMatch(/]*>3<\/w:t>/) + expect(xmlBlock!.xml).not.toMatch(/]*>1<\/w:t>/) + }) +}) + +function tocLineNode(title: string, right = '') { + return { + type: 'docProtected', + attrs: { + docxIndex: null, + blockType: 'passthrough', + label: 'TOC line', + genXml: generateTocFieldXml([{ level: 1, text: title, pageNo: 1 }])[0], + fieldDisplay: { kind: 'tocLine', left: title, right, level: 1 }, + }, + } +} + +function tocRights(editor: Editor): string[] { + const rights: string[] = [] + editor.state.doc.forEach((node) => { + const field = node.attrs.fieldDisplay as { kind?: string; right?: string } | null + if (field?.kind === 'tocLine') rights.push(field.right ?? '') + }) + return rights +} + +describe('applyTocPageDisplays', () => { + it('keeps the formatted page string (no Arabic coercion)', async () => { + const { editor } = await openBlankDoc() + editor.commands.insertContentAt(0, tocLineNode('Preface', '2')) + const tr = editor.state.tr + const changed = applyTocPageDisplays( + editor.state.doc, + tr, + [{ text: 'Preface', level: 1, pos: 0 }], + ['iv'], + ) + expect(changed).toBe(true) + editor.view.dispatch(tr) + expect(tocRights(editor)).toEqual(['iv']) + }) + + it('assigns duplicate titles their own pages in document order', async () => { + const { editor } = await openBlankDoc() + editor.commands.insertContentAt(0, [ + tocLineNode('Summary', '1'), + tocLineNode('Summary', '1'), + ] as never) + const headings = [ + { text: 'Summary', level: 1, pos: 0 }, + { text: 'Summary', level: 1, pos: 10 }, + ] + const tr = editor.state.tr + expect(applyTocPageDisplays(editor.state.doc, tr, headings, ['2', '7'])).toBe(true) + editor.view.dispatch(tr) + expect(tocRights(editor)).toEqual(['2', '7']) + }) + + it('skips unmeasured headings and reports no change when values match', async () => { + const { editor } = await openBlankDoc() + editor.commands.insertContentAt(0, tocLineNode('Chapter', '5')) + const tr = editor.state.tr + const changed = applyTocPageDisplays( + editor.state.doc, + tr, + [ + { text: 'Chapter', level: 1, pos: 0 }, + { text: 'Chapter', level: 1, pos: 10 }, + ], + [undefined, '5'], + ) + expect(changed).toBe(false) + }) +}) diff --git a/apps/markdown/electron.vite.config.ts b/apps/markdown/electron.vite.config.ts new file mode 100644 index 0000000..b409449 --- /dev/null +++ b/apps/markdown/electron.vite.config.ts @@ -0,0 +1,37 @@ +import react from '@vitejs/plugin-react' +import { defineConfig, externalizeDepsPlugin } from 'electron-vite' + +// npm hoists some @tiptap packages to the repo root (shared with docs at a +// different version) and nests others under this app — dedupe forces every +// import onto this app's single copy so the bundle never carries two cores. +const TIPTAP_DEDUPE = [ + '@tiptap/core', + '@tiptap/pm', + '@tiptap/react', + '@tiptap/extensions', + '@tiptap/extension-list', + '@tiptap/extension-table', + '@tiptap/extension-image', + '@tiptap/suggestion', + '@tiptap/markdown', + '@tiptap/extension-highlight', + '@tiptap/extension-code-block', +] + +export default defineConfig({ + // @genoffice/i18n and @genoffice/electron-utils ship as TS source — must be bundled + main: { + plugins: [externalizeDepsPlugin({ exclude: ['@genoffice/i18n', '@genoffice/electron-utils'] })], + }, + preload: { + plugins: [externalizeDepsPlugin({ exclude: ['@genoffice/i18n'] })], + }, + renderer: { + plugins: [react()], + resolve: { dedupe: TIPTAP_DEDUPE }, + server: { + port: Number(process.env.MARKDOWN_DEV_PORT) || 5177, + strictPort: Boolean(process.env.MARKDOWN_DEV_PORT), + }, + }, +}) diff --git a/apps/markdown/package.json b/apps/markdown/package.json new file mode 100644 index 0000000..f7f5857 --- /dev/null +++ b/apps/markdown/package.json @@ -0,0 +1,49 @@ +{ + "name": "@genoffice/markdown", + "productName": "GenOffice Markdown", + "version": "0.1.0", + "license": "Apache-2.0", + "private": true, + "description": "Markdown editor module: TipTap block editor over plain .md files, hosted by shell tabs", + "main": "out/main/index.js", + "scripts": { + "dev": "electron-vite dev", + "dev:renderer": "vite --config vite.renderer.config.ts", + "build": "electron-vite build", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@genoffice/agent-core": "*", + "@genoffice/ai-provider": "*", + "@genoffice/docx-engine": "*", + "@genoffice/electron-utils": "*", + "@genoffice/i18n": "*", + "@genoffice/project-store": "*", + "@genoffice/ui": "*", + "@tiptap/core": "3.29.2", + "@tiptap/extension-code-block": "3.29.2", + "@tiptap/extension-highlight": "3.29.2", + "@tiptap/extension-image": "3.29.2", + "@tiptap/extension-list": "3.29.2", + "@tiptap/extension-table": "3.29.2", + "@tiptap/extensions": "3.29.2", + "@tiptap/markdown": "3.29.2", + "@tiptap/pm": "3.29.2", + "@tiptap/react": "3.29.2", + "@tiptap/starter-kit": "3.29.2", + "@tiptap/suggestion": "3.29.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.0.4", + "electron": "^43.3.0", + "electron-vite": "^5.0.0", + "jsdom": "^28.0.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "typescript": "^5.9.3", + "vite": "^7.3.1", + "vitest": "^4.1.10" + }, + "author": "GenOffice" +} diff --git a/apps/markdown/src/main/atomic-write.ts b/apps/markdown/src/main/atomic-write.ts new file mode 100644 index 0000000..05a2c69 --- /dev/null +++ b/apps/markdown/src/main/atomic-write.ts @@ -0,0 +1,46 @@ +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. + * Mirrors apps/docs/src/main/atomic-write.ts (candidate for electron-utils). + */ +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 + } +} diff --git a/apps/markdown/src/main/index.ts b/apps/markdown/src/main/index.ts new file mode 100644 index 0000000..cb55837 --- /dev/null +++ b/apps/markdown/src/main/index.ts @@ -0,0 +1,3 @@ +import { startMarkdownStandalone } from './markdown-main' + +startMarkdownStandalone() diff --git a/apps/markdown/src/main/markdown-main.ts b/apps/markdown/src/main/markdown-main.ts new file mode 100644 index 0000000..8a36b8d --- /dev/null +++ b/apps/markdown/src/main/markdown-main.ts @@ -0,0 +1,773 @@ +import { existsSync } from 'node:fs' +import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, extname, join, resolve, sep } from 'node:path' +import { pathToFileURL } from 'node:url' +import { + BrowserWindow, + WebContentsView, + app, + dialog, + ipcMain, + net, + protocol, + shell, +} from 'electron' +import type { WebContents } from 'electron' +import { + contextMenuLabels, + installContextMenu, + installNavigationGuard, + safeExternalUrl, + showOpenDialogWithMemory, + showSaveDialogWithMemory, +} from '@genoffice/electron-utils' +import { createI18n, getUiLang } from '@genoffice/i18n' +import { atomicWriteFile } from './atomic-write' +import { MARKDOWN_CHANNELS } from '../shared/ipc' +import type { + ExportDocxRequest, + ExportFormat, + ExportPdfRequest, + ExportResult, + ImageData, + SaveMarkdownRequest, + SaveMarkdownResult, + SaveMode, +} from '../shared/ipc' + +const tDlg = createI18n({ + zh: { + dlgSaveTitle: '保存 Markdown 文档', + filterMarkdown: 'Markdown 文档', + dlgPickImage: '选择图片', + filterImages: '图片', + untitledFile: '未命名文档', + closeUnsavedMsg: '此文档有未保存的更改。', + closeUnsavedDetail: '关闭前是否保存?', + btnSave: '保存', + btnDontSave: '不保存', + btnCancel: '取消', + }, + en: { + dlgSaveTitle: 'Save Markdown Document', + filterMarkdown: 'Markdown Documents', + dlgPickImage: 'Choose an Image', + filterImages: 'Images', + untitledFile: 'Untitled', + closeUnsavedMsg: 'This document has unsaved changes.', + closeUnsavedDetail: 'Do you want to save them before closing?', + btnSave: 'Save', + btnDontSave: "Don't Save", + btnCancel: 'Cancel', + }, + ja: { + dlgSaveTitle: 'Markdown ドキュメントを保存', + filterMarkdown: 'Markdown ドキュメント', + dlgPickImage: '画像を選択', + filterImages: '画像', + untitledFile: '無題', + closeUnsavedMsg: 'このドキュメントに未保存の変更があります。', + closeUnsavedDetail: '閉じる前に保存しますか?', + btnSave: '保存', + btnDontSave: '保存しない', + btnCancel: 'キャンセル', + }, + ko: { + dlgSaveTitle: 'Markdown 문서 저장', + filterMarkdown: 'Markdown 문서', + dlgPickImage: '이미지 선택', + filterImages: '이미지', + untitledFile: '제목 없음', + closeUnsavedMsg: '이 문서에 저장하지 않은 변경 사항이 있습니다.', + closeUnsavedDetail: '닫기 전에 저장하시겠습니까?', + btnSave: '저장', + btnDontSave: '저장 안 함', + btnCancel: '취소', + }, + fr: { + dlgSaveTitle: 'Enregistrer le document Markdown', + filterMarkdown: 'Documents Markdown', + dlgPickImage: 'Choisir une image', + filterImages: 'Images', + untitledFile: 'Sans titre', + closeUnsavedMsg: 'Ce document contient des modifications non enregistrées.', + closeUnsavedDetail: 'Voulez-vous les enregistrer avant de fermer ?', + btnSave: 'Enregistrer', + btnDontSave: 'Ne pas enregistrer', + btnCancel: 'Annuler', + }, + de: { + dlgSaveTitle: 'Markdown-Dokument speichern', + filterMarkdown: 'Markdown-Dokumente', + dlgPickImage: 'Bild auswählen', + filterImages: 'Bilder', + untitledFile: 'Unbenannt', + closeUnsavedMsg: 'Dieses Dokument enthält ungespeicherte Änderungen.', + closeUnsavedDetail: 'Vor dem Schließen speichern?', + btnSave: 'Speichern', + btnDontSave: 'Nicht speichern', + btnCancel: 'Abbrechen', + }, + es: { + dlgSaveTitle: 'Guardar documento Markdown', + filterMarkdown: 'Documentos Markdown', + dlgPickImage: 'Elegir imagen', + filterImages: 'Imágenes', + untitledFile: 'Sin título', + closeUnsavedMsg: 'Este documento tiene cambios sin guardar.', + closeUnsavedDetail: '¿Quieres guardarlos antes de cerrar?', + btnSave: 'Guardar', + btnDontSave: 'No guardar', + btnCancel: 'Cancelar', + }, + th: { + dlgSaveTitle: 'บันทึกเอกสาร Markdown', + filterMarkdown: 'เอกสาร Markdown', + dlgPickImage: 'เลือกรูปภาพ', + filterImages: 'รูปภาพ', + untitledFile: 'ไม่มีชื่อ', + closeUnsavedMsg: 'เอกสารนี้มีการเปลี่ยนแปลงที่ยังไม่ได้บันทึก', + closeUnsavedDetail: 'ต้องการบันทึกก่อนปิดหรือไม่?', + btnSave: 'บันทึก', + btnDontSave: 'ไม่บันทึก', + btnCancel: 'ยกเลิก', + }, + id: { + dlgSaveTitle: 'Simpan dokumen Markdown', + filterMarkdown: 'Dokumen Markdown', + dlgPickImage: 'Pilih gambar', + filterImages: 'Gambar', + untitledFile: 'Tanpa judul', + closeUnsavedMsg: 'Dokumen ini memiliki perubahan yang belum disimpan.', + closeUnsavedDetail: 'Simpan sebelum menutup?', + btnSave: 'Simpan', + btnDontSave: 'Jangan Simpan', + btnCancel: 'Batal', + }, + ru: { + dlgSaveTitle: 'Сохранить документ Markdown', + filterMarkdown: 'Документы Markdown', + dlgPickImage: 'Выберите изображение', + filterImages: 'Изображения', + untitledFile: 'Без названия', + closeUnsavedMsg: 'В этом документе есть несохранённые изменения.', + closeUnsavedDetail: 'Сохранить их перед закрытием?', + btnSave: 'Сохранить', + btnDontSave: 'Не сохранять', + btnCancel: 'Отмена', + }, + ar: { + dlgSaveTitle: 'حفظ مستند Markdown', + filterMarkdown: 'مستندات Markdown', + dlgPickImage: 'اختر صورة', + filterImages: 'صور', + untitledFile: 'بدون عنوان', + closeUnsavedMsg: 'يحتوي هذا المستند على تغييرات غير محفوظة.', + closeUnsavedDetail: 'هل تريد حفظها قبل الإغلاق؟', + btnSave: 'حفظ', + btnDontSave: 'عدم الحفظ', + btnCancel: 'إلغاء', + }, + pt: { + dlgSaveTitle: 'Salvar documento Markdown', + filterMarkdown: 'Documentos Markdown', + dlgPickImage: 'Escolher imagem', + filterImages: 'Imagens', + untitledFile: 'Sem título', + closeUnsavedMsg: 'Este documento tem alterações não salvas.', + closeUnsavedDetail: 'Deseja salvá-las antes de fechar?', + btnSave: 'Salvar', + btnDontSave: 'Não Salvar', + btnCancel: 'Cancelar', + }, + it: { + dlgSaveTitle: 'Salva documento Markdown', + filterMarkdown: 'Documenti Markdown', + dlgPickImage: 'Scegli immagine', + filterImages: 'Immagini', + untitledFile: 'Senza titolo', + closeUnsavedMsg: 'Questo documento contiene modifiche non salvate.', + closeUnsavedDetail: 'Vuoi salvarle prima di chiudere?', + btnSave: 'Salva', + btnDontSave: 'Non salvare', + btnCancel: 'Annulla', + }, + pl: { + dlgSaveTitle: 'Zapisz dokument Markdown', + filterMarkdown: 'Dokumenty Markdown', + dlgPickImage: 'Wybierz obraz', + filterImages: 'Obrazy', + untitledFile: 'Bez tytułu', + closeUnsavedMsg: 'Ten dokument ma niezapisane zmiany.', + closeUnsavedDetail: 'Czy zapisać je przed zamknięciem?', + btnSave: 'Zapisz', + btnDontSave: 'Nie zapisuj', + btnCancel: 'Anuluj', + }, + nl: { + dlgSaveTitle: 'Markdown-document opslaan', + filterMarkdown: 'Markdown-documenten', + dlgPickImage: 'Kies een afbeelding', + filterImages: 'Afbeeldingen', + untitledFile: 'Naamloos', + closeUnsavedMsg: 'Dit document bevat niet-opgeslagen wijzigingen.', + closeUnsavedDetail: 'Wilt u ze opslaan voordat u sluit?', + btnSave: 'Opslaan', + btnDontSave: 'Niet opslaan', + btnCancel: 'Annuleren', + }, + ms: { + dlgSaveTitle: 'Simpan dokumen Markdown', + filterMarkdown: 'Dokumen Markdown', + dlgPickImage: 'Pilih imej', + filterImages: 'Imej', + untitledFile: 'Tanpa tajuk', + closeUnsavedMsg: 'Dokumen ini mempunyai perubahan yang belum disimpan.', + closeUnsavedDetail: 'Simpan sebelum menutup?', + btnSave: 'Simpan', + btnDontSave: 'Jangan Simpan', + btnCancel: 'Batal', + }, + he: { + dlgSaveTitle: 'שמירת מסמך Markdown', + filterMarkdown: 'מסמכי Markdown', + dlgPickImage: 'בחרו תמונה', + filterImages: 'תמונות', + untitledFile: 'ללא שם', + closeUnsavedMsg: 'במסמך הזה יש שינויים שלא נשמרו.', + closeUnsavedDetail: 'האם לשמור אותם לפני הסגירה?', + btnSave: 'שמירה', + btnDontSave: 'אל תשמור', + btnCancel: 'ביטול', + }, + hi: { + dlgSaveTitle: 'Markdown दस्तावेज़ सहेजें', + filterMarkdown: 'Markdown दस्तावेज़', + dlgPickImage: 'छवि चुनें', + filterImages: 'छवियाँ', + untitledFile: 'शीर्षकहीन', + closeUnsavedMsg: 'इस दस्तावेज़ में सहेजे नहीं गए परिवर्तन हैं।', + closeUnsavedDetail: 'क्या बंद करने से पहले उन्हें सहेजना चाहते हैं?', + btnSave: 'सहेजें', + btnDontSave: 'न सहेजें', + btnCancel: 'रद्द करें', + }, + 'zh-TW': { + dlgSaveTitle: '儲存 Markdown 文件', + filterMarkdown: 'Markdown 文件', + dlgPickImage: '選擇圖片', + filterImages: '圖片', + untitledFile: '未命名文件', + closeUnsavedMsg: '此文件有未儲存的變更。', + closeUnsavedDetail: '關閉前是否儲存?', + btnSave: '儲存', + btnDontSave: '不儲存', + btnCancel: '取消', + }, +}) +type DlgKey = + | 'dlgSaveTitle' + | 'filterMarkdown' + | 'dlgPickImage' + | 'filterImages' + | 'untitledFile' + | 'closeUnsavedMsg' + | 'closeUnsavedDetail' + | 'btnSave' + | 'btnDontSave' + | 'btnCancel' +const tm = (key: DlgKey) => tDlg(getUiLang(), key) + +interface RuntimePaths { + preloadPath: string + rendererUrl?: string + rendererFile?: string +} + +let runtime: RuntimePaths = { preloadPath: '' } + +export function configureMarkdownRuntime(paths: RuntimePaths): void { + runtime = paths +} + +/** Open path per view, queued at tab creation; the renderer consumes it after mount. + * Kept until the view is destroyed so a reload (View > Reload) consumes it again. */ +const openPathByWc = new Map() +/** File paths granted to each view — readFile/save only allow these */ +const allowedByWc = new Map>() +/** Current save target per view; absent = untitled document */ +const savePathByWc = new Map() +/** Unsaved-changes flags mirrored from the renderer; drives the save prompt before closing a tab/window */ +const dirtyByWc = new Set() +const closeSaveWaiters = new Map void>() +/** Resolvers for menu-triggered saves, resolved when the renderer's save invoke completes */ +const saveWaiters = new Map void>() + +/** Fired after a save lands on a NEW path (untitled first save / Save As) — the shell syncs tab title, recents, projects */ +let fileSavedHook: ((wc: WebContents, path: string) => void) | null = null + +export function setMarkdownFileSavedHook(hook: (wc: WebContents, path: string) => void): void { + fileSavedHook = hook +} + +/** Fired after a "convert & open in Docs" export — the shell routes the new .docx to a docs tab */ +let docxExportedHook: ((path: string) => void) | null = null + +export function setMarkdownDocxExportedHook(hook: (path: string) => void): void { + docxExportedHook = hook +} + +/** Shell menu export entry: ask the renderer to serialize and run the export flow */ +export function sendMarkdownExportRequest(contents: WebContents, format: ExportFormat): void { + if (!contents.isDestroyed()) contents.send(MARKDOWN_CHANNELS.exportRequest, format) +} + +export function markdownIsDirty(webContentsId: number): boolean { + return dirtyByWc.has(webContentsId) +} + +export function markdownFilePath(webContentsId: number): string | undefined { + return savePathByWc.get(webContentsId) +} + +/** The file was renamed on disk — re-grant the new path and tell the renderer */ +export function markdownFileRenamed(contents: WebContents, oldPath: string, newPath: string): void { + const wcId = contents.id + if (savePathByWc.get(wcId) === oldPath) savePathByWc.set(wcId, newPath) + if (openPathByWc.get(wcId) === oldPath) openPathByWc.set(wcId, newPath) + const allowed = allowedByWc.get(wcId) + if (allowed?.has(oldPath)) allowed.add(newPath) + if (!contents.isDestroyed()) contents.send(MARKDOWN_CHANNELS.fileRenamed, newPath) +} + +/** + * Close guard: true means proceed with closing. Clean → true; dirty → + * Save / Don't Save / Cancel. On Save, ask the renderer to serialize + write + * and await the result; a canceled untitled-save dialog keeps the tab open. + */ +export async function requestMarkdownClose( + contents: WebContents, + parent?: BrowserWindow | null, +): Promise { + if (!dirtyByWc.has(contents.id) || contents.isDestroyed()) return true + const options = { + type: 'warning' as const, + message: tm('closeUnsavedMsg'), + detail: tm('closeUnsavedDetail'), + buttons: [tm('btnSave'), tm('btnDontSave'), tm('btnCancel')], + defaultId: 0, + cancelId: 2, + noLink: true, + } + const { response } = + parent && !parent.isDestroyed() + ? await dialog.showMessageBox(parent, options) + : await dialog.showMessageBox(options) + if (response === 2) return false + if (response === 1) return true + return new Promise((resolve) => { + const timer = setTimeout(() => { + closeSaveWaiters.delete(contents.id) + resolve(false) + }, 120_000) + closeSaveWaiters.set(contents.id, (ok) => { + clearTimeout(timer) + resolve(ok) + }) + contents.send(MARKDOWN_CHANNELS.closeSaveRequest) + }) +} + +/** Menu Save / Save As: ask the renderer to serialize and save; clean views resolve true immediately on plain save */ +export function requestMarkdownSave(contents: WebContents, mode: SaveMode): Promise { + if (contents.isDestroyed()) return Promise.resolve(false) + if (mode === 'save' && !dirtyByWc.has(contents.id) && savePathByWc.has(contents.id)) { + return Promise.resolve(true) + } + return new Promise((resolve) => { + const timer = setTimeout(() => { + saveWaiters.delete(contents.id) + resolve(false) + }, 120_000) + saveWaiters.set(contents.id, (ok) => { + clearTimeout(timer) + resolve(ok) + }) + contents.send(MARKDOWN_CHANNELS.saveRequest, mode) + }) +} + +async function writeTextAtomic(path: string, text: string): Promise { + await atomicWriteFile(path, Buffer.from(text, 'utf8')) +} + +async function resolveSaveTarget( + e: Electron.IpcMainInvokeEvent, + mode: SaveMode, + suggestedName?: string, +): Promise { + const current = savePathByWc.get(e.sender.id) + if (mode === 'save' && current) return current + // AI auto-naming: silent first save of an untitled document + if (mode === 'save' && !current && suggestedName) { + const base = suggestedName + .replace(/[/\\:*?"<>|]/g, '_') + .slice(0, 80) + .trim() + if (base) { + const dir = app.getPath('documents') + let target = join(dir, `${base}.md`) + for (let n = 1; existsSync(target); n++) target = join(dir, `${base}-${n}.md`) + return target + } + } + const win = + BrowserWindow.fromWebContents(e.sender) ?? BrowserWindow.getFocusedWindow() ?? undefined + const defaultPath = current + ? join(dirname(current), basename(current)) + : join(app.getPath('documents'), `${tm('untitledFile')}.md`) + const picked = await showSaveDialogWithMemory(dialog, win, { + title: tm('dlgSaveTitle'), + defaultPath, + filters: [{ name: tm('filterMarkdown'), extensions: ['md', 'markdown'] }], + }) + if (picked.canceled || !picked.filePath) return 'canceled' + return picked.filePath +} + +const DISPLAY_IMAGE_EXTS = new Set([ + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.svg', + '.bmp', + '.avif', +]) + +/** + * Serves authored image paths to the editor DOM. A plain file:// URL is + * blocked whenever the renderer page is served over http (dev server), so the + * renderer resolves images to md-asset:// instead. Only image files inside an + * open document's directory are served. + */ +function registerImageProtocol(): void { + protocol.handle('md-asset', (request) => { + let target: string + try { + target = decodeURIComponent(new URL(request.url).pathname) + } catch { + return new Response(null, { status: 400 }) + } + if (/^\/[a-zA-Z]:\//.test(target)) target = target.slice(1) + target = resolve(target) + if (!DISPLAY_IMAGE_EXTS.has(extname(target).toLowerCase()) || !existsSync(target)) { + return new Response(null, { status: 404 }) + } + const inDocDir = [...new Set([...openPathByWc.values(), ...savePathByWc.values()])].some( + (doc) => { + const dir = resolve(dirname(doc)) + return target === dir || target.startsWith(dir + sep) + }, + ) + if (!inDocDir) return new Response(null, { status: 403 }) + return net.fetch(pathToFileURL(target).toString()) + }) +} + +let ipcRegistered = false + +function registerMarkdownIpc(): void { + if (ipcRegistered) return + ipcRegistered = true + + registerImageProtocol() + + ipcMain.handle(MARKDOWN_CHANNELS.consumePending, (e) => openPathByWc.get(e.sender.id) ?? null) + + ipcMain.handle(MARKDOWN_CHANNELS.readFile, async (e, path: unknown) => { + if (typeof path !== 'string' || !allowedByWc.get(e.sender.id)?.has(path)) { + throw new Error('markdown: path not granted to this view') + } + return await readFile(path, 'utf8') + }) + + ipcMain.handle( + MARKDOWN_CHANNELS.save, + async (e, request: SaveMarkdownRequest): Promise => { + const waiter = saveWaiters.get(e.sender.id) + saveWaiters.delete(e.sender.id) + const done = (result: SaveMarkdownResult): SaveMarkdownResult => { + waiter?.(result.ok && !('canceled' in result)) + return result + } + if (typeof request?.text !== 'string') { + return done({ ok: false, error: 'markdown: bad save request' }) + } + const mode: SaveMode = request.mode === 'saveAs' ? 'saveAs' : 'save' + try { + const suggestedName = + typeof request.suggestedName === 'string' ? request.suggestedName : undefined + const target = await resolveSaveTarget(e, mode, suggestedName) + if (target === 'canceled') return done({ ok: true, canceled: true }) + if (!target) return done({ ok: false, error: 'markdown: no save target' }) + const isNewPath = savePathByWc.get(e.sender.id) !== target + await writeTextAtomic(target, request.text) + savePathByWc.set(e.sender.id, target) + // keep the reload path in sync — a stale openPathByWc would make a + // reloaded renderer load the OLD file and then save it over the new one + openPathByWc.set(e.sender.id, target) + const allowed = allowedByWc.get(e.sender.id) ?? new Set() + allowed.add(target) + allowedByWc.set(e.sender.id, allowed) + dirtyByWc.delete(e.sender.id) + if (isNewPath) fileSavedHook?.(e.sender, target) + return done({ ok: true, path: target }) + } catch (err) { + return done({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }, + ) + + ipcMain.handle(MARKDOWN_CHANNELS.pickImage, async (e): Promise => { + const docPath = savePathByWc.get(e.sender.id) + if (!docPath) return null + const win = + BrowserWindow.fromWebContents(e.sender) ?? BrowserWindow.getFocusedWindow() ?? undefined + const picked = await showOpenDialogWithMemory(dialog, win, { + title: tm('dlgPickImage'), + // only formats readImage/DOCX export can round-trip (docx-engine NewImage mimes) + filters: [{ name: tm('filterImages'), extensions: ['png', 'jpg', 'jpeg', 'gif'] }], + properties: ['openFile'], + }) + const source = picked.filePaths[0] + if (picked.canceled || !source) return null + const assetsDir = join(dirname(docPath), 'assets') + await mkdir(assetsDir, { recursive: true }) + const ext = extname(source) + const base = basename(source, ext).replace(/[/\\:*?"<>|]/g, '_') + let name = `${base}${ext}` + for (let n = 1; existsSync(join(assetsDir, name)); n++) name = `${base}-${n}${ext}` + await copyFile(source, join(assetsDir, name)) + return `assets/${name}` + }) + + ipcMain.handle( + MARKDOWN_CHANNELS.saveImage, + async (e, data: { base64?: unknown; ext?: unknown }): Promise => { + const docPath = savePathByWc.get(e.sender.id) + const ext = String(data?.ext ?? '').toLowerCase() + if (!docPath || typeof data?.base64 !== 'string' || !data.base64) return null + // keep in sync with readImage's MIME map — every authored asset must stay DOCX-exportable + if (!['png', 'jpg', 'jpeg', 'gif'].includes(ext)) return null + const assetsDir = join(dirname(docPath), 'assets') + await mkdir(assetsDir, { recursive: true }) + let name = `image.${ext}` + for (let n = 1; existsSync(join(assetsDir, name)); n++) name = `image-${n}.${ext}` + await writeFile(join(assetsDir, name), Buffer.from(data.base64, 'base64')) + return `assets/${name}` + }, + ) + + const MIME_BY_EXT: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + } + + ipcMain.handle( + MARKDOWN_CHANNELS.readImage, + async (e, src: unknown): Promise => { + const docPath = savePathByWc.get(e.sender.id) + if (!docPath || typeof src !== 'string' || /^[a-z][a-z0-9+.-]*:/i.test(src)) return null + const docDir = resolve(dirname(docPath)) + const target = resolve(docDir, src) + // images must live inside the document's directory (assets/ convention) + if (target !== docDir && !target.startsWith(docDir + sep)) return null + const mime = MIME_BY_EXT[extname(target).toLowerCase()] + if (!mime || !existsSync(target)) return null + try { + return { base64: (await readFile(target)).toString('base64'), mime } + } catch { + return null + } + }, + ) + + ipcMain.handle( + MARKDOWN_CHANNELS.exportDocx, + async (e, request: ExportDocxRequest): Promise => { + if (typeof request?.base64 !== 'string' || !request.base64) { + return { ok: false, error: 'markdown: bad export request' } + } + const safeName = + String(request.suggestedName || tm('untitledFile')) + .replace(/[/\\:*?"<>|]/g, '_') + .slice(0, 80) + .trim() || tm('untitledFile') + try { + const bytes = Buffer.from(request.base64, 'base64') + if (request.mode === 'openInDocs') { + // silent convert next to the .md (untitled documents go to Documents) + const docPath = savePathByWc.get(e.sender.id) + const dir = docPath ? dirname(docPath) : app.getPath('documents') + let target = join(dir, `${safeName}.docx`) + for (let n = 1; existsSync(target); n++) target = join(dir, `${safeName}-${n}.docx`) + await writeFile(target, bytes) + docxExportedHook?.(target) + return { ok: true, path: target } + } + const win = + BrowserWindow.fromWebContents(e.sender) ?? BrowserWindow.getFocusedWindow() ?? undefined + const picked = await showSaveDialogWithMemory(dialog, win, { + defaultPath: `${safeName}.docx`, + filters: [{ name: 'Word', extensions: ['docx'] }], + }) + if (picked.canceled || !picked.filePath) return { ok: true, canceled: true } + await writeFile(picked.filePath, bytes) + return { ok: true, path: picked.filePath } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } + }, + ) + + ipcMain.handle( + MARKDOWN_CHANNELS.exportPdf, + async (e, request: ExportPdfRequest): Promise => { + if (typeof request?.html !== 'string' || !request.html) { + return { ok: false, error: 'markdown: bad export request' } + } + const safeName = + String(request.suggestedName || tm('untitledFile')) + .replace(/[/\\:*?"<>|]/g, '_') + .slice(0, 80) + .trim() || tm('untitledFile') + const win = + BrowserWindow.fromWebContents(e.sender) ?? BrowserWindow.getFocusedWindow() ?? undefined + const picked = await showSaveDialogWithMemory(dialog, win, { + defaultPath: `${safeName}.pdf`, + filters: [{ name: 'PDF', extensions: ['pdf'] }], + }) + if (picked.canceled || !picked.filePath) return { ok: true, canceled: true } + // sheets-style: render the print HTML in a hidden scripting-disabled window + const workDir = await mkdtemp(join(tmpdir(), 'genoffice-md-pdf-')) + const printWin = new BrowserWindow({ + show: false, + webPreferences: { sandbox: true, javascript: false }, + }) + try { + const htmlPath = join(workDir, 'print.html') + await writeFile(htmlPath, request.html, 'utf8') + await printWin.loadFile(htmlPath) + const pdf = await printWin.webContents.printToPDF({ + pageSize: 'A4', + printBackground: true, + margins: { top: 0.6, bottom: 0.6, left: 0.6, right: 0.6 }, + }) + await writeFile(picked.filePath, pdf) + return { ok: true, path: picked.filePath } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } finally { + printWin.destroy() + await rm(workDir, { recursive: true, force: true }) + } + }, + ) + + ipcMain.on(MARKDOWN_CHANNELS.dirtyChanged, (e, dirty: unknown) => { + if (dirty === true) dirtyByWc.add(e.sender.id) + else dirtyByWc.delete(e.sender.id) + }) + + ipcMain.on(MARKDOWN_CHANNELS.closeSaveResult, (e, ok: unknown) => { + const waiter = closeSaveWaiters.get(e.sender.id) + closeSaveWaiters.delete(e.sender.id) + waiter?.(ok === true) + }) + + // safety net for menu saves the renderer declined without invoking save() + // (busy / still loading) — the save handler itself resolves the normal path + ipcMain.on(MARKDOWN_CHANNELS.saveRequestAck, (e, ok: unknown) => { + const waiter = saveWaiters.get(e.sender.id) + saveWaiters.delete(e.sender.id) + waiter?.(ok === true) + }) + + // Language channel shared with other modules; removeHandler tolerates duplicate registration + ipcMain.removeHandler(MARKDOWN_CHANNELS.getLanguage) + ipcMain.handle(MARKDOWN_CHANNELS.getLanguage, () => getUiLang()) +} + +function grantAndTrack(wc: WebContents, openPath?: string | null): void { + const wcId = wc.id + if (openPath && existsSync(openPath)) { + openPathByWc.set(wcId, openPath) + savePathByWc.set(wcId, openPath) + allowedByWc.set(wcId, new Set([openPath])) + } + wc.setWindowOpenHandler(({ url }) => { + const target = safeExternalUrl(url, { allowedProtocols: ['http:', 'https:', 'mailto:'] }) + if (target) void shell.openExternal(target) + return { action: 'deny' } + }) + wc.once('destroyed', () => { + openPathByWc.delete(wcId) + allowedByWc.delete(wcId) + savePathByWc.delete(wcId) + dirtyByWc.delete(wcId) + closeSaveWaiters.get(wcId)?.(false) + closeSaveWaiters.delete(wcId) + saveWaiters.get(wcId)?.(false) + saveWaiters.delete(wcId) + }) +} + +export function createMarkdownView(openPath?: string | null): WebContentsView { + registerMarkdownIpc() + const view = new WebContentsView({ + webPreferences: { + preload: runtime.preloadPath, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }) + grantAndTrack(view.webContents, openPath) + if (runtime.rendererUrl) void view.webContents.loadURL(runtime.rendererUrl) + else if (runtime.rendererFile) void view.webContents.loadFile(runtime.rendererFile) + return view +} + +/** Standalone window mode: `npm run dev -w @genoffice/markdown`, md path passed via argv */ +export function startMarkdownStandalone(): void { + installNavigationGuard(app) + installContextMenu(app, () => contextMenuLabels(getUiLang())) + configureMarkdownRuntime({ + preloadPath: join(__dirname, '../preload/index.js'), + rendererUrl: process.env.ELECTRON_RENDERER_URL, + rendererFile: join(__dirname, '../renderer/index.html'), + }) + void app.whenReady().then(() => { + registerMarkdownIpc() + const win = new BrowserWindow({ + width: 1200, + height: 850, + webPreferences: { + preload: runtime.preloadPath, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }) + const argPath = process.argv.slice(1).find((a) => /\.(md|markdown)$/i.test(a) && existsSync(a)) + grantAndTrack(win.webContents, argPath) + if (runtime.rendererUrl) void win.loadURL(runtime.rendererUrl) + else if (runtime.rendererFile) void win.loadFile(runtime.rendererFile) + }) + app.on('window-all-closed', () => app.quit()) +} diff --git a/apps/markdown/src/preload/index.ts b/apps/markdown/src/preload/index.ts new file mode 100644 index 0000000..d767a15 --- /dev/null +++ b/apps/markdown/src/preload/index.ts @@ -0,0 +1,66 @@ +import { contextBridge, ipcRenderer } from 'electron' +import type { Lang } from '@genoffice/i18n' +import type { AiStreamChunk } from '@genoffice/ai-provider' +import type { ProjectApi } from '@genoffice/project-store' +import { AI_CHANNELS, MARKDOWN_CHANNELS } from '../shared/ipc' +import type { ExportFormat, MarkdownApi, SaveMode } from '../shared/ipc' + +const api: MarkdownApi = { + consumePending: () => ipcRenderer.invoke(MARKDOWN_CHANNELS.consumePending), + readFile: (path) => ipcRenderer.invoke(MARKDOWN_CHANNELS.readFile, path), + save: (request) => ipcRenderer.invoke(MARKDOWN_CHANNELS.save, request), + setDirty: (dirty) => ipcRenderer.send(MARKDOWN_CHANNELS.dirtyChanged, dirty), + onSaveRequest: (handler) => { + const listener = (_e: Electron.IpcRendererEvent, mode: SaveMode) => handler(mode) + ipcRenderer.on(MARKDOWN_CHANNELS.saveRequest, listener) + return () => ipcRenderer.removeListener(MARKDOWN_CHANNELS.saveRequest, listener) + }, + onCloseSaveRequest: (handler) => { + const listener = () => handler() + ipcRenderer.on(MARKDOWN_CHANNELS.closeSaveRequest, listener) + return () => ipcRenderer.removeListener(MARKDOWN_CHANNELS.closeSaveRequest, listener) + }, + sendCloseSaveResult: (ok) => ipcRenderer.send(MARKDOWN_CHANNELS.closeSaveResult, ok), + sendSaveRequestAck: (ok) => ipcRenderer.send(MARKDOWN_CHANNELS.saveRequestAck, ok), + onFileRenamed: (handler) => { + const listener = (_e: Electron.IpcRendererEvent, newPath: string) => handler(newPath) + ipcRenderer.on(MARKDOWN_CHANNELS.fileRenamed, listener) + return () => ipcRenderer.removeListener(MARKDOWN_CHANNELS.fileRenamed, listener) + }, + pickImage: () => ipcRenderer.invoke(MARKDOWN_CHANNELS.pickImage), + saveImage: (data) => ipcRenderer.invoke(MARKDOWN_CHANNELS.saveImage, data), + readImage: (src) => ipcRenderer.invoke(MARKDOWN_CHANNELS.readImage, src), + onExportRequest: (handler) => { + const listener = (_e: Electron.IpcRendererEvent, format: ExportFormat) => handler(format) + ipcRenderer.on(MARKDOWN_CHANNELS.exportRequest, listener) + return () => ipcRenderer.removeListener(MARKDOWN_CHANNELS.exportRequest, listener) + }, + exportDocx: (request) => ipcRenderer.invoke(MARKDOWN_CHANNELS.exportDocx, request), + exportPdf: (request) => ipcRenderer.invoke(MARKDOWN_CHANNELS.exportPdf, request), + getLanguage: () => ipcRenderer.invoke(MARKDOWN_CHANNELS.getLanguage), + onLanguageChanged: (handler) => { + const listener = (_e: Electron.IpcRendererEvent, lang: Lang) => handler(lang) + ipcRenderer.on(MARKDOWN_CHANNELS.languageChanged, listener) + return () => ipcRenderer.removeListener(MARKDOWN_CHANNELS.languageChanged, listener) + }, + getAiSettings: () => ipcRenderer.invoke(AI_CHANNELS.getSettings), + aiStream: (request) => ipcRenderer.invoke(AI_CHANNELS.stream, request), + aiStreamCancel: (requestId) => ipcRenderer.invoke(AI_CHANNELS.streamCancel, requestId), + onAiStream: (handler) => { + const listener = (_e: Electron.IpcRendererEvent, chunk: AiStreamChunk) => handler(chunk) + ipcRenderer.on(AI_CHANNELS.streamChunk, listener) + return () => ipcRenderer.removeListener(AI_CHANNELS.streamChunk, listener) + }, + webSearch: (query, maxResults) => ipcRenderer.invoke(AI_CHANNELS.webSearch, query, maxResults), +} + +/** Chat persistence: the shared project:* handlers are registered once by the shell (docs-main registerProjectIpc) */ +const projectApi: Pick = { + resolveChat: (args) => ipcRenderer.invoke('project:resolveChat', args), + appendChat: (args) => ipcRenderer.invoke('project:appendChat', args), + loadChat: (args) => ipcRenderer.invoke('project:loadChat', args), + rebindChat: (args) => ipcRenderer.invoke('project:rebindChat', args), +} + +contextBridge.exposeInMainWorld('markdownApi', api) +contextBridge.exposeInMainWorld('projectApi', projectApi) diff --git a/apps/markdown/src/renderer/App.tsx b/apps/markdown/src/renderer/App.tsx new file mode 100644 index 0000000..cdfdf97 --- /dev/null +++ b/apps/markdown/src/renderer/App.tsx @@ -0,0 +1,395 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { EditorContent, useEditor } from '@tiptap/react' +import type { Editor } from '@tiptap/core' +import { useI18n } from './i18n/locale' +import { + buildFrontmatterRaw, + frontmatterInner, + parseDocText, + serializeDocText, + type DocEnvelope, +} from './markdown/docText' +import { buildExtensions } from './editor/extensions' +import { buildSlashItems } from './editor/slashCommand' +import type { SlashController, SlashMenuState } from './editor/slashCommand' +import { setImageBaseDir } from './editor/localImage' +import { Ribbon } from './components/Ribbon' +import { SlashMenu, type SlashMenuHandle } from './components/SlashMenu' +import { TableMenu } from './components/TableMenu' +import { FrontmatterPanel } from './components/FrontmatterPanel' +import { AiPanel, GensparkMark, type AiPreset, type MarkdownAiDeps } from './ai/AiPanel' +import { exportDocxBytes } from './export/docxExport' +import { buildPrintHtml } from './export/printHtml' +import { resolveImageSrc } from './editor/localImage' +import type { ExportFormat, SaveMode } from '../shared/ipc' + +type LoadStatus = 'loading' | 'ready' | 'error' +type SaveState = 'idle' | 'saving' | 'saved' | 'failed' + +const EMPTY_ENVELOPE: DocEnvelope = { + frontmatter: '', + body: '', + eol: '\n', + trailingNewline: true, + bom: false, +} + +function dirOf(path: string): string { + const i = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + return i > 0 ? path.slice(0, i) : path +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = '' + const CHUNK = 0x8000 + for (let i = 0; i < bytes.length; i += CHUNK) { + binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK)) + } + return btoa(binary) +} + +/** Measure a document image via the DOM (the editor already displays it) */ +function measureImage(displaySrc: string): Promise<{ width: number; height: number } | null> { + return new Promise((resolvePromise) => { + const img = new Image() + img.onload = () => resolvePromise({ width: img.naturalWidth, height: img.naturalHeight }) + img.onerror = () => resolvePromise(null) + img.src = displaySrc + }) +} + +/** widest image that fits the A4 text column */ +const DOCX_MAX_IMAGE_PX = 620 + +/** File name for an AI-generated untitled document: first heading, else first words */ +export function deriveAutoFileName(editor: Editor): string { + const doc = editor.state.doc + for (let i = 0; i < doc.childCount; i++) { + const node = doc.child(i) + const text = node.textContent.replace(/\s+/g, ' ').trim() + if (!text) continue + if (node.type.name === 'heading') return text.slice(0, 60) + return text.split(' ').slice(0, 8).join(' ').slice(0, 60) + } + return '' +} + +export default function App() { + const { t } = useI18n() + const [status, setStatus] = useState('loading') + const [filePath, setFilePath] = useState(null) + const [dirty, setDirty] = useState(false) + const [saveState, setSaveState] = useState('idle') + const [slashState, setSlashState] = useState(null) + const [fmOpen, setFmOpen] = useState(false) + const [fmText, setFmText] = useState('') + const [aiOpen, setAiOpen] = useState(true) + const [aiPreset, setAiPreset] = useState(null) + + const statusRef = useRef('loading') + const dirtyRef = useRef(false) + const savingRef = useRef(false) + const envelopeRef = useRef(EMPTY_ENVELOPE) + const editorRef = useRef(null) + const filePathRef = useRef(null) + const slashMenuRef = useRef(null) + const scrollRef = useRef(null) + + const markDirty = useCallback(() => { + if (statusRef.current !== 'ready' || dirtyRef.current) return + dirtyRef.current = true + setDirty(true) + setSaveState('idle') + window.markdownApi.setDirty(true) + }, []) + + const insertImage = useCallback(() => { + void (async () => { + const relPath = await window.markdownApi.pickImage() + const current = editorRef.current + if (relPath && current) current.chain().focus().setImage({ src: relPath }).run() + })() + }, []) + + const extensions = useMemo(() => { + const controller: SlashController = { + onOpen: setSlashState, + onUpdate: setSlashState, + onKeyDown: (event) => slashMenuRef.current?.handleKey(event) ?? false, + onClose: () => setSlashState(null), + } + return buildExtensions({ + slashController: controller, + slashItems: () => + buildSlashItems({ insertImage: filePathRef.current ? insertImage : undefined }), + }) + }, [insertImage]) + + const editor = useEditor({ + extensions, + content: '', + autofocus: true, + editorProps: { attributes: { class: 'doc-editor' } }, + // uiOnly transactions (toggle fold state) never reach the file — not dirty + onUpdate: ({ transaction }) => { + if (!transaction.getMeta('uiOnly')) markDirty() + }, + }) + editorRef.current = editor + filePathRef.current = filePath + + useEffect(() => { + setImageBaseDir(filePath ? dirOf(filePath) : null) + }, [filePath]) + + useEffect(() => { + if (!editor) return + let cancelled = false + void (async () => { + try { + const path = await window.markdownApi.consumePending() + if (cancelled) return + if (path) { + const raw = await window.markdownApi.readFile(path) + if (cancelled) return + const envelope = parseDocText(raw) + envelopeRef.current = envelope + setImageBaseDir(dirOf(path)) + // the initial load must not be undoable — Cmd+Z right after opening + // would otherwise blank the document (and Cmd+S overwrite the file) + editor + .chain() + .setMeta('addToHistory', false) + .setContent(envelope.body, { contentType: 'markdown' }) + .run() + setFilePath(path) + const inner = frontmatterInner(envelope.frontmatter) + setFmText(inner) + if (inner) setFmOpen(true) + } else { + envelopeRef.current = { ...EMPTY_ENVELOPE } + } + statusRef.current = 'ready' + setStatus('ready') + } catch (err) { + console.error('[markdown] load failed:', err) + if (!cancelled) { + statusRef.current = 'error' + setStatus('error') + } + } + })() + return () => { + cancelled = true + } + }, [editor]) + + const onFrontmatterChange = useCallback( + (inner: string) => { + setFmText(inner) + envelopeRef.current.frontmatter = buildFrontmatterRaw(inner) + markDirty() + }, + [markDirty], + ) + + /** Serialize and write to disk; false when canceled/failed (caller keeps the tab open) */ + const doSave = useCallback(async (mode: SaveMode, suggestedName?: string): Promise => { + const current = editorRef.current + if (!current || statusRef.current !== 'ready' || savingRef.current) return false + savingRef.current = true + setSaveState('saving') + try { + // edits landing while the write is in flight (AI streaming, fast typing) + // must keep the document dirty — compare doc identity after the await + const docAtSave = current.state.doc + const fmAtSave = envelopeRef.current.frontmatter + const body = current.getMarkdown() + const text = serializeDocText(envelopeRef.current, body) + const result = await window.markdownApi.save({ text, mode, suggestedName }) + if (result.ok && 'path' in result) { + setFilePath(result.path) + const unchanged = + editorRef.current?.state.doc === docAtSave && envelopeRef.current.frontmatter === fmAtSave + if (unchanged) { + dirtyRef.current = false + setDirty(false) + window.markdownApi.setDirty(false) + setSaveState('saved') + } else { + // the main process cleared its dirty flag on write — re-assert it + dirtyRef.current = true + setDirty(true) + window.markdownApi.setDirty(true) + setSaveState('idle') + } + return true + } + setSaveState(result.ok ? 'idle' : 'failed') + return false + } catch (err) { + console.error('[markdown] save failed:', err) + setSaveState('failed') + return false + } finally { + savingRef.current = false + } + }, []) + + const runExport = useCallback(async (format: ExportFormat) => { + const current = editorRef.current + if (!current || statusRef.current !== 'ready') return + const suggestedName = + (filePathRef.current + ? filePathRef.current.replace(/^.*[/\\]/, '').replace(/\.(md|markdown)$/i, '') + : deriveAutoFileName(current)) || 'Untitled' + try { + if (format === 'pdf') { + const html = buildPrintHtml(current.view.dom, suggestedName) + const result = await window.markdownApi.exportPdf({ html, suggestedName }) + if (!result.ok) console.error('[markdown] pdf export failed:', result.error) + return + } + const loadImage = async (src: string) => { + const data = await window.markdownApi.readImage(src) + if (!data) return null + const dims = await measureImage(resolveImageSrc(src)) + let width = dims?.width || 400 + let height = dims?.height || 300 + if (width > DOCX_MAX_IMAGE_PX) { + height = Math.round((height * DOCX_MAX_IMAGE_PX) / width) + width = DOCX_MAX_IMAGE_PX + } + return { base64: data.base64, mime: data.mime, widthPx: width, heightPx: height } + } + const bytes = await exportDocxBytes(current.getJSON(), loadImage) + const result = await window.markdownApi.exportDocx({ + base64: bytesToBase64(bytes), + suggestedName, + mode: format === 'docs' ? 'openInDocs' : 'dialog', + }) + if (!result.ok) console.error('[markdown] docx export failed:', result.error) + } catch (err) { + console.error('[markdown] export failed:', err) + } + }, []) + + useEffect(() => { + const offExport = window.markdownApi.onExportRequest((format) => void runExport(format)) + return offExport + }, [runExport]) + + useEffect(() => { + const offSave = window.markdownApi.onSaveRequest( + (mode) => void doSave(mode).then((ok) => window.markdownApi.sendSaveRequestAck(ok)), + ) + const offClose = window.markdownApi.onCloseSaveRequest(() => { + void doSave('save').then((ok) => window.markdownApi.sendCloseSaveResult(ok)) + }) + const offRenamed = window.markdownApi.onFileRenamed((newPath) => setFilePath(newPath)) + const onKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && !event.altKey && event.key.toLowerCase() === 's') { + event.preventDefault() + void doSave(event.shiftKey ? 'saveAs' : 'save') + } + } + window.addEventListener('keydown', onKeyDown, true) + return () => { + offSave() + offClose() + offRenamed() + window.removeEventListener('keydown', onKeyDown, true) + } + }, [doSave]) + + const aiDeps: MarkdownAiDeps = { + getEditor: () => editorRef.current, + getSnapshot: () => editorRef.current?.getMarkdown() ?? '', + restoreSnapshot: (markdown) => { + const current = editorRef.current + if (!current) return + current.commands.setContent(markdown, { contentType: 'markdown' }) + markDirty() + }, + onRunDone: (mutated) => { + // AI wrote into a never-saved document → name it from the content and save silently + if (!mutated || filePathRef.current || !editorRef.current) return + const name = deriveAutoFileName(editorRef.current) + if (name) void doSave('save', name) + }, + } + + const fileName = filePath ? filePath.replace(/^.*[/\\]/, '') : null + const statusText = + saveState === 'saving' + ? t('saving') + : saveState === 'failed' + ? t('saveFailed') + : dirty + ? t('unsaved') + : saveState === 'saved' + ? t('savedOk') + : '' + + if (status === 'error') { + return ( +
+
{t('loadError')}
+
+ ) + } + + return ( +
+ setFmOpen((v) => !v)} + aiOpen={aiOpen} + onToggleAi={() => setAiOpen((v) => !v)} + onAiPreset={(text) => { + setAiOpen(true) + setAiPreset((prev) => ({ text, nonce: (prev?.nonce ?? 0) + 1 })) + }} + /> + {status === 'loading' &&
{t('loading')}
} +
+
+ {!aiOpen && ( + + )} + {/* mounted only after the file is loaded so chat history resolves against the real path */} + {status === 'ready' && ( + setAiOpen(false)} + /> + )} +
+
+
+ {fmOpen && } + +
+
+
+ setSlashState(null)} /> + +
+ {fileName && {fileName}} + {statusText && {statusText}} +
+
+ ) +} diff --git a/apps/markdown/src/renderer/ai/AiPanel.tsx b/apps/markdown/src/renderer/ai/AiPanel.tsx new file mode 100644 index 0000000..aae1693 --- /dev/null +++ b/apps/markdown/src/renderer/ai/AiPanel.tsx @@ -0,0 +1,840 @@ +import { useEffect, useRef, useState } from 'react' +import type { PointerEvent as ReactPointerEvent, ReactElement, ReactNode } from 'react' +import { AgentLoop, composeSkills } from '@genoffice/agent-core' +import type { AiSettings } from '@genoffice/ai-provider' +import { AiComposer, AiTypingIndicator, Markdown } from '@genoffice/ui' +import type { Editor } from '@tiptap/core' +import { aiLangDirective, t as tGlobal, useI18n } from '../i18n/locale' +import sendEnterOn from '../assets/send-enter-on.png' +import sendEnterOff from '../assets/send-enter-off.png' +import sendStop from '../assets/send-stop.png' +import { clearAiHighlights } from '../editor/aiHighlight' +import { createMarkdownSkill } from './markdown-skill' +import { createSearchSkill } from './search-skill' +import { createElectronTransport } from './transport' + +const PANEL_WIDTH_KEY = 'markdown-ai-panel-width' +const PANEL_WIDTH_DEFAULT = 360 +const PANEL_WIDTH_MIN = 280 +const MAX_TURNS = 50 +const MAX_SNAPSHOTS = 20 +const TOOL_OUTPUT_MAX_CHARS = 2000 + +function clampPanelWidth(w: number): number { + return Math.min(Math.max(w, PANEL_WIDTH_MIN), Math.min(720, Math.round(window.innerWidth * 0.6))) +} + +function loadPanelWidth(): number { + const saved = Number(localStorage.getItem(PANEL_WIDTH_KEY)) + return Number.isFinite(saved) && saved > 0 ? clampPanelWidth(saved) : PANEL_WIDTH_DEFAULT +} + +interface ToolActivity { + name: string + summary: string + /** still executing: rendered as a spinner chip, replaced in place when the tool finishes */ + running?: boolean + isError?: boolean + output?: string +} + +interface ChatEntry { + role: 'user' | 'assistant' + text: string + streaming?: boolean + isError?: boolean + /** the run failed and this user message was rolled back out of the model context */ + undelivered?: boolean + tools?: ToolActivity[] +} + +interface Snapshot { + label: string + time: string + markdown: string +} + +/** Ribbon preset instruction; a new nonce triggers one auto-send */ +export interface AiPreset { + text: string + nonce: number +} + +export interface MarkdownAiDeps { + getEditor(): Editor | null + /** full document body as markdown, for pre-mutation snapshots */ + getSnapshot(): string + /** rollback: replace the document with a snapshot */ + restoreSnapshot(markdown: string): void + /** fired when a run with at least one mutation finishes (auto-save hook) */ + onRunDone(mutated: boolean): void +} + +export function AiPanel({ + deps, + filePath, + preset, + onCollapse, +}: { + deps: MarkdownAiDeps + filePath: string | null + preset?: AiPreset | null + onCollapse: () => void +}): ReactElement { + const { lang, t } = useI18n() + const [chat, setChat] = useState([]) + const [prompt, setPrompt] = useState('') + const [busy, setBusy] = useState(false) + const [copiedIdx, setCopiedIdx] = useState(null) + const [snapshots, setSnapshots] = useState([]) + const chatRef = useRef(null) + const inputRef = useRef(null) + const stickToBottomRef = useRef(true) + const [panelWidth, setPanelWidth] = useState(loadPanelWidth) + const [resizing, setResizing] = useState(false) + const asideRef = useRef(null) + + useEffect(() => { + const dock = asideRef.current?.closest('.ai-dock') as HTMLElement | null + dock?.style.setProperty('--ai-panel-width', `${panelWidth}px`) + }, [panelWidth]) + + const settingsRef = useRef(null) + const langRef = useRef(lang) + langRef.current = lang + const depsRef = useRef(deps) + depsRef.current = deps + const filePathRef = useRef(filePath) + /** instruction of the in-flight run, labels its rollback snapshot */ + const runInstructionRef = useRef('') + const runMutatedRef = useRef(false) + /** tool activity of the whole run, for transcript persistence */ + const runToolsRef = useRef([]) + const chatIdsRef = useRef<{ projectId: string; chatId: string } | null>(null) + /** messages sent before resolveChat returned, flushed once the chat id is known */ + const pendingPersistRef = useRef< + Array<{ role: 'user' | 'assistant'; text: string; tools?: ToolActivity[] }> + >([]) + + const patchLast = (patch: Partial | ((last: ChatEntry) => Partial)) => { + setChat((prev) => { + const next = [...prev] + const last = next[next.length - 1] + if (!last || last.role !== 'assistant') return prev + next[next.length - 1] = { ...last, ...(typeof patch === 'function' ? patch(last) : patch) } + return next + }) + } + + const persistMessage = (role: 'user' | 'assistant', text: string, tools?: ToolActivity[]) => { + const ids = chatIdsRef.current + if (!window.projectApi) return + if (!ids) { + pendingPersistRef.current.push({ role, text, tools }) + return + } + void window.projectApi + .appendChat({ + projectId: ids.projectId, + chatId: ids.chatId, + role, + text, + ...(tools && tools.length > 0 ? { tools } : {}), + }) + .catch(() => { + /* persistence failures are silent */ + }) + } + + // The loop is built once; every mutable value goes through a ref getter + const loopRef = useRef | null>(null) + if (!loopRef.current) { + loopRef.current = new AgentLoop({ + transport: createElectronTransport(() => settingsRef.current!), + maxTurns: MAX_TURNS, + skill: composeSkills('markdown+search', '', [ + createMarkdownSkill(() => depsRef.current.getEditor()), + createSearchSkill(), + ]), + captureSnapshot: () => depsRef.current.getSnapshot(), + systemSuffix: () => aiLangDirective(langRef.current), + events: { + onText: (text) => patchLast({ text }), + onToolStart: (call) => { + // Live "running" chip: replaced in place by onToolExecuted + patchLast((last) => ({ + tools: [ + ...(last.tools ?? []), + { name: call.name, summary: call.name.replace(/[_-]+/g, ' '), running: true }, + ], + })) + }, + onToolExecuted: ({ call, execution, snapshotBefore }) => { + if (execution.mutated) runMutatedRef.current = true + if (snapshotBefore !== undefined) { + const label = runInstructionRef.current.slice(0, 40) + const time = new Date().toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }) + setSnapshots((prev) => + [...prev, { label, time, markdown: snapshotBefore }].slice(-MAX_SNAPSHOTS), + ) + } + const activity: ToolActivity = { + name: call.name, + summary: execution.summary, + isError: execution.isError, + output: execution.output?.slice(0, TOOL_OUTPUT_MAX_CHARS), + } + runToolsRef.current.push(activity) + patchLast((last) => { + // Swap out the running placeholder pushed by onToolStart (parse-fail calls have none) + const tools = [...(last.tools ?? [])] + if (tools.at(-1)?.running) tools.pop() + return { tools: [...tools, activity] } + }) + }, + onTurnEnd: () => { + patchLast({ streaming: false }) + setChat((prev) => [...prev, { role: 'assistant', text: '', streaming: true }]) + }, + onDone: ({ text, cancelled, turnLimit }) => { + const final = turnLimit + ? [text, tGlobal('aiTurnLimit')].filter(Boolean).join('\n\n') + : text || (cancelled ? tGlobal('aiStopped') : '') + patchLast((last) => ({ + streaming: false, + text: final || (last.tools?.length ? last.text : tGlobal('aiNoReply')), + // A stop mid-tool can leave a running placeholder behind — drop it + tools: last.tools?.filter((tl) => !tl.running), + })) + persistMessage('assistant', final, runToolsRef.current) + const editor = depsRef.current.getEditor() + if (editor) clearAiHighlights(editor) + depsRef.current.onRunDone(runMutatedRef.current) + setBusy(false) + }, + onError: (error) => { + setChat((prev) => { + const next = [...prev] + for (let i = next.length - 1; i >= 0; i--) { + const entry = next[i]! + if (entry.role === 'user') { + next[i] = { ...entry, undelivered: true } + break + } + } + const last = next.at(-1) + if (last?.role === 'assistant') { + next[next.length - 1] = { + ...last, + streaming: false, + text: error, + isError: true, + tools: last.tools?.filter((tl) => !tl.running), + } + } + return next + }) + setBusy(false) + }, + }, + }) + } + + // ── chat-history persistence: bind to the file, restore prior transcript ── + useEffect(() => { + const api = window.projectApi + if (!api) return + const tempChatId = `unsaved-${Date.now()}` + void api + .resolveChat({ filePath: filePathRef.current ?? null, tempChatId }) + .then((ids) => { + chatIdsRef.current = ids + for (const msg of pendingPersistRef.current.splice(0)) { + persistMessage(msg.role, msg.text, msg.tools) + } + return api.loadChat({ projectId: ids.projectId, chatId: ids.chatId, limit: 200 }) + }) + .then((msgs) => { + if (msgs.length === 0) return + // the user may have sent a message while history was loading — never + // replace a live transcript (and don't clobber the loop context) + let applied = false + setChat((prev) => { + if (prev.length > 0) return prev + applied = true + return msgs.map((m) => ({ + role: m.role, + text: m.text, + tools: m.tools?.map((tool) => ({ + name: tool.name, + summary: tool.summary, + isError: tool.isError, + output: tool.output ? tool.output.slice(0, TOOL_OUTPUT_MAX_CHARS) : undefined, + })), + })) + }) + if (applied && !loopRef.current?.busy) { + loopRef.current?.restore(msgs.map((m) => ({ role: m.role, text: m.text }))) + } + }) + .catch(() => { + /* history load failures are silent */ + }) + }, []) + + /** after an untitled document's first save, bind the unsaved-* history to the real path */ + useEffect(() => { + filePathRef.current = filePath + const ids = chatIdsRef.current + if (!window.projectApi || !ids || !filePath || !ids.chatId.startsWith('unsaved-')) return + void window.projectApi + .rebindChat({ projectId: ids.projectId, tempChatId: ids.chatId, newFilePath: filePath }) + .then((r) => { + if (r?.chatId) chatIdsRef.current = r + }) + .catch(() => { + /* silent */ + }) + }, [filePath]) + + useEffect(() => { + if (stickToBottomRef.current) { + chatRef.current?.scrollTo({ top: chatRef.current.scrollHeight }) + } + }, [chat, busy]) + + const onChatScroll = (): void => { + const el = chatRef.current + if (!el) return + stickToBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48 + } + + const send = (text: string): void => { + const instruction = text.trim() + const loop = loopRef.current + if (!instruction || !loop || loop.busy) return + stickToBottomRef.current = true + runInstructionRef.current = instruction + runMutatedRef.current = false + runToolsRef.current = [] + setChat((prev) => [ + ...prev, + { role: 'user', text: instruction }, + { role: 'assistant', text: '', streaming: true }, + ]) + setPrompt('') + setBusy(true) + persistMessage('user', instruction) + void (async () => { + try { + settingsRef.current = await window.markdownApi.getAiSettings() + await loop.run(instruction) + } catch (err) { + patchLast({ + streaming: false, + text: err instanceof Error ? err.message : String(err), + isError: true, + }) + setBusy(false) + } + })() + } + + const stop = (): void => loopRef.current?.cancel() + + const retry = (): void => send(runInstructionRef.current) + + // ribbon presets auto-send; while a run is active they land in the composer instead + const presetNonceRef = useRef(0) + useEffect(() => { + if (!preset || preset.nonce === presetNonceRef.current) return + presetNonceRef.current = preset.nonce + if (loopRef.current?.busy) setPrompt(preset.text) + else send(preset.text) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [preset]) + + const copyMessage = (text: string, idx: number): void => { + void navigator.clipboard.writeText(text) + setCopiedIdx(idx) + window.setTimeout(() => setCopiedIdx((cur) => (cur === idx ? null : cur)), 1200) + } + + const rollback = (snapshot: Snapshot): void => { + if (busy) return + depsRef.current.restoreSnapshot(snapshot.markdown) + setSnapshots((prev) => prev.filter((s) => s !== snapshot)) + } + + useEffect(() => { + const onResize = (): void => setPanelWidth((w) => clampPanelWidth(w)) + window.addEventListener('resize', onResize) + return () => window.removeEventListener('resize', onResize) + }, []) + + const resizeCleanupRef = useRef<(() => void) | null>(null) + useEffect(() => () => resizeCleanupRef.current?.(), []) + + /** Drag the right edge to resize: the panel is flush with the window's left edge, so width = clientX */ + const startResize = (e: ReactPointerEvent): void => { + e.preventDefault() + const resizer = e.currentTarget + setResizing(true) + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + const onMove = (ev: PointerEvent): void => { + setPanelWidth(clampPanelWidth(ev.clientX)) + } + let done = false + const cleanup = (): void => { + if (done) return + done = true + resizeCleanupRef.current = null + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', cleanup) + window.removeEventListener('pointercancel', cleanup) + resizer.removeEventListener('lostpointercapture', cleanup) + document.body.style.cursor = '' + document.body.style.userSelect = '' + setResizing(false) + setPanelWidth((w) => { + localStorage.setItem(PANEL_WIDTH_KEY, String(Math.round(w))) + return w + }) + } + resizeCleanupRef.current = cleanup + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', cleanup) + window.addEventListener('pointercancel', cleanup) + resizer.addEventListener('lostpointercapture', cleanup) + resizer.setPointerCapture(e.pointerId) + } + + return ( + + ) +} + +/** Step-row status icons (timeline glyphs, unified with the other apps) */ +function StepIcon({ status }: { status: 'running' | 'done' | 'error' }) { + if (status === 'running') { + return ( + + + + ) + } + if (status === 'error') { + return ( + + + + + ) + } + return ( + + + + + ) +} + +/** Tool activity group (docs parity): auto-opens while tools run, auto-collapses into + * "Worked · N steps" when they finish; a manual toggle always wins */ +function ToolChipList({ tools }: { tools: ToolActivity[] }) { + const { t: tr } = useI18n() + const [expanded, setExpanded] = useState>(new Set()) + const [userOpen, setUserOpen] = useState(null) + + const toggle = (j: number) => { + setExpanded((prev) => { + const next = new Set(prev) + if (next.has(j)) next.delete(j) + else next.add(j) + return next + }) + } + + const anyRunning = tools.some((tool) => tool.running) + const open = userOpen ?? anyRunning + const label = anyRunning ? tr('aiGroupWorking') : tr('aiWorkedSteps', { n: tools.length }) + + return ( +
+ +
+
+ {tools.map((tool, j) => { + const hasOutput = !tool.running && !!tool.output + const isOpen = expanded.has(j) + const stepStatus = tool.running ? 'running' : tool.isError ? 'error' : 'done' + return ( +
+ + + +
+ {hasOutput ? ( + + ) : ( + + {tool.summary} + + )} + {hasOutput && isOpen && ( +
+
+
{tool.output}
+
+
+ )} +
+
+ ) + })} +
+
+
+ ) +} + +function Svg({ children }: { children: ReactNode }): ReactElement { + return ( + + {children} + + ) +} + +function IconNewChat(): ReactElement { + return ( + + + + + ) +} + +function IconCollapse(): ReactElement { + return ( + + + + + + ) +} + +function IconClock(): ReactElement { + return ( + + + + + ) +} + +/** Genspark brand mark, inline for crisp device-resolution rendering */ +export function GensparkMark({ size = 18 }: { size?: number }): React.JSX.Element { + return ( + + + + ) +} diff --git a/apps/markdown/src/renderer/ai/markdown-skill.ts b/apps/markdown/src/renderer/ai/markdown-skill.ts new file mode 100644 index 0000000..dabd062 --- /dev/null +++ b/apps/markdown/src/renderer/ai/markdown-skill.ts @@ -0,0 +1,46 @@ +import type { AgentSkill } from '@genoffice/agent-core' +import type { Editor } from '@tiptap/core' +import { AGENT_TOOLS, buildDocContext, executeTool, markDocSeen } from './tools' + +const AGENT_SYSTEM_PROMPT = [ + 'You are the writing assistant inside GenOffice Markdown, a markdown document editor.', + 'You read and edit the open document through tools that address top-level blocks by 0-based index.', + '', + '## Editing rules', + '- The per-message document state lists every block as `index | type | preview`. Previews are truncated — use read_blocks when you need full text.', + '- Write standard GFM markdown: headings, lists, task lists (`- [ ]`), tables, fenced code blocks, blockquotes, images, horizontal rules.', + '- Two extra fenced-div blocks are available: `:::callout {type="info|tip|warning|danger"}` for admonitions and `:::toggle {summary="Title"}` for collapsible sections, each closed with `:::` on its own line.', + '- Prefer replace_blocks for rewrites and formatting changes; insert_content for additions. Batch related edits into as few calls as possible.', + '- After a mutating call, block indexes change — refresh with get_document_context before more index-based edits.', + '- If a tool reports the document changed under you, refresh the context and re-plan instead of retrying blindly.', + '', + '## Writing a new document', + '- When the document is blank and the user asks for content, write the full document in one insert_content call: start with a single `#` title, use `##` sections, keep paragraphs short.', + '- Use tables for comparisons, task lists for actionable items, callouts for important notes.', + '- Never invent facts or numbers; use web_search when the topic needs current information and attribute sources.', + '', + '## Conversation', + '- Answer questions about the document directly, without editing it.', + '- Keep replies short; the edits themselves are the deliverable. Summarize what you changed in one or two sentences.', +].join('\n') + +export function createMarkdownSkill(getEditor: () => Editor | null): AgentSkill { + return { + id: 'markdown', + systemPrompt: AGENT_SYSTEM_PROMPT, + tools: AGENT_TOOLS, + buildContext: () => { + const editor = getEditor() + if (!editor) return '' + markDocSeen(editor) + return buildDocContext(editor) + }, + executeTool: (call) => { + const editor = getEditor() + if (!editor) { + return { output: 'editor not ready', isError: true, summary: call.name } + } + return executeTool(editor, call) + }, + } +} diff --git a/apps/markdown/src/renderer/ai/search-skill.ts b/apps/markdown/src/renderer/ai/search-skill.ts new file mode 100644 index 0000000..1befbd6 --- /dev/null +++ b/apps/markdown/src/renderer/ai/search-skill.ts @@ -0,0 +1,49 @@ +import type { AgentSkill } from '@genoffice/agent-core' +import { t } from '../i18n/locale' + +const SEARCH_SYSTEM_PROMPT = `## Web search +- When you need up-to-date information, data, or facts beyond the document, use web_search; never fabricate numbers from memory. +- When writing search results into the document, attribute the data source (a link or a source name).` + +/** Web-search AgentSkill (same main-process source as docs/sheets/slides web_search). */ +export function createSearchSkill(): AgentSkill { + return { + id: 'search', + systemPrompt: SEARCH_SYSTEM_PROMPT, + tools: [ + { + name: 'web_search', + description: + 'Search the web for textual information (references/data/facts). Use when you need up-to-date information or are unsure about a fact. Returns titles/links/snippets.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search keywords' }, + maxResults: { type: 'integer', description: 'Maximum number of results, default 6' }, + }, + required: ['query'], + }, + }, + ], + executeTool: async (call) => { + if (call.name !== 'web_search') { + return { output: `Unknown tool: ${call.name}`, isError: true, summary: call.name } + } + const query = String(call.input.query ?? '').trim() + if (!query) { + return { output: 'query must not be empty', isError: true, summary: t('aiToolWebSearch') } + } + const r = await window.markdownApi.webSearch(query, Number(call.input.maxResults) || 6) + const lines: string[] = [] + if (r.answer) lines.push(`Direct answer: ${r.answer}\n`) + r.results.forEach((it, i) => + lines.push(`${i + 1}. ${it.title}\n ${it.url}\n ${it.snippet}`), + ) + return { + output: lines.join('\n') || '(no results)', + mutated: false, + summary: t('aiToolWebSearchDone', { query, count: r.results.length }), + } + }, + } +} diff --git a/apps/markdown/src/renderer/ai/tools.ts b/apps/markdown/src/renderer/ai/tools.ts new file mode 100644 index 0000000..494d9a8 --- /dev/null +++ b/apps/markdown/src/renderer/ai/tools.ts @@ -0,0 +1,302 @@ +import type { Editor, JSONContent } from '@tiptap/core' +import type { Node as PmNode } from '@tiptap/pm/model' +import type { AgentToolCall, AgentToolDef, ToolExecution } from '@genoffice/agent-core' +import { markAiRange } from '../editor/aiHighlight' +import { t } from '../i18n/locale' + +const CONTEXT_MAX_CHARS = 8000 +const PREVIEW_CHARS = 60 +const READ_PAGE_CHARS = 24000 +const SELECTION_MAX_CHARS = 4000 + +const INDEX_CHANGE_NOTICE = + 'Block indexes may have changed; call get_document_context before further index-based edits.' +const STALE_DOC_ERROR = + 'The document changed since you last saw it (the user edited it). Call get_document_context to refresh before editing.' + +// ── staleness guard: index-addressed writes are refused after user edits ── + +const docBaseline = new WeakMap() + +export function markDocSeen(editor: Editor): void { + docBaseline.set(editor, editor.state.doc) +} + +function editedExternally(editor: Editor): boolean { + const seen = docBaseline.get(editor) + return seen !== undefined && seen !== editor.state.doc +} + +// ── document skeleton / serialization helpers ── + +function blockPreview(node: PmNode): string { + const text = node.textContent.replace(/\s+/g, ' ').trim() + return text.length > PREVIEW_CHARS ? `${text.slice(0, PREVIEW_CHARS)}…` : text +} + +function blockLabel(node: PmNode): string { + if (node.type.name === 'heading') return `h${node.attrs.level}` + return node.type.name +} + +/** Serialize a range of top-level blocks back to markdown */ +function serializeBlocks(editor: Editor, from: number, to: number): string { + const content: JSONContent[] = [] + editor.state.doc.forEach((node, _offset, index) => { + if (index >= from && index <= to) content.push(node.toJSON() as JSONContent) + }) + return editor.markdown?.serialize({ type: 'doc', content }) ?? '' +} + +function selectionMarkdown(editor: Editor): string { + const { from, to } = editor.state.selection + if (from === to) return '' + const text = editor.state.doc.textBetween(from, to, '\n') + return text.length > SELECTION_MAX_CHARS ? `${text.slice(0, SELECTION_MAX_CHARS)}…` : text +} + +/** Per-turn context: numbered block skeleton + selection, same shape as the docs agent */ +export function buildDocContext(editor: Editor): string { + const doc = editor.state.doc + const blockCount = doc.childCount + const isBlank = + blockCount === 0 || (blockCount === 1 && doc.firstChild!.textContent.trim() === '') + if (isBlank) { + return ['## Document state', 'The document is currently blank.'].join('\n') + } + const lines: string[] = ['## Document state', `${blockCount} top-level blocks:`, ''] + let used = 0 + for (let i = 0; i < blockCount; i++) { + const node = doc.child(i) + const line = `${i} | ${blockLabel(node)} | ${blockPreview(node)}` + used += line.length + 1 + if (used > CONTEXT_MAX_CHARS) { + lines.push(`… (${blockCount - i} more blocks; use read_blocks to view them)`) + break + } + lines.push(line) + } + const selection = selectionMarkdown(editor) + if (selection) lines.push('', '## User selection', selection) + return lines.join('\n') +} + +// ── tool definitions ── + +export const AGENT_TOOLS: AgentToolDef[] = [ + { + name: 'get_document_context', + description: + 'Refresh the document overview: a numbered list of top-level blocks (index | type | preview) plus the current selection. Call this before index-based edits when in doubt.', + inputSchema: { type: 'object', properties: {}, required: [] }, + }, + { + name: 'read_blocks', + description: + 'Read a range of top-level blocks as markdown. Long output is paged; a notice tells you the offset to continue from.', + inputSchema: { + type: 'object', + properties: { + startIndex: { type: 'integer', description: '0-based index of the first block' }, + endIndex: { type: 'integer', description: '0-based index of the last block (inclusive)' }, + offset: { + type: 'integer', + description: 'Character offset to continue a previously truncated read', + }, + }, + required: ['startIndex', 'endIndex'], + }, + }, + { + name: 'insert_content', + description: + 'Insert new markdown content after a top-level block. Use afterIndex -1 to insert at the very beginning of the document. On a blank document this replaces the empty paragraph.', + inputSchema: { + type: 'object', + properties: { + afterIndex: { + type: 'integer', + description: '0-based block index to insert after; -1 = document start', + }, + markdown: { type: 'string', description: 'Markdown content to insert' }, + }, + required: ['afterIndex', 'markdown'], + }, + }, + { + name: 'replace_blocks', + description: + 'Replace a range of top-level blocks (inclusive) with new markdown content. Use this for rewrites, formatting changes and deletions (empty markdown deletes the range).', + inputSchema: { + type: 'object', + properties: { + startIndex: { type: 'integer', description: '0-based index of the first block' }, + endIndex: { type: 'integer', description: '0-based index of the last block (inclusive)' }, + markdown: { type: 'string', description: 'Replacement markdown; empty string deletes' }, + }, + required: ['startIndex', 'endIndex', 'markdown'], + }, + }, +] + +// ── executor ── + +function fail(output: string, summary: string): ToolExecution { + return { output, isError: true, summary } +} + +function clampIndex(value: unknown, max: number): number | null { + const n = Number(value) + if (!Number.isInteger(n) || n < 0 || n > max) return null + return n +} + +/** Byte offsets of each top-level block: [startPos, endPos] in the current doc */ +function blockRange(doc: PmNode, from: number, to: number): { from: number; to: number } { + let pos = 0 + let start = 0 + let end = 0 + for (let i = 0; i <= to; i++) { + const child = doc.child(i) + if (i === from) start = pos + pos += child.nodeSize + if (i === to) end = pos + } + return { from: start, to: end } +} + +function parseMarkdownToNodes(editor: Editor, markdown: string): PmNode[] { + const json = editor.markdown?.parse(markdown) + const content = json?.content ?? [] + return content.map((c) => editor.schema.nodeFromJSON(c)) +} + +function isBlankDoc(doc: PmNode): boolean { + return doc.childCount === 1 && doc.firstChild!.textContent.trim() === '' +} + +export function executeTool(editor: Editor, call: AgentToolCall): ToolExecution { + const doc = editor.state.doc + const maxIndex = doc.childCount - 1 + + switch (call.name) { + case 'get_document_context': { + markDocSeen(editor) + return { + output: buildDocContext(editor), + mutated: false, + summary: t('aiToolReadDoc'), + } + } + + case 'read_blocks': { + const start = clampIndex(call.input.startIndex, maxIndex) + const end = clampIndex(call.input.endIndex, maxIndex) + if (start === null || end === null || start > end) { + return fail( + `Invalid block range; the document has ${doc.childCount} blocks.`, + t('aiToolReadBlocks'), + ) + } + const full = serializeBlocks(editor, start, end) + const offset = Math.max(0, Number(call.input.offset) || 0) + const page = full.slice(offset, offset + READ_PAGE_CHARS) + const truncated = offset + READ_PAGE_CHARS < full.length + const notice = truncated + ? `\n\n[truncated — continue with offset=${offset + READ_PAGE_CHARS}]` + : '' + return { + output: page + notice, + mutated: false, + summary: t('aiToolReadBlocks'), + } + } + + case 'insert_content': { + if (editedExternally(editor)) return fail(STALE_DOC_ERROR, t('aiToolInsert')) + const markdown = String(call.input.markdown ?? '') + if (!markdown.trim()) return fail('markdown must not be empty', t('aiToolInsert')) + const after = Number(call.input.afterIndex) + if (!Number.isInteger(after) || after < -1 || after > maxIndex) { + return fail( + `afterIndex out of range; the document has ${doc.childCount} blocks.`, + t('aiToolInsert'), + ) + } + let nodes: PmNode[] + try { + nodes = parseMarkdownToNodes(editor, markdown) + } catch (err) { + return fail( + `markdown parse failed: ${err instanceof Error ? err.message : String(err)}`, + t('aiToolInsert'), + ) + } + if (nodes.length === 0) return fail('markdown parsed to no content', t('aiToolInsert')) + + let tr = editor.state.tr + if (isBlankDoc(doc)) { + tr = tr.replaceWith(0, doc.content.size, nodes) + tr = markAiRange(tr, 0, tr.doc.content.size) + } else { + const pos = after === -1 ? 0 : blockRange(doc, after, after).to + const insertedSize = nodes.reduce((s, n) => s + n.nodeSize, 0) + tr = tr.insert(pos, nodes) + tr = markAiRange(tr, pos, pos + insertedSize) + } + editor.view.dispatch(tr) + markDocSeen(editor) + return { + output: `Inserted ${nodes.length} block(s). ${INDEX_CHANGE_NOTICE}`, + mutated: true, + summary: t('aiToolInsertDone', { n: nodes.length }), + } + } + + case 'replace_blocks': { + if (editedExternally(editor)) return fail(STALE_DOC_ERROR, t('aiToolReplace')) + const start = clampIndex(call.input.startIndex, maxIndex) + const end = clampIndex(call.input.endIndex, maxIndex) + if (start === null || end === null || start > end) { + return fail( + `Invalid block range; the document has ${doc.childCount} blocks.`, + t('aiToolReplace'), + ) + } + const markdown = String(call.input.markdown ?? '') + let nodes: PmNode[] + try { + nodes = parseMarkdownToNodes(editor, markdown) + } catch (err) { + return fail( + `markdown parse failed: ${err instanceof Error ? err.message : String(err)}`, + t('aiToolReplace'), + ) + } + const { from, to } = blockRange(doc, start, end) + let tr = editor.state.tr + if (nodes.length === 0) { + // deleting every block is not allowed by the schema — leave one empty paragraph + if (start === 0 && end === maxIndex) { + tr = tr.replaceWith(from, to, editor.schema.nodes.paragraph!.create()) + } else { + tr = tr.delete(from, to) + } + } else { + const insertedSize = nodes.reduce((s, n) => s + n.nodeSize, 0) + tr = tr.replaceWith(from, to, nodes) + tr = markAiRange(tr, from, from + insertedSize) + } + editor.view.dispatch(tr) + markDocSeen(editor) + return { + output: `Replaced blocks ${start}-${end} with ${nodes.length} block(s). ${INDEX_CHANGE_NOTICE}`, + mutated: true, + summary: t('aiToolReplaceDone', { n: end - start + 1 }), + } + } + + default: + return fail(`Unknown tool: ${call.name}`, call.name) + } +} diff --git a/apps/markdown/src/renderer/ai/transport.ts b/apps/markdown/src/renderer/ai/transport.ts new file mode 100644 index 0000000..e517db2 --- /dev/null +++ b/apps/markdown/src/renderer/ai/transport.ts @@ -0,0 +1,16 @@ +import { createIpcTransport, type AgentTransport } from '@genoffice/agent-core' +import type { AiSettings } from '@genoffice/ai-provider' +import { t } from '../i18n/locale' + +/** The shared IPC transport wired to the markdown preload bridge (window.markdownApi). */ +export function createElectronTransport(getSettings: () => AiSettings): AgentTransport { + return createIpcTransport({ + onStream: (listener) => window.markdownApi.onAiStream(listener), + start: (request) => window.markdownApi.aiStream(request), + cancel: (requestId) => void window.markdownApi.aiStreamCancel(requestId), + getSettings, + unknownErrorText: () => t('aiUnknownError'), + timeoutErrorText: () => t('aiTimeoutError'), + creditsErrorText: () => t('aiCreditsExhausted'), + }) +} diff --git a/apps/markdown/src/renderer/assets/send-enter-off.png b/apps/markdown/src/renderer/assets/send-enter-off.png new file mode 100644 index 0000000000000000000000000000000000000000..8009f0b0e52e40501d5ad797a2e4a84624c14ca7 GIT binary patch literal 4247 zcma)Ac{tQ<_y5i?c8%phLlUBgVJQ2^HuhyC%UIG-7`q|cFinPsC`(b+ke*OkNA?UR zk;=aBgPs~Og|de9o9DfL@Bi-~=RWu6-1l{^`#OJ|&*$8C>}<{XdBk}D0N}T@FtKO( z&3}!HgSF~At!A+tcc_JHH~@&s{c9jg`?LSv0EOF|qkx*hQ>!e%?rVfL0)V=Eyhqm{ zEPNuu)Fr|JkBf-DcEblS^Y;zDR$Q0k1popv+PxnG+Ky|7pLhYIe3(_$cr&UPy`V%aFf-TNh!L<iQT)%g!#79e~06I2mAMA(NXOgGAB1ddgB0_bMNghQX?}po7Z&uyZ~9MO)KheHYj9Sda&^dFkpmT;pm+oiL|U1rLcU<% zyPG;>UutvDF5;tT#U7R01z+o>RvJYq(%8kM!x8Un*1pX2`JkzU5i{7A<791CJIGzdAWwqM&Mvfz+K#1HvWZ8G+C#`7&h5UeK8 z*iU~|W;%JA1marA03>Q6ZnXUy_SB z(Etrn+~5|TzsIE_^xU&1elL|%)bra$0d9*5yx*NXgV*3M<*xp1j2jNHB|lWH?r*KD zs~Zz~Im{MR@CUN;ZYHjD@C@oy02xh)gp5=Hs~UKYCR-~D6qAkki|h&-4W0~4x-Ih% z7+Zlf>4;3ya(3j^mmvkbPcH>&(2%vQZq>zt0Ggkle;^|xqgcec3Nn4Am*@&K__#ec zf7{!edObc{pE4M%30+|~G%r^m4fff1#oilTrwCSIO91g{ zAOtj=y6TP)BX}e2$dSWVc|MSBrsHrAlmxx*FM*97_4E-Gcy||;uRpxu_OSy_*(GwT z2uvq7x0-q!@TmU6(kfObCMI-Njfc>Fc-=xdkmp=HHhTL@E11}v224+F{heG44l?V< zC6%iQKoyBE^{m;!BAxA(>0URVTz!%(VJW=wdu8R0Ps(ZY-j*=by|6OBa;|yjo&^5mA^GnQP0+8a?H{*(itA-cHyNt}Z)(>=yRB)3}K==C* zoXWZ%N{s;NcBp?{B`Go;kvCI%BIOD0FIQb~(7LXq2vFzsvh`_Tn=+05{Z9x;z&wdn zf&2rp+Y-lJX~6k9w+`9n5ap@jdug9O)m^>E697{_@KEQ-W2nhQM}9AGcXiL&2sw_P zV?y%EG}&(BWUh?@WTf7CBgv1-Yxse{Y#RBJL~`>R1{mbl|1L%*z6W zUT#AM?+c=~6M@hSk9cm?rE+dCUrQ>$hLEL^+jv|-OG;oq?KFVx1Pj06Qe9I0pW$r? zW;K4ySv2?4+jRWr-oxzKzbJ+p$oR510Xa_3dd7Ex#WDEy`G zo-laYPAvt0cw`Euv)U0N(k4|RcOmPCM*@3`^{nqN+WebFpQ##i z$fNCJLgy&`t~BlH@Mgy$ofC6d#rZ5wcDi%}-|2^Xs$6}-EhacQS7!wJkj-(aq4OSl z3qOA?hJFT3H512HY_J{BIbP6Wz-hQHu8reoah3#$c|L6wcjwqL7$`(0ZS(m0B7N5C zY>+dLsx11AwbaQuKaZ&>LgddSYn5XRrKqFVsQe*gE5=e6?0zn7Ga&m6b%0d23FZEq zav1h+(EN>rw_#c4?Y#+SEbgt7@PYM@wU1fsuiGQQ)X!Z#m#S|&>2TeD zEMG?*56Wr!Hu#6W=+G^bp&b~F65BOblkaTmm;B|Jn)4RPh2fSnn*KX6);&$L=<&x~ABQ_EW;v*vdARi(-9Bm+oq1|Jr$xPJ&iW=f z80w}l*C{Z3s(A%zy={`g)asB6Ix*&xm)P8H!r9XO(|GXXsn6?Yk+j$n&H1M!?1BMb zkz5nBicMMl@xq(-9r{^K+O_#q6GC3r>|;j+&XDTo7r7_IQ5Bmz4dnXk>lz)t3TmF~ z?B>w^rn}o)wC*1n8XNab@RxNcc}7h;qoJ1Qc>puZSGEiyYYi<9K#)YyUBA${U)!|- zV!DyD555>xnU|NNioEX)Hr%P2$gzYiv&6&R*IR`EG!HYSu$Ag?2|eAsvEn#)Uk*t_ zN*ju)4X2$*zU|N}Ufv&E^4JzVep-W?7!@ic>8aNm(zzNwJdQy_r(!b+0|W zwm4j*GbexDH!q9$Tcr$djydabb!y_t%hnSkipZ5o#*3(b!NQEiD>SdPnSJSx(0b#X zOAebu$0Hr*RV`ldg`R9qh+qRr7Rh&?EFC|svX(r}NMz^#ekgTIhE`H~K*qDOlaeFX zcY1T~g@N2GBgxsfr=n775eUKSd?ssbRb4z`C!e8!9WKhbG=VvNDKS42GSiO`8Qm?I z+PBPgzRQFx@uj+K%0^DUI}`*8K^Y%$I@}2!1>$ygw7}CQ2J~t{&^JV~{|uP)_mdbLYAPQ}S*p*oxO-r(Ok+t%Q_xZxi{d#&^5T_f3cOgH zl+qWifiGKN8sy4V`T;NoK%5DR@`dcqj+=r6e0xSlMzVhQ zG$}{gs2zB*0&{OG#P_-tdC1!E$+%4aqmWj2>;))`>$S>m>URd8>yd1;FTicPf&_F2 zLUz`l)ArUE;CX_pav%Y?v9U3c$w?W{;Q9J1>2DzA!F4Z!Q)T)>em11rdaNe6d?vN8 zuTQwu-S*LlF!)S>E=TQ6G@N3)rYgy3h8Uju;|fW3Y)yg>2l_r7wE1D`N2nS{J=rA> zFM^%ejX;g1=_>;=UKRK`z9#<7{e8ma=Us-5vlx$w?>9XwR0Yy)C!8bsRGm(ynDnky zUKoX#mBzmg(E8zC>jl9aK*Kyvp3JMMs9mc7!*RYKw#yeL_AHt~>nw=xljLY1hZe1C zQY)A|U*WlZQu)a+kmr>?ouTT8tMN>#G*C5GUcdc(1z_fv&P#B79ktmRjo96pPu$Hf z-}c#3RR$8)T35d^pK)LH`tU1kiy{LY;1oBk4A8zFi=f>PdT9SMWR_@i9-EnpCTA-g z`sOWxw$1CG!m@yab+$t)_vzTcJIAWS?W5Dv(;$alM*b&|`m-4>q~F9lE2)+d z)`(QNTG*)o1gKNXvyta=l7u{cP&eNu?ni>`5mlQN!{Dlr#SXp41Gr0E_w6SFcD}xW z8eD`+*UfTT1czLSYTUY70OtgvINYFIT)$<)Nw|9qIJG%g>Z$I*0O^xAHW1iuok z!X3fHmo~Fo)I5mQb>i?Y0b%63k-i6otvukfi3nbGXm2|=noVt!_2~5m1o=;{r4Gnd zDSTk8w+c$CJHYI>IXwFVC`YC%@&I;PLPA%_l;Ct$q@{RDW%9K$=E7^(toLNke$z9v zUBw>&ykI=k#v9Er%T#|Nu`2Q8QEXV}$*>h=3vGeq%?cOCrC3L=ZpX_?Dv$~!H8<~sQ6SOvAIDeA*fXa`}zxkNO(tIh>H# zQY?M62No_Vq8L8w*SYIOim1cSRkNtCc*=T!tL3M>m!st z#*JQXQi#5i5{b|JJ#k!h?M8dnPuHU*y`26b)TM2BnMT#Xk(6=aqFbg@b1LiK0I)Q* KHK{>)Cj1{x1@CzP literal 0 HcmV?d00001 diff --git a/apps/markdown/src/renderer/assets/send-enter-on.png b/apps/markdown/src/renderer/assets/send-enter-on.png new file mode 100644 index 0000000000000000000000000000000000000000..83de4da09d01f8ab4194c98a90a5ef5ecb3dc2c5 GIT binary patch literal 3742 zcmaJ^c{~&T|DS!V5V>OR`XH2JNRC3TiDYgQIhQMQMWp5WlnRZq4I%fHWf{60ncU_q z$2K3Mv~p}=?jwHd`}_X={o{SS-mmBTdcB^n$Lsx0wTE36;FID50008kRu+z&cI8ju zIl`%pT^6!94R3_iwI~2U{Pdr~W$mc=2f-EPc-ag<7?N4w2;70Dwx$38G4t5IFNh-_ zkA}KMJB0^C$NJv#2UrFNhWZu~vk@GHq_u^q^BpeMbX&p~H>vJb<>zPSd8T8^J^;=1 zrt>l~B0yTx(b7%Sv~t>l2!X^D<8GSn&kK9YcRktmou`?evs`QT3$5gb||Dgr`|%8jRZ%Kd2#||IF zjkIW45t5JP;ibxHHsAbDrO80$(`;(UCMOJd@(b#z=8(gca!(EbyeX(<09_om8gO6k z%GNo~SUUN<@sfg;RQQJb#{&oJEw;PBXKV%5Iy;0>1{sN-Q?naS+L(A$QzW}Yf6V@r zX|WTvdG-7rY=bEIBhlYrm~D~-*(`{!+>hIDQ%kHqa+_tQ?3W|S(sH$J9=pvn+v?Of zM?39sFl5abUZ^>CuVz0(jyEXB;=BwP9>%Vo;Ddo2GEp35Ai&HVz~&f zS;(cqu=~rf9u-Xn=;L_)^bz&QdEivj4{wkUuJE9#)I#X*97o#612h1MAZs{*glb5s zY%$kVjyM^4w-gXkWqyJZKtX=D<@SNcd;m+Q+IV8sJGJWK6<3}Em~-L%m$KXzSrzr5 zx7PS5E|pJouiXMhteA5E_=3a7r2*yiq;FSb1#`}F=qgK6R+1w1A^ zF^0(d!8Jx=lF6bR<0jQ1PtceAzC(w4wN4~SXl(88w8oa~ebW{-QA^z{z|jN27liS( zNfe~(?q$|{5~4_$@^GsV(7A4cQd^JD=39ajB8a zQ}B2cT-`B|Df>$scy1TCL7}yVDn!UzdKbf_FMkbH&@6-4zedacs{>T_TLcRk=1G%mTa4im5r|J~gLEaEf47*SEKFx+#zWpaq528OU!FI}>w7>(aTo zxrM};>FG1JPQJ9!=;?5pq@L1)vF8bGmn4rr!uY>?_YTwUquH0nT0Gz$vJX)dpjmw$ zbu)b@iMI5FtoEm$DbR^VSYN&s7iU1lp}S=@BE41lrQVm6D-frD#8boGR_#e>!ri}C zmPp%Nc$%M|Z!O&FH9Y>CANwreN4Nsjc!QWEr8cCyy!5>s1K+mRxp=$~oXalNb)7Ah zGrm`yu}FnHO7{lPv1i(%zL>^(&GrCi``{oLHwm*)OxDtzb;@G&@?DMNDiTqRkO#;) z_>i>t`1N(wR|J8xqvonB&j5F@_CBQ73Vi(A&}6P*70p?AJ!_W)ka`0AE91X#%VaJN zPiG}SM`X%KNr)2?5&uJ}U+49@s@)tGSoKV07dA zGA+yc`ubAwd(O~$aI3XL$JdeZcmY|aBwi;p*wtwOmHV%qNd0;cH+FkBel;yJV7;T*vIBQm=2 zSHt2ByH^XljHmU~5O}63^9JMzMjI*nrfN_6Z-VaI`c9Q_U9KOCTGjHu6A~RCwgT=n zwF!T_iBRQb)M`vlPGU-#GreEp(I?>2(ngzUa!oCIdU^!8`!#+LtbYq?YiRs8T3CrF zPO-q*x~1InnCo~Xcz1XG<%+sThP$P!Px~m|mPxUa%tC*cDXWE1M<*#WUl)uor{U4) zYz|F%`})bEYV^55-@f?kp-8Mf{iajQ595g;(BKu*U;`hjdvjkmTD?jYk_BLf4VekJcqNIOn z<4Z5$F&?e{VuW!X8yn9g@_x{KIJ%Ypo$!%+CpV1dU@8s#16lXj z-+39+#p5TH2b2gZuMrUMkL4{MjZLKCqD$a=^`eKXaWgD;Pai_Z5(cN&SrO7y{HBMb z+m|)?gzqCIEUFss9o@cl^GtIVaKDZh4~BR}bfpuDW>Gso@?6S+5mCGzgdY${8ZdTmkfl<1_*#OWRFbu_H{l3m84adip zglk5iMmU(_q*OMq*At8CBQ3uQ2=IDa09KQ$YHLtidL{M$4?74!b7i` zm3Ef@;5hPBNoP5fOQpCy;6#jXkGrmB$yrx1k+PrpWd&0hwQ?(dn?J6vYCM}oHQv)VuD z85}=yZ`+~4oZcl&*?j556;$thY|Z77P1{aBK!*yCav_6YpyIyIU6A9h`y>?`PWC+K zny`;rj_4Bn?E&DV+jn>2XwI9(LBgcr-(ln7TB&5#QAe(zEN4|$8#%&`ud6@TU_EEm`5-ZcR&v{3?Rf-N(s9Q*Nkf`qH9-Tx=C)=L2^ew&_ z{-z_C5F?pvD<4pvd^S`zeo#Z9E+A2O`Pg6Mckr@y+8>s9kZvzPoq9uT=ZoUo7g@hD z)x7HsMU&jTf-?=O;w@(l@}K?UH3>r0UR!H{Jp)D=`JT#np+pd8H5@bAN@R2`^O~rD zuhE0;pd74pN+ziI6>;<5+l5mbq2(@KK{4^-fRt;G3Tukxn@7?Q5djs+w+@~&E}gBu znmoAN4~g2w0GMR)%P;T`w0lTVZ|gef?%(c6P^R_CqBui-0S6z9O3|1fPP}f~4fX0R zP0^WSf_qFMN(#EgRO6>dfcV{Od5j7G($D7feb4k}rs9P5l%{wb08jNpdyCo61M{7+ z5^ezuBe;(iAJTDkQ$!P7(W2ygYS18AzSDyTO95c}Tss@;^Lk?X1dbBaWHs!q|)Qr$c!`a^SRP|2a zZA_^Rew#_Dtg)8`>87&%b5a3^NG=R6_ElQS^(!ATiR_Kyf)FV>HA=ttsAx<5R&Q9t zRi4Nbs3A*KVl15VK!@0LYMkG%4$XLLuNT;HVV!?Bb31sA~0{peBn!Ieb@uf<}Y{mv_GjFSLPrN~Hz14-{B zTdB*2=GSAj#ZhyuIgAhb-3xg-^^v)cWHLbR}@srB( edtbLdP&6@-UVe{HtD5tD1Xx3176db&fBp|gR6jWY literal 0 HcmV?d00001 diff --git a/apps/markdown/src/renderer/assets/send-stop.png b/apps/markdown/src/renderer/assets/send-stop.png new file mode 100644 index 0000000000000000000000000000000000000000..a2e625ddee04a59e0124783ced9a69362797784c GIT binary patch literal 2875 zcmdT``8(8WAO6l}#x@8uWvS>Kb%ur{yJE6c*31|}29X#;7;6|pN0Ls;k`TRFGKNW} zjBT=(Vx~lcCe#teo-8NIJDuxX*ZaPI!uwp;XStu}{^9xD_w&P(;pAW=Euka<0D$xf zTg%g;y7V&;VxoMMFmqE>#KUaeBLD#P>(2n4IQ`2{1}NgR4Gv)RtIUW1^t^?=1pqL! zCAWNFB8-f*a*K2c4Ty~P3HJxAFPsnYDQ4#S0e}SSgrx;O1~e~diBs-T>|REPOGK+R zMJjnEKkqMcEi%?6lFB3Ymq%bu6W+Twf<=&d3PU%pP^ZG&qGxQW4r#BRVGkfWd%*c! z3!oouQ%3EOo2&~!ZU3veIf`R`q^zdh+m(!3Rr~{?(VO^uXDj;HLz`-o31`R6m+kpo z##dJ##Yv^WQ6JTZD%@gMw=CiVOTgr0Gr}&{c&T=5W3|xQ8{E1QWyW!*69@e?=5jp! z^XLEYd9qJ!$ck=3SF@_x(%RCp_A2u4__X4d@Ygqz^Jr}%{cx##N-rf|+k21t!|zI+ z>>XZ>iUrdwJe$44;=@%tXkHM5VR=JY&N8fOe(*LTd3VcgeRe~A)#F# z6$TTfJ0Gr|B%kv0ZvAjBXgzo@d-*i_%WC=QO(I%&gF${E#Y}V_PDo~G`nI^j*F287 zY?2xAn(dOuQhK>_jc3-fM*K8iC1q20&3b@Z`RCWNdb!I+pl8NSe9fT(QCCyvJ|zuy$`dSf882HK_@6oiXSsmK5FE^p ze|@>tnT6C2Q6hOfW-HLB{D}u#X5!!-{arZt?c?YBwZKD9USEpI0c1Lx(YDV%cl4a; zU>^@0rC-DVzws~e?_6Lg)(j+H?p=`FdvLa0Q*iB4k!JW;*gf%MwHqs%GNs(k5Di*6 zwB{+MrB~MYcY&+<0GaSDEr_z@_5k7$&xPJQF&302y_;$hCM!wg=)0q_x!;`xR&)me zPn-`SQWGm3;K;H~aEM#nh))sHZXGtl)Qf*<940G32M)Ajo9U2&gyg7$ug1%eDwgXo zHfiDN*Rr2H_5}HZ0g6g;KttI+3k&pE)eXB;^t41%VD~UR^p)h&I+*wC{`ywKV1#Pt zM@62MBdR`tN}||`MGg#qMT=TgEscV~M@`rasPZYEte{d1T z$$V`Z^FDc|m8MzIIddJ&Ob3tmm5T0A2b8413=uRN z=+3Zf0OWcWc*_$o{dOO=VGfu|KzA<9(f~-J9Eo#yG@}cv#i{aNhl}xi{$Y#una2=W zCWeN*(I=s|{R?o2`y*B|GBWI@rh&e`tfQAU?v))#{VZJ-fDxne7uTn6=@ZF?3>;-&k!T(6es{@y|D*)l8^o4#?UMWgt`ahpkD-uxx^sV;Kl z$FE%u^i=Xi;o4f% zAj8SK_MN=G4O~oRABX1mpJ5Myr3|fUpdN(I_)gIO!Qow7X9b*@$Pb(n)KCuWIVKPk z{XPGuo@|_%fq?-|sm1NMj~^TkXScPXbK{pk!uI7_iqzsaW4RGN%-PxC_Ci@z!&2E4 zN@%E!V5j4-cX&@v57bc2I1IP;?9%!|kxpTYeyTx>+HDqATl>m?xkh!Fnwsi(T6yGs z^qLYscw(j{!j+NePE?%g?06b@_&xD^V$$DFp;AK$)z`i%aF@%767~4$!I%4Z}#D>Z3P-OF1#|@ z6^_I->Ch;#o(a1P2b1UF;h^c8o`<7O^*7a-G(DnG3XB@&`~LD7eS4^KH|TLS@9oDy zelwC>X#7JB@Vdd>v=04otiu<@lXFDXcB2IE>9U#b=A(>;ar~H-D*qhw8<>~dD0rvU z)Dnng>+o`e4>t0;A0f%QuhWwCwdPvO`=whWM14q;Yt~E8!5NOm?}`^lg}G7QE)|gK zZ|IAjJNegx2b-Ut6Cla_BA8z7@2h8yK(?}kn`T4XzAbXJ@-2cU-KgeGau3LihT}JsmJKDjq`9gXXAhcP z(|cJ$5R||;BU;C68_KEayqs+x*9XacTL*{Q9+%sL$%%y6!j45KiNG=vo>u0cLz)IC zo6PZctoYn(RUcyN-%T$^)QMxz4g3c%En;Arz%}z<5_K;+FI+KK9g59)-yfYrn7$B8 zZ$og@Sp?nTmZt_@op%D-b{3k_hROBajwcULY4%v0lwJMOSqxJHOb)*oBfc~Em*(;+ zK-t7FWVFMuIW-~RiKP+o_#LS_X5X+>1+$iXzgs5AX#k|(h&Z&CEnS#|?_kN1ax!K8 zlJxYaidZ#3q3O{1i(ep7h4X1KwFgf$%2#7O-hQ*1TsuV~@{0pZy7b?n8stdwcuJEL zc&lF84)p=&ksGc#iIKRyY0)^O*IdsHyD2Z<{K)UzIx}5ivU2Y5D1TYN9#O(MO8VML zA3LM0>lhr2FspHD44y5bQR=ys_S+IZ<@dgzu)EWp-~PaRlO@l-C_v~ACQ^MFvjnHu z3Vp{=!v+(7W!>##DNmsXTuN&kSOSjl8Ce-mbZ2&x9(DKvj_bcstvQCVgK&FVl|vFL9L NoUn4RWZ+0C{|1noH&g%s literal 0 HcmV?d00001 diff --git a/apps/markdown/src/renderer/components/FrontmatterPanel.tsx b/apps/markdown/src/renderer/components/FrontmatterPanel.tsx new file mode 100644 index 0000000..c319018 --- /dev/null +++ b/apps/markdown/src/renderer/components/FrontmatterPanel.tsx @@ -0,0 +1,32 @@ +import { useI18n } from '../i18n/locale' + +interface Props { + /** inner YAML text, without the --- fences */ + value: string + onChange: (value: string) => void + readOnly?: boolean +} + +/** + * Raw-text properties panel: edits the YAML between the frontmatter fences + * verbatim. No YAML parsing — whatever the user types is what lands in the + * file, so exotic YAML survives untouched. + */ +export function FrontmatterPanel({ value, onChange, readOnly }: Props) { + const { t } = useI18n() + const rows = Math.min(12, Math.max(2, value.split('\n').length)) + return ( +
+
{t('fmProperties')}
+