diff --git a/.gitignore b/.gitignore index 1bb9d27..b86be69 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,8 @@ $RECYCLE.BIN/ .agents/ .agent/ skills/ + +# --- Local Dev Server & Sandbox (Excluded from PR) --- +server.mjs +metadata.json +bun.lock diff --git a/crates/tok-cli/src/main.rs b/crates/tok-cli/src/main.rs index f921d47..47a351f 100644 --- a/crates/tok-cli/src/main.rs +++ b/crates/tok-cli/src/main.rs @@ -76,6 +76,8 @@ struct RawMultiFlowState { template_type: Option, #[serde(default)] flows: HashMap>, + #[serde(default)] + settings: Option, } #[derive(serde::Deserialize)] @@ -90,6 +92,9 @@ struct RawStoryPara { fn convert_multi_flow_state_to_root(raw: RawMultiFlowState) -> DocumentRoot { let title = raw.title.unwrap_or_else(|| "מסמך ללא שם".to_string()); let mut root = DocumentRoot::new(&title); + if let Some(settings) = raw.settings { + root.metadata.document_settings = Some(settings); + } root.sections.clear(); let is_prose = raw.template_type.as_deref() == Some("prose"); @@ -145,6 +150,90 @@ fn convert_multi_flow_state_to_root(raw: RawMultiFlowState) -> DocumentRoot { root } +/// Builds typesetting configuration from document_settings (page size, margins in mm/pt), +/// falling back to standard A4 layout. +fn engine_config_from_root(root: &DocumentRoot) -> TypesettingEngineConfig { + let mut config = TypesettingEngineConfig::default(); + let mm_to_pt = |mm: f64| (mm * 72.0 / 25.4) as f32; + + if let Some(ref settings) = root.metadata.document_settings { + let get_val = |keys: &[&str]| -> Option { + for key in keys { + if let Some(v) = settings.get(*key).and_then(|v| v.as_f64()) { + return Some(v); + } + } + None + }; + + if let Some(w_mm) = get_val(&["pageWidthMm", "page_width_mm"]) { + if w_mm > 10.0 { + config.page_width_pt = mm_to_pt(w_mm); + } + } else if let Some(w_pt) = get_val(&["pageWidthPt", "page_width_pt"]) { + if w_pt > 30.0 { + config.page_width_pt = w_pt as f32; + } + } + + if let Some(h_mm) = get_val(&["pageHeightMm", "page_height_mm"]) { + if h_mm > 10.0 { + config.page_height_pt = mm_to_pt(h_mm); + } + } else if let Some(h_pt) = get_val(&["pageHeightPt", "page_height_pt"]) { + if h_pt > 30.0 { + config.page_height_pt = h_pt as f32; + } + } + + if let Some(top_mm) = get_val(&["marginTopMm", "margin_top_mm"]) { + config.margin_top_pt = mm_to_pt(top_mm); + } else if let Some(top_pt) = get_val(&["marginTopPt", "margin_top_pt"]) { + config.margin_top_pt = top_pt as f32; + } + + if let Some(bot_mm) = get_val(&["marginBottomMm", "margin_bottom_mm"]) { + config.margin_bottom_pt = mm_to_pt(bot_mm); + } else if let Some(bot_pt) = get_val(&["marginBottomPt", "margin_bottom_pt"]) { + config.margin_bottom_pt = bot_pt as f32; + } + + if let Some(in_mm) = get_val(&[ + "marginInsideMm", + "margin_inside_mm", + "marginInnerMm", + "margin_inner_mm", + ]) { + config.margin_inner_pt = mm_to_pt(in_mm); + } else if let Some(in_pt) = get_val(&[ + "marginInsidePt", + "margin_inside_pt", + "marginInnerPt", + "margin_inner_pt", + ]) { + config.margin_inner_pt = in_pt as f32; + } + + if let Some(out_mm) = get_val(&[ + "marginOutsideMm", + "margin_outside_mm", + "marginOuterMm", + "margin_outer_mm", + ]) { + config.margin_outer_pt = mm_to_pt(out_mm); + } else if let Some(out_pt) = get_val(&[ + "marginOutsidePt", + "margin_outside_pt", + "marginOuterPt", + "margin_outer_pt", + ]) { + config.margin_outer_pt = out_pt as f32; + } + } + + config +} + /// Builds the package manifest (title, author, document id) for a parsed document. fn manifest_for_root(root: &DocumentRoot) -> TokManifest { TokManifest { @@ -232,43 +321,13 @@ fn load_document_input( } } -fn engine_config_from_root(root: &DocumentRoot) -> TypesettingEngineConfig { - let mut config = TypesettingEngineConfig::default(); - if let Some(settings) = &root.metadata.document_settings { - let mm_to_pt = |val: Option| val.map(|v| (v * 72.0 / 25.4) as f32); - - if let Some(w) = mm_to_pt(settings.get("pageWidthMm").and_then(|v| v.as_f64())) { - if w > 10.0 { - config.page_width_pt = w; - } - } - if let Some(h) = mm_to_pt(settings.get("pageHeightMm").and_then(|v| v.as_f64())) { - if h > 10.0 { - config.page_height_pt = h; - } - } - if let Some(mt) = mm_to_pt(settings.get("marginTopMm").and_then(|v| v.as_f64())) { - config.margin_top_pt = mt; - } - if let Some(mb) = mm_to_pt(settings.get("marginBottomMm").and_then(|v| v.as_f64())) { - config.margin_bottom_pt = mb; - } - if let Some(mi) = mm_to_pt(settings.get("marginInsideMm").and_then(|v| v.as_f64())) { - config.margin_inner_pt = mi; - } - if let Some(mo) = mm_to_pt(settings.get("marginOutsideMm").and_then(|v| v.as_f64())) { - config.margin_outer_pt = mo; - } - } - config -} - fn handle_render_pdf(input: &str, output: &str) -> Result<(), Box> { println!("[TOK-CLI] Rendering document to Pre-Press PDF: {}", output); let (doc, manifest) = load_document_input(input, 30)?; let start_typeset = Instant::now(); - let engine = TypesettingEngine::new(engine_config_from_root(doc.root())); + let config = engine_config_from_root(doc.root()); + let engine = TypesettingEngine::new(config); let pages = engine.typeset_document(doc.root()); let typeset_dur = start_typeset.elapsed(); println!(" - Typeset {} pages in {:.2?}", pages.len(), typeset_dur); @@ -314,7 +373,8 @@ fn handle_render_html(input: &str, output: &str) -> Result<(), Box Result<(), Box Result<(), Box { isForceClosing = true; - isClosingPromptActive = false; if (!win.isDestroyed()) win.close(); }, 300); } else if (choice.response === 1) { // Discard without saving isForceClosing = true; - isClosingPromptActive = false; win.close(); } else { // Cancel: keep window open @@ -463,7 +501,6 @@ function createWindow(): void { } } catch (err: any) { logger.warn('[MAIN] Exception in window close guard; closing safely', { error: err?.message }); - isClosingPromptActive = false; isForceClosing = true; if (!win.isDestroyed()) win.close(); } @@ -730,7 +767,6 @@ handle('tok:get-plugin-system-status', () => pluginManager.getSystemStatus()); handle('tok:set-plugin-safe-mode', (_, enabled: boolean) => pluginManager.setSafeMode(enabled)); // System Handlers -handle('tok:get-startup-file', () => startupFilePath); handle('tok:open-external', (_, url: string) => openExternalSafely(url)); handle('tok:get-app-info', () => ({ version: getAppVersion(), @@ -775,43 +811,7 @@ handle('tok:show-import-dialog', async (_, options?: { filters?: { name: string; return res.filePaths[0]; }); -function extractDocxXmlFromBuffer(buffer: Buffer): string | null { - try { - if (buffer.length < 30) return null; - if (buffer[0] !== 0x50 || buffer[1] !== 0x4B || buffer[2] !== 0x03 || buffer[3] !== 0x04) return null; - let offset = 0; - while (offset + 30 <= buffer.length) { - const sig = buffer.readUInt32LE(offset); - if (sig !== 0x04034b50) break; - const compressionMethod = buffer.readUInt16LE(offset + 8); - const compressedSize = buffer.readUInt32LE(offset + 18); - const fileNameLen = buffer.readUInt16LE(offset + 26); - const extraFieldLen = buffer.readUInt16LE(offset + 28); - const entryName = buffer.toString('utf-8', offset + 30, offset + 30 + fileNameLen); - const dataStart = offset + 30 + fileNameLen + extraFieldLen; - if (entryName === 'word/document.xml') { - const entryBytes = buffer.subarray(dataStart, dataStart + compressedSize); - if (compressionMethod === 0) { - return entryBytes.toString('utf-8'); - } else if (compressionMethod === 8) { - const zlib = require('zlib'); - return zlib.inflateRawSync(entryBytes).toString('utf-8'); - } - } - offset = dataStart + compressedSize; - } - } catch (err: any) { - logger.warn('[MAIN] Failed to extract docx XML in main process', { error: err?.message }); - } - return null; -} - -handle('tok:decompress-raw', async (_, dataBase64: string) => { - const buf = Buffer.from(requireString(dataBase64, 'dataBase64'), 'base64'); - const zlib = await import('zlib'); - const decompressed = zlib.inflateRawSync(buf); - return decompressed.toString('base64'); -}); +handle('tok:get-startup-file', () => startupFilePath); handle('tok:read-import-file', async (_, filePath: string) => { const sourcePath = requireString(filePath, 'filePath'); @@ -823,19 +823,33 @@ handle('tok:read-import-file', async (_, filePath: string) => { const buffer = await fs.promises.readFile(resolvedPath); const ext = path.extname(resolvedPath).toLowerCase(); const isBinary = ext === '.docx'; - const docxXml = isBinary ? extractDocxXmlFromBuffer(buffer) : undefined; + let docxXml: string | undefined; + if (ext === '.docx') { + try { + const xml = extractDocxXmlFromBuffer(buffer); + if (xml) docxXml = xml; + } catch (err: any) { + logger.warn(`[IPC] Failed to pre-extract docx XML: ${err?.message}`); + } + } logger.info(`[IPC] Import file read successfully: ${resolvedPath} (${stat.size} bytes)`); return { fileName: path.basename(resolvedPath), filePath: resolvedPath, size: stat.size, isBinary, + docxXml, dataBase64: buffer.toString('base64'), - text: isBinary ? undefined : buffer.toString('utf-8'), - docxXml: docxXml || undefined + text: docxXml || (isBinary ? undefined : buffer.toString('utf-8')) }; }); +handle('tok:decompress-raw', async (_, dataBase64: string) => { + const buf = Buffer.from(requireString(dataBase64, 'dataBase64'), 'base64'); + const zlib = await import('node:zlib'); + const decompressed = zlib.inflateRawSync(buf); + return decompressed.toString('base64'); +}); // Window and Application Lifecycle Handlers handle('tok:close-window', () => { @@ -890,25 +904,28 @@ if (process.platform === 'win32') { if (!app.requestSingleInstanceLock()) { app.quit(); } else { + // Capture startup file argument if launched with a file path from OS file association or CLI + startupFilePath = extractFilePathFromArgs(process.argv); + app.on('second-instance', (_event, commandLine) => { const win = mainWindow; if (win && !win.isDestroyed()) { if (win.isMinimized()) win.restore(); if (!win.isVisible()) win.show(); win.focus(); - const targetFile = extractFileFromArgv(commandLine); - if (targetFile) { - win.webContents.send('tok:open-file', targetFile); + const filePath = extractFilePathFromArgs(commandLine); + if (filePath) { + win.webContents.send('tok:event', { action: 'open-file-at-path', data: filePath }); } } }); app.on('open-file', (event, filePath) => { event.preventDefault(); - startupFilePath = filePath; - const win = mainWindow; - if (win && !win.isDestroyed()) { - win.webContents.send('tok:open-file', filePath); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('tok:event', { action: 'open-file-at-path', data: filePath }); + } else { + startupFilePath = filePath; } }); diff --git a/packages/tok-electron/src/preload.ts b/packages/tok-electron/src/preload.ts index a7841e9..32992d0 100644 --- a/packages/tok-electron/src/preload.ts +++ b/packages/tok-electron/src/preload.ts @@ -35,19 +35,19 @@ export interface TokIpcBridge { showSaveDialog: (options?: { defaultPath?: string; filters?: { name: string; extensions: string[] }[] }) => Promise; showOpenDialog: (options?: { filters?: { name: string; extensions: string[] }[] }) => Promise; showImportDialog: (options?: { filters?: { name: string; extensions: string[] }[] }) => Promise; + getStartupFile: () => Promise; readImportFile: (filePath: string) => Promise<{ fileName: string; filePath: string; size: number; isBinary: boolean; + docxXml?: string; dataBase64: string; text?: string; - docxXml?: string; }>; decompressRaw: (dataBase64: string) => Promise; closeWindow: () => Promise; quitApp: () => Promise; - getStartupFile: () => Promise; onOpenFile: (callback: (filePath: string) => void) => () => void; } @@ -154,6 +154,9 @@ const tokIpc: TokIpcBridge = { showImportDialog: async (options?: { filters?: { name: string; extensions: string[] }[] }) => { return await ipcRenderer.invoke('tok:show-import-dialog', options); }, + getStartupFile: async () => { + return await ipcRenderer.invoke('tok:get-startup-file'); + }, readImportFile: async (filePath: string) => { return await ipcRenderer.invoke('tok:read-import-file', filePath); }, @@ -166,9 +169,6 @@ const tokIpc: TokIpcBridge = { quitApp: async () => { return await ipcRenderer.invoke('tok:quit-app'); }, - getStartupFile: async () => { - return await ipcRenderer.invoke('tok:get-startup-file'); - }, onOpenFile: (callback: (filePath: string) => void) => { const handler = (_: Electron.IpcRendererEvent, filePath: string) => callback(filePath); ipcRenderer.on('tok:open-file', handler); diff --git a/packages/tok-ui/src/app.ts b/packages/tok-ui/src/app.ts index e9366ec..926475f 100644 --- a/packages/tok-ui/src/app.ts +++ b/packages/tok-ui/src/app.ts @@ -26,7 +26,6 @@ import { MultiFlowDocumentState, DEFAULT_TALMUD_FLOWS, DEFAULT_PROSE_FLOWS, - getDefaultFlowsForTemplate, getFlowsForTemplate, TemplateType, FlowPaginator @@ -36,7 +35,7 @@ import { documentRootToDocumentState, importResultToDocumentState } from './engine/documentBridge'; -import { importDocument, ImportResult } from './engine/textImporter'; +import { importDocument, parseDocxXml, ImportResult } from './engine/textImporter'; import { layoutToPageDescriptors } from './engine/typesetBridge'; import { toHebrewGematria } from './gematria'; @@ -230,12 +229,12 @@ export class TypesetOkApp { onOpenCommandPalette: () => this.commandPalette.show(), onViewModeChange: (mode) => this.setViewMode(mode), onExportPdf: () => this.handleSystemAction('export-pdf'), - onRenameDocument: (newTitle) => this.renameDocument(newTitle), onOpenProjects: () => this.welcomeModal.show(), onOpenSettings: () => this.settingsModal.show(), onOpenAbout: () => this.aboutModal.show(), onUndo: () => this.runEditCommand('undo'), - onRedo: () => this.runEditCommand('redo') + onRedo: () => this.runEditCommand('redo'), + onRenameDocument: (newTitle) => this.renameDocument(newTitle) }); this.root.appendChild(this.topBar.element); @@ -542,6 +541,13 @@ export class TypesetOkApp { return; } + // F2: Rename Document + if (e.key === 'F2') { + e.preventDefault(); + this.topBar?.startRenaming(); + return; + } + const hasCtrl = e.ctrlKey || e.metaKey; if (!hasCtrl) return; @@ -1134,7 +1140,7 @@ export class TypesetOkApp { this.storyFontFamilySelect = el('select', 'tok-select tok-select-xs', { title: 'גופן מקטע / פיסקה', 'aria-label': 'גופן מקטע / פיסקה', - style: 'height:24px;font-size:11px;padding:0 6px;min-width:130px;max-width:160px;flex-shrink:0;background:var(--tok-bg-surface-2, #1E293B);color:var(--tok-text-primary, #F8FAFC);border:1px solid var(--tok-border-strong, #334155);border-radius:4px;outline:none;' + style: 'height:24px;font-size:11px;padding:0 6px;min-width:90px;max-width:140px;background:var(--tok-bg-surface-2);color:var(--tok-text-primary);border:1px solid var(--tok-border-strong);border-radius:4px;' }) as HTMLSelectElement; for (const font of getAvailableFonts()) { const opt = el('option', undefined, { value: font.family }, font.family) as HTMLOptionElement; @@ -1264,7 +1270,7 @@ export class TypesetOkApp { private buildFindReplaceBar(): HTMLElement { this.findBar = el('div', 'tok-story-find-bar', { - style: 'display:none;padding:6px 10px;background:var(--tok-bg-surface-2, #1E293B);border-bottom:1px solid var(--tok-border-subtle);flex-wrap:wrap;gap:6px;align-items:center;font-size:12px;' + style: 'display:none;padding:6px 10px;background:var(--tok-surface-2);border-bottom:1px solid var(--tok-border-subtle);flex-wrap:wrap;gap:6px;align-items:center;font-size:12px;' }); this.findInput = el('input', 'tok-input', { @@ -1456,9 +1462,9 @@ export class TypesetOkApp { const isItalic = Boolean(fmt.italic); const isUnderline = Boolean(fmt.underline); - this.storyBoldBtn.style.background = isBold ? 'var(--tok-surface-hover)' : ''; - this.storyItalicBtn.style.background = isItalic ? 'var(--tok-surface-hover)' : ''; - this.storyUnderlineBtn.style.background = isUnderline ? 'var(--tok-surface-hover)' : ''; + this.storyBoldBtn.style.background = isBold ? 'var(--tok-bg-surface-hover)' : ''; + this.storyItalicBtn.style.background = isItalic ? 'var(--tok-bg-surface-hover)' : ''; + this.storyUnderlineBtn.style.background = isUnderline ? 'var(--tok-bg-surface-hover)' : ''; this.storyBoldBtn.setAttribute('aria-pressed', String(isBold)); this.storyItalicBtn.setAttribute('aria-pressed', String(isItalic)); this.storyUnderlineBtn.setAttribute('aria-pressed', String(isUnderline)); @@ -1859,9 +1865,6 @@ export class TypesetOkApp { this.activeTypesetAbortController = abortCtrl; const requestId = ++this.latestTypesetRequestId; - if (this.storyEditor && this.activeFlowId) { - this.documentState.flows[this.activeFlowId] = this.storyEditor.getStory(); - } this.statusBar?.setBusy(true, 'מעמד מחדש...'); const win = window as any; let newPages: PageDescriptor[] = []; @@ -1982,15 +1985,19 @@ export class TypesetOkApp { const name = TEMPLATE_TITLES[templateId] || BLANK_TEMPLATE_TITLE; this.documentTitle = name; this.documentState.title = name; - const tType = (templateId in TEMPLATE_TITLES ? templateId : (templateId === 'blank' ? 'blank' : 'gemara')) as TemplateType; - this.documentState.templateType = tType; + this.documentState.templateType = (templateId in TEMPLATE_TITLES ? templateId : 'gemara') as TemplateType; this.currentFilePath = ''; - this.documentState.flows = getDefaultFlowsForTemplate(tType); - this.structureBar.setFlows(getFlowsForTemplate(tType)); - this.activeFlowId = 'gemara'; - this.storyEditor.loadStory(this.documentState.flows.gemara || []); + if (isBlank) { + this.documentState.flows = { gemara: [], rashi: [], tosafot: [], notes: [] }; + } else if (templateId === 'prose') { + this.documentState.flows = JSON.parse(JSON.stringify(DEFAULT_PROSE_FLOWS)); + } else { + this.documentState.flows = JSON.parse(JSON.stringify(DEFAULT_TALMUD_FLOWS)); + } + this.activeFlowId = 'gemara'; + this.storyEditor.loadStory(this.documentState.flows.gemara); this.activePageIndex = 0; this.structureBar.setActivePage(0); this.repaginateAndSync(true); @@ -2009,22 +2016,6 @@ export class TypesetOkApp { } } - public renameDocument(newTitle: string): void { - const clean = newTitle.trim().replace(/\.tok$/i, ''); - if (!clean) return; - this.documentTitle = clean; - this.documentState.title = clean; - this.topBar.setDocumentTitle(clean, true); - this.setDirty(true); - addRecentProject({ - name: clean + '.tok', - path: this.currentFilePath || undefined, - pages: this.pages.length || 1, - lastSavedAt: new Date().toISOString() - }); - this.showToast(i18n.getLanguage() === 'en' ? `Document renamed to "${clean}"` : `שם המסמך שונה ל-"${clean}"`); - } - private async saveDocumentToFile(filePath: string): Promise { this.statusBar?.setBusy(true, 'שומר מסמך...'); try { @@ -2123,9 +2114,6 @@ export class TypesetOkApp { const activeStory = newState.flows[activeKey] || []; this.storyEditor.loadStory(activeStory); - const tType = (docRoot.metadata?.template || newState.templateType || 'gemara') as TemplateType; - this.structureBar.setFlows(getFlowsForTemplate(tType)); - this.activePageIndex = 0; this.structureBar.setActivePage(0); @@ -2146,13 +2134,23 @@ export class TypesetOkApp { }); } + public openAnyFile(filePath: string): void { + const ext = filePath.split('.').pop()?.toLowerCase(); + this.welcomeModal.setHasOpenDocument(true); + this.welcomeModal.hide(); + if (ext === 'docx' || ext === 'md' || ext === 'txt' || ext === 'markdown') { + this.promptAndImportDocument(filePath); + } else { + this.openProjectFile(filePath); + } + } + private openProjectFile(filePath: string): void { const isImportFormat = /\.(docx|txt|md|markdown|rtf)$/i.test(filePath); if (isImportFormat) { this.promptAndImportDocument(filePath); return; } - const win = window as any; const baseName = filePath.split(/[\\/]/).pop() || filePath; @@ -2163,7 +2161,12 @@ export class TypesetOkApp { }) .catch((err: any) => { console.error('[TOK] Failed to open document:', err); - this.showToast(tf('toastOpenError', { error: err?.message ?? String(err) }), true); + const rawMsg = err?.message ?? String(err); + const isZipError = rawMsg.includes('Zip error') || rawMsg.includes('EOCD'); + const errorMsg = isZipError + ? 'הקובץ אינו חבילת פרויקט (.tok) תקינה. אם זהו מסמך Word או Markdown, השתמש ב"ייבוא מסמך".' + : (err?.userMessage || rawMsg); + this.showToast(tf('toastOpenError', { error: errorMsg }), true); }); return; } @@ -2220,19 +2223,19 @@ export class TypesetOkApp { let result: ImportResult; if (win.tokIpc?.readImportFile) { const fileInfo = await win.tokIpc.readImportFile(targetPath); - let content: string | Uint8Array; - if (fileInfo.isBinary) { + if (fileInfo.docxXml) { + result = parseDocxXml(fileInfo.docxXml, { fileName: fileInfo.fileName }); + } else if (fileInfo.isBinary) { const binStr = atob(fileInfo.dataBase64); const len = binStr.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) { bytes[i] = binStr.charCodeAt(i); } - content = bytes; + result = importDocument(bytes, { fileName: fileInfo.fileName }); } else { - content = fileInfo.text || ''; + result = importDocument(fileInfo.text || '', { fileName: fileInfo.fileName }); } - result = importDocument(content, { fileName: fileInfo.fileName, docxXml: fileInfo.docxXml }); } else { throw new Error('IPC reader not available'); } @@ -2296,25 +2299,16 @@ export class TypesetOkApp { this.activeFlowId = 'gemara'; this.storyEditor.loadStory(this.documentState.flows.gemara); this.structureBar.setFlows(getFlowsForTemplate(templateType)); - this.activePageIndex = 0; this.structureBar.setActivePage(0); this.repaginateAndSync(true); - if (filePath && filePath.toLowerCase().endsWith('.tok')) { - addRecentProject({ - name: (this.documentTitle || 'imported') + '.tok', - path: filePath, - pages: this.pages.length || 1, - lastSavedAt: new Date().toISOString() - }); - } else { - addRecentProject({ - name: (this.documentTitle || 'imported') + '.tok', - pages: this.pages.length || 1, - lastSavedAt: new Date().toISOString() - }); - } + addRecentProject({ + name: (this.documentTitle || 'imported') + '.tok', + path: filePath, + pages: this.pages.length || 1, + lastSavedAt: new Date().toISOString() + }); this.showToast(tf('toastImportSuccess', { title: result.title, count: result.paragraphs.length })); } @@ -2416,11 +2410,17 @@ export class TypesetOkApp { case 'open-about': this.aboutModal.show(); break; + case 'open-file-at-path': + if (typeof data === 'string' && data) { + if (!this.confirmDiscardUnsaved()) return; + this.openAnyFile(data); + } + break; case 'open-document': { if (!this.confirmDiscardUnsaved()) return; const win = window as any; if (typeof data === 'string' && data) { - this.openProjectFile(data); + this.openAnyFile(data); } else if (win.tokIpc?.showOpenDialog) { win.tokIpc.showOpenDialog({ filters: [{ name: 'TypesetOK Document', extensions: ['tok', 'json'] }] }) .then((filePath: string | null) => { @@ -2490,6 +2490,9 @@ export class TypesetOkApp { case 'export-pdf': this.openExportDialog(); break; + case 'rename-document': + this.topBar?.startRenaming(); + break; case 'open-text-cleanup': case 'text-cleanup': this.openTextCleanupWizard(); @@ -2600,6 +2603,16 @@ export class TypesetOkApp { }, 3200); } + public renameDocument(newTitle: string): void { + const trimmed = newTitle.trim(); + if (!trimmed || trimmed === this.documentTitle) return; + this.documentTitle = trimmed; + this.documentState.title = trimmed; + this.topBar.setDocumentTitle(trimmed, true); + this.setDirty(true); + this.showToast(tf('toastTitleRenamed', { title: trimmed })); + } + public setDirty(dirty: boolean): void { if (!this.documentState) return; this.documentState.isDirty = dirty; diff --git a/packages/tok-ui/src/components/ImportTemplateModal.ts b/packages/tok-ui/src/components/ImportTemplateModal.ts index 60bbc4d..a4719b4 100644 --- a/packages/tok-ui/src/components/ImportTemplateModal.ts +++ b/packages/tok-ui/src/components/ImportTemplateModal.ts @@ -146,7 +146,11 @@ export class ImportTemplateModal { card.addEventListener('click', () => { this.hide(); - this.callbacks.onSelect(opt.id); + try { + this.callbacks.onSelect(opt.id); + } catch (err) { + console.error('[TOK] Error applying template:', err); + } }); body.appendChild(card); diff --git a/packages/tok-ui/src/components/TopSystemBar.ts b/packages/tok-ui/src/components/TopSystemBar.ts index 5ae412c..1876f84 100644 --- a/packages/tok-ui/src/components/TopSystemBar.ts +++ b/packages/tok-ui/src/components/TopSystemBar.ts @@ -7,13 +7,13 @@ export interface TopSystemBarCallbacks { onOpenCommandPalette: () => void; onViewModeChange: (mode: ViewMode) => void; onExportPdf: () => void; - onRenameDocument?: (newTitle: string) => void; onOpenProjects?: () => void; onToggleLanguage?: () => void; onOpenSettings?: () => void; onOpenAbout?: () => void; onUndo?: () => void; onRedo?: () => void; + onRenameDocument?: (newTitle: string) => void; } type MenuItem = { type: 'separator' } | { labelKey: string; shortcut?: string; action: string }; @@ -26,6 +26,7 @@ const MENU_ITEMS: MenuItem[] = [ { labelKey: 'menuImportDocument', shortcut: 'Ctrl+I', action: 'import-document' }, { labelKey: 'menuSave', shortcut: 'Ctrl+S', action: 'save-document' }, { labelKey: 'menuSaveAs', shortcut: 'Ctrl+Shift+S', action: 'save-as' }, + { labelKey: 'menuRenameDocument', shortcut: 'F2', action: 'rename-document' }, { type: 'separator' }, { labelKey: 'menuTextCleanup', shortcut: 'Ctrl+Shift+C', action: 'open-text-cleanup' }, { labelKey: 'menuNormalizeNiqqud', action: 'normalize-hebrew' }, @@ -139,6 +140,55 @@ export class TopSystemBar { } } + public startRenaming(): void { + const titleContainer = this.element.querySelector('.tok-doc-title'); + if (!titleContainer) return; + const titleEl = titleContainer.querySelector('.tok-doc-title-name'); + if (!titleEl || titleEl.querySelector('input')) return; + + const currentTitle = this.documentTitle.replace(/\.tok$/i, ''); + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'tok-doc-title-input tok-title-inline-input'; + input.value = currentTitle; + input.setAttribute('aria-label', t('menuRenameDocument')); + input.style.cssText = 'background: var(--tok-bg-surface-2); color: var(--tok-text-primary); border: 1px solid var(--tok-accent-text); border-radius: 4px; padding: 1px 6px; font-size: 13px; font-weight: 600; width: 180px; outline: none; font-family: inherit;'; + + let committed = false; + const commit = () => { + if (committed) return; + committed = true; + const next = input.value.trim(); + if (next && next !== currentTitle) { + this.callbacks.onRenameDocument?.(next); + } else { + titleEl.textContent = this.isDirty ? `• ${currentTitle}` : currentTitle; + } + }; + + const cancel = () => { + if (committed) return; + committed = true; + titleEl.textContent = this.isDirty ? `• ${currentTitle}` : currentTitle; + }; + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + commit(); + } else if (e.key === 'Escape') { + e.preventDefault(); + cancel(); + } + }); + + input.addEventListener('blur', () => commit()); + + titleEl.replaceChildren(input); + input.focus(); + input.select(); + } + private render(): void { // innerHTML='' detaches an open dropdown; drop the reference too. this.closeDropdown(); @@ -160,68 +210,30 @@ export class TopSystemBar { if (this.documentTitle) { const doc = el('div', 'tok-doc-title', { title: this.documentTitle }); const cleanTitle = this.documentTitle.replace(/\.tok$/i, ''); - const titleName = el('span', 'tok-doc-title-name', { - role: 'button', + + const titleSpan = el('span', 'tok-doc-title-name', { tabindex: '0', - title: i18n.getLanguage() === 'en' ? 'Click to rename document' : 'לחץ לשינוי שם המסמך', - style: 'cursor: pointer; border-radius: 4px; padding: 1px 4px; transition: background 0.15s ease;' + role: 'button', + title: t('renameDocumentTooltip'), + style: 'cursor: pointer; border-radius: 3px; padding: 1px 4px;' }, this.isDirty ? `• ${cleanTitle}` : cleanTitle); - - titleName.addEventListener('mouseenter', () => { - titleName.style.background = 'var(--tok-bg-surface-2, rgba(255,255,255,0.08))'; - }); - titleName.addEventListener('mouseleave', () => { - titleName.style.background = 'transparent'; - }); - - const startEditing = () => { - const input = el('input', 'tok-doc-title-input', { - type: 'text', - value: cleanTitle, - style: 'background: var(--tok-bg-surface-2); color: var(--tok-text-primary); border: 1px solid var(--tok-accent-primary); border-radius: 4px; padding: 1px 6px; font-size: 13px; font-weight: 600; font-family: inherit; outline: none; width: ' + Math.max(120, cleanTitle.length * 9) + 'px;' - }) as HTMLInputElement; - - let committed = false; - const commit = () => { - if (committed) return; - committed = true; - const trimmed = input.value.trim(); - if (trimmed && trimmed !== cleanTitle) { - this.callbacks.onRenameDocument?.(trimmed); - } else { - this.render(); - } - }; - - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - commit(); - } else if (e.key === 'Escape') { - e.preventDefault(); - committed = true; - this.render(); - } - }); - input.addEventListener('blur', () => commit()); - - titleName.replaceWith(input); - input.focus(); - input.select(); - }; - - titleName.addEventListener('click', (e) => { + titleSpan.addEventListener('dblclick', (e) => { e.stopPropagation(); - startEditing(); + this.startRenaming(); }); - titleName.addEventListener('keydown', (e) => { - if (e.key === 'Enter' || e.key === ' ') { + titleSpan.addEventListener('keydown', (e) => { + if (e.key === 'F2' || e.key === 'Enter') { e.preventDefault(); - startEditing(); + this.startRenaming(); } }); + doc.appendChild(titleSpan); + + const renameBtn = iconButton('edit', t('renameDocumentTooltip'), () => { + this.startRenaming(); + }, { size: 12, className: 'tok-btn tok-btn-ghost tok-btn-xs', attrs: { style: 'height: 20px; width: 20px; padding: 0; opacity: 0.6; margin-inline-start: 2px;' } }); + doc.appendChild(renameBtn); - doc.appendChild(titleName); const sep = el('span', 'tok-doc-title-sep', { 'aria-hidden': 'true' }, '/'); sep.hidden = !this.pageLabel; doc.appendChild(sep); diff --git a/packages/tok-ui/src/engine/textImporter.ts b/packages/tok-ui/src/engine/textImporter.ts index 29b777d..dc8b394 100644 --- a/packages/tok-ui/src/engine/textImporter.ts +++ b/packages/tok-ui/src/engine/textImporter.ts @@ -442,8 +442,9 @@ export async function decompressDeflateRawAsync(bytes: Uint8Array): Promise = { @@ -398,6 +399,11 @@ const ICONS_SVG: Record = { + `, + edit: ` + + + ` }; diff --git a/packages/tok-ui/src/index.html b/packages/tok-ui/src/index.html index 2dcf66a..538e73c 100644 --- a/packages/tok-ui/src/index.html +++ b/packages/tok-ui/src/index.html @@ -6,7 +6,10 @@ inline styles are used for per-element geometry. --> - TypesetOK + TypesetOK (TOK) + + +