From e55166120c3a63ab9d5f7990cdaa8fb3f315f4be Mon Sep 17 00:00:00 2001 From: pacer Date: Sat, 9 May 2026 11:15:50 +0800 Subject: [PATCH 1/7] =?UTF-8?q?style(ui):=20phase=201=20=E2=80=94=20typogr?= =?UTF-8?q?aphy,=20icons,=20contrast,=20accent=20tokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Across-the-board polish pass to remove the "mushy / blurry" feel: Typography - Promote sub-11px text (badges, labels, kbd, palette-foot, version, pill counts, citation card meta) to a 11px floor; sub-12px hinting was rendering as a fuzzy stripe. - Cap letter-spacing at 0.04em on small uppercase labels. - New --font-serif token cascading through resident faces (Charter on macOS, Cambria on Windows, Liberation Serif on Linux, Times last) for editor body, ref-card titles, palette titles, cover preview, and cover-dialog logo. Replaces unbundled Newsreader/Source Serif which silently fell through to Times for most users. Icons - Global .lucide { stroke-width: 1.5 } so 11–14px icons stop reading as heavy/double-stroke smudges in chrome that uses tertiary-grey tones. - Replace ✎ unicode pencil glyph in EmptyState with Lucide PencilLine (Unicode dingbat rendered as colored emoji on macOS / monochrome on Windows — looked broken either way). Color tokens - Use --accent-dark for text on --accent-light backgrounds (activity bar, status pill, update notice, citation chip, citation-card key, outline hover) so dark-mode contrast stays AA. - Lift dark-mode --scrollbar-thumb from #3D3833 to #5A544D so it stops being identical to --border-hover; thumbs were invisible against panel borders. - Bump editor-toolbar-divider from --border to --border-hover so groups actually delineate. Rendering - Reduce PDF zoom debounce 200→80ms and add will-change: transform on the scaled wrapper so the bilinear-interpolation period is short and GPU-accelerated. Co-Authored-By: Claude Opus 4.7 --- src/App.tsx | 6 +- src/components/FloatingOutline.tsx | 4 +- src/components/PdfPreview.tsx | 11 ++- src/components/ReferencesPanel.tsx | 8 +- src/components/SettingsPanel.tsx | 4 +- src/components/Sidebar.tsx | 2 +- src/components/StatusBar.tsx | 8 +- src/index.css | 125 ++++++++++++++++------------- 8 files changed, 93 insertions(+), 75 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 4b498d9..fb720d3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { useCallback, useState, useEffect, useRef } from "react"; import { openUrl } from "@tauri-apps/plugin-opener"; -import { Sparkles } from "lucide-react"; +import { PencilLine, Sparkles } from "lucide-react"; import { Editor } from "./components/Editor"; import { PdfPreview } from "./components/PdfPreview"; import { StatusBar } from "./components/StatusBar"; @@ -283,9 +283,7 @@ function EmptyState() { return (
- - {"\u270E"} - +

diff --git a/src/components/FloatingOutline.tsx b/src/components/FloatingOutline.tsx index b6b1461..f60b600 100644 --- a/src/components/FloatingOutline.tsx +++ b/src/components/FloatingOutline.tsx @@ -80,7 +80,7 @@ export function FloatingOutline({ editor }: FloatingOutlineProps) {

- + {t("editor.outline")}
@@ -103,7 +103,7 @@ export function FloatingOutline({ editor }: FloatingOutlineProps) { onMouseLeave={() => setHoveredIndex(null)} className={`block w-full text-left py-1.5 px-2 rounded-md transition-all duration-150 truncate ${ hoveredIndex === index - ? "bg-[var(--accent-light)] text-[var(--accent)]" + ? "bg-[var(--accent-light)] text-[var(--accent-dark)]" : "text-[var(--text-secondary)] hover:text-[var(--text-primary)]" }`} style={{ diff --git a/src/components/PdfPreview.tsx b/src/components/PdfPreview.tsx index a4739c6..ddbd73d 100644 --- a/src/components/PdfPreview.tsx +++ b/src/components/PdfPreview.tsx @@ -84,12 +84,14 @@ export function PdfPreview({ collapsed, onToggleCollapse, panelWidth, isResizing numPages > 0 ? numPages * pageHeight + (numPages - 1) * PAGE_GAP : 0; const visualTotalHeight = Math.round(totalPagesHeight * cssScale); - // Schedule a high-res re-render after zoom stops changing + // Schedule a high-res re-render after zoom stops changing. 80ms keeps the + // user from sitting on a CSS-bilinear-scaled bitmap for very long while + // still coalescing a wheel burst into one rasterization pass. const scheduleRender = useCallback((newZoom: number) => { if (renderTimerRef.current) clearTimeout(renderTimerRef.current); renderTimerRef.current = setTimeout(() => { setRenderedZoom(newZoom); - }, 200); + }, 80); }, []); // Cleanup timer @@ -254,7 +256,7 @@ export function PdfPreview({ collapsed, onToggleCollapse, panelWidth, isResizing
- + {t("pdf.preview")}
@@ -281,7 +283,7 @@ export function PdfPreview({ collapsed, onToggleCollapse, panelWidth, isResizing <>
- PDF + PDF
@@ -445,6 +447,7 @@ export function PdfPreview({ collapsed, onToggleCollapse, panelWidth, isResizing left: 0, transform: cssScale !== 1 ? `scale(${cssScale})` : undefined, transformOrigin: "top left", + willChange: cssScale !== 1 ? "transform" : undefined, display: "flex", flexDirection: "column", gap: PAGE_GAP, diff --git a/src/components/ReferencesPanel.tsx b/src/components/ReferencesPanel.tsx index 22148c2..87ad5dc 100644 --- a/src/components/ReferencesPanel.tsx +++ b/src/components/ReferencesPanel.tsx @@ -241,7 +241,7 @@ export function ReferencesPanel({ {s.toUpperCase()} {getStyleDescription(s) && ( - + {getStyleDescription(s)} )} @@ -259,10 +259,10 @@ export function ReferencesPanel({ {entries.length === 0 ? (
-

+

{t("refs.noRefs")}

-

+

{t("refs.noRefsHint")}

@@ -371,7 +371,7 @@ export function ReferencesPanel({ className="flex items-center gap-1.5 flex-wrap" onClick={(ev) => ev.stopPropagation()} > - + {t("refs.deleteConfirm")}
)} - + LexTyp v{__APP_VERSION__}
diff --git a/src/index.css b/src/index.css index 2b51baf..659e831 100644 --- a/src/index.css +++ b/src/index.css @@ -127,9 +127,10 @@ --editor-bg: #1A1714; --editor-border: #2E2A26; - /* Scrollbar */ - --scrollbar-thumb: #3D3833; - --scrollbar-thumb-hover: #4A453F; + /* Scrollbar — must stay visibly distinct from --border-hover (#3D3833), + otherwise the thumb disappears whenever it overlaps a panel border. */ + --scrollbar-thumb: #5A544D; + --scrollbar-thumb-hover: #6F6A62; } /* ─── Shared tokens ─── */ @@ -143,8 +144,16 @@ --radius-xl: 12px; --radius-2xl: 16px; - /* Typography scale */ + /* Typography scale. + `--font-serif` is the editor body face. `Newsreader` / `Source Serif 4` + were declared previously but aren't bundled and most installs don't have + them — so users silently fell through to Times, which the PDF preamble + also targets but renders very differently on screen. The cascade below + prefers resident high-quality serifs (Charter on macOS, Cambria on + Windows, Liberation Serif on Linux) before falling back to Times. */ --font-ui: "Inter", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; + --font-serif: "Newsreader", "Iowan Old Style", "Charter", "Bitstream Charter", + Cambria, "Liberation Serif", "Times New Roman", Times, Georgia, serif; --font-editor: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif; --font-mono: "JetBrains Mono", "Fira Code", ui-monospace, monospace; } @@ -172,6 +181,14 @@ textarea { color: inherit; } +/* Lucide icons ship with stroke-width: 2, which at 11–14 px renders as + visually heavy double-stroke smudges in chrome that uses tertiary-grey + colors. 1.5 is Lucide's recommended UI weight and stays sharp at small + sizes. The CSS property overrides the inline SVG attribute. */ +.lucide { + stroke-width: 1.5; +} + /* ═══════════════════════════════════════ BlockNote Editor Overrides ═══════════════════════════════════════ */ @@ -181,7 +198,7 @@ textarea { serif do the work — what the writer sees is what compiles, modulo the editor's max content width. */ .bn-editor { - font-family: "Newsreader", "Source Serif 4", "Source Serif Pro", "Times New Roman", Times, Georgia, serif; + font-family: var(--font-serif); font-size: 16px; line-height: 1.6; color: var(--text-primary); @@ -581,10 +598,10 @@ textarea { } .panel-section-label { - font-size: 10px; + font-size: 11px; font-weight: 700; text-transform: uppercase; - letter-spacing: 0.08em; + letter-spacing: 0.04em; color: var(--text-secondary); } @@ -716,9 +733,9 @@ textarea { .slash-menu-group { padding: 8px 10px 4px; - font-size: 10px; + font-size: 11px; font-weight: 700; - letter-spacing: 0.08em; + letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-tertiary); } @@ -767,10 +784,10 @@ textarea { display: inline-flex; align-items: center; padding: 2px 8px; - font-size: 10px; + font-size: 11px; font-weight: 700; text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.04em; border-radius: var(--radius-sm); background: var(--bg-tertiary); color: var(--text-secondary); @@ -906,7 +923,7 @@ textarea { .ref-style-desc { font-family: var(--font-ui); - font-size: 10.5px; + font-size: 11px; color: var(--text-tertiary); line-height: 1.3; overflow: hidden; @@ -960,9 +977,10 @@ textarea { } .ref-pill-count { - font-size: 10px; + font-size: 11px; font-weight: 600; color: var(--text-tertiary); + font-variant-numeric: tabular-nums; } .ref-pill:hover .ref-pill-count { @@ -1014,15 +1032,15 @@ textarea { flex-shrink: 0; display: inline-flex; align-items: center; - height: 16px; + height: 18px; padding: 0 6px; border-radius: var(--radius-sm); background: var(--bg-tertiary); color: var(--text-secondary); font-family: var(--font-ui); - font-size: 9px; + font-size: 11px; font-weight: 700; - letter-spacing: 0.1em; + letter-spacing: 0.04em; text-transform: uppercase; line-height: 1; } @@ -1036,7 +1054,7 @@ textarea { flex: 1; min-width: 0; font-family: var(--font-mono); - font-size: 10px; + font-size: 11px; color: var(--text-tertiary); overflow: hidden; text-overflow: ellipsis; @@ -1044,8 +1062,7 @@ textarea { } .ref-card-title { - font-family: "Newsreader", "Source Serif 4", "Source Serif Pro", - "Times New Roman", Georgia, serif; + font-family: var(--font-serif); font-size: 13px; font-weight: 500; line-height: 1.35; @@ -1060,7 +1077,7 @@ textarea { .ref-card-source { margin-top: 3px; font-family: var(--font-ui); - font-size: 10.5px; + font-size: 11px; color: var(--text-tertiary); overflow: hidden; text-overflow: ellipsis; @@ -1074,7 +1091,7 @@ textarea { .ref-card-preview { margin-top: 8px; - font-family: "Newsreader", "Source Serif 4", "Times New Roman", Georgia, serif; + font-family: var(--font-serif); font-style: italic; font-size: 11.5px; line-height: 1.55; @@ -1091,7 +1108,7 @@ textarea { .ref-card-field { display: flex; gap: 8px; - font-size: 10.5px; + font-size: 11px; line-height: 1.4; } @@ -1111,7 +1128,7 @@ textarea { .ref-card-field-value.is-mono { font-family: var(--font-mono); - font-size: 10px; + font-size: 10.5px; } .ref-card-actions { @@ -1132,7 +1149,7 @@ textarea { padding: 0 4px; margin-bottom: 6px; font-family: var(--font-ui); - font-size: 10.5px; + font-size: 11px; color: var(--text-tertiary); } @@ -1356,13 +1373,13 @@ textarea { no matter the label length (BOOK / ARTICLE / STATUTE …). */ width: 64px; margin-top: 2px; - padding: 3px 0; + padding: 4px 0; border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); border-radius: var(--radius-sm); background: var(--accent-light); - font-size: 9px; + font-size: 11px; font-weight: 700; - letter-spacing: 0.12em; + letter-spacing: 0.04em; line-height: 1; color: var(--accent-dark); text-transform: uppercase; @@ -1377,7 +1394,7 @@ textarea { .palette-title { display: block; - font-family: "Newsreader", "Source Serif 4", "Source Serif Pro", "Times New Roman", Georgia, serif; + font-family: var(--font-serif); font-size: 14px; line-height: 1.3; color: var(--text-primary); @@ -1407,7 +1424,7 @@ textarea { } .palette-empty-title { - font-family: "Newsreader", "Source Serif 4", "Times New Roman", Georgia, serif; + font-family: var(--font-serif); font-size: 14px; color: var(--text-secondary); margin-bottom: 4px; @@ -1421,7 +1438,7 @@ textarea { padding: 8px 16px; background: var(--bg-secondary); border-top: 1px solid var(--border-light); - font-size: 10px; + font-size: 11px; color: var(--text-tertiary); } @@ -1435,15 +1452,15 @@ textarea { display: inline-flex; align-items: center; justify-content: center; - min-width: 16px; - height: 17px; + min-width: 18px; + height: 18px; padding: 0 5px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 3px; box-shadow: 0 1px 0 var(--border-hover); font-family: var(--font-mono); - font-size: 10px; + font-size: 11px; line-height: 1; color: var(--text-secondary); } @@ -1483,7 +1500,7 @@ textarea { .editor-toolbar-divider { width: 1px; height: 18px; - background: var(--border); + background: var(--border-hover); flex-shrink: 0; } @@ -1567,19 +1584,19 @@ textarea { .editor-toolbar-style-name { font-family: var(--font-ui); - font-size: 10.5px; + font-size: 11px; font-weight: 700; - letter-spacing: 0.08em; + letter-spacing: 0.04em; color: var(--text-primary); line-height: 1; } .editor-toolbar-style-edn { font-family: var(--font-ui); - font-size: 10px; + font-size: 11px; font-weight: 500; color: var(--text-tertiary); - letter-spacing: 0.01em; + letter-spacing: 0; line-height: 1; white-space: nowrap; } @@ -1629,7 +1646,7 @@ textarea { font-size: 11px; font-weight: 600; background-color: var(--accent-light); - color: var(--accent); + color: var(--accent-dark); border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent); transition: background-color var(--transition-fast), border-color var(--transition-fast); } @@ -1710,13 +1727,13 @@ textarea { font-family: var(--font-mono); font-size: 11px; font-weight: 600; - color: var(--accent); + color: var(--accent-dark); } .lextyp-citation-card-type { - font-size: 9.5px; + font-size: 11px; font-weight: 700; - letter-spacing: 0.08em; + letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-tertiary); } @@ -1766,9 +1783,9 @@ textarea { } .lextyp-citation-card-field-name { - font-size: 10px; + font-size: 11px; font-weight: 600; - letter-spacing: 0.04em; + letter-spacing: 0.02em; text-transform: uppercase; color: var(--text-tertiary); align-self: start; @@ -1828,9 +1845,9 @@ textarea { } .cover-page-tag { - font-size: 9.5px; + font-size: 11px; font-weight: 700; - letter-spacing: 0.08em; + letter-spacing: 0.04em; color: var(--text-tertiary); text-transform: uppercase; } @@ -1841,7 +1858,7 @@ textarea { gap: 4px; padding: 3px 8px; border-radius: 999px; - font-size: 10.5px; + font-size: 11px; font-weight: 500; color: var(--text-tertiary); background: var(--bg-tertiary); @@ -1867,7 +1884,7 @@ textarea { } .cover-preview-title { - font-family: "Times New Roman", Times, "Libertinus Serif", serif; + font-family: var(--font-serif); font-size: clamp(20px, 6.5cqi, 30px); font-weight: 700; letter-spacing: -0.01em; @@ -1876,7 +1893,7 @@ textarea { } .cover-preview-subtitle { - font-family: "Times New Roman", Times, "Libertinus Serif", serif; + font-family: var(--font-serif); font-size: clamp(13px, 3.5cqi, 17px); font-style: italic; color: var(--text-secondary); @@ -1890,7 +1907,7 @@ textarea { justify-content: center; align-items: baseline; gap: 4px 8px; - font-family: "Times New Roman", Times, "Libertinus Serif", serif; + font-family: var(--font-serif); font-size: clamp(12px, 2.8cqi, 14px); color: var(--text-secondary); } @@ -2005,7 +2022,7 @@ textarea { background: var(--accent); color: var(--text-inverse); border-radius: var(--radius-md); - font-family: "Times New Roman", Times, "Libertinus Serif", serif; + font-family: var(--font-serif); font-size: 18px; font-weight: 700; letter-spacing: -0.02em; @@ -2019,9 +2036,9 @@ textarea { } .cover-dialog-tag { - font-size: 10px; + font-size: 11px; font-weight: 700; - letter-spacing: 0.08em; + letter-spacing: 0.04em; color: var(--text-tertiary); text-transform: uppercase; } @@ -2106,7 +2123,7 @@ textarea { } .cover-form-hint { - font-size: 11px; + font-size: 11.5px; color: var(--text-tertiary); font-style: italic; } From c1e6e03c6d3f2f89fd6e7e8d38c46defbed96e12 Mon Sep 17 00:00:00 2001 From: pacer Date: Sat, 9 May 2026 11:16:35 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(pdf):=20make=20double-click=20=E2=86=92?= =?UTF-8?q?=20editor=20jump=20fire=20reliably?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that together restore the double-click-to-jump feature: 1. Pointer-drag bookkeeping was capturing the pointer (and toggling isDragging) on every pointerdown. On the second mousedown of a dblclick pair, this race could swallow the dblclick event in some webview configurations. Capture is now deferred until movement crosses a 4 px threshold (same pattern FileTreeItem already uses), so plain click/dblclick sequences never enter capture/drag state. 2. handleDoubleClick now logs which step it bailed at (no jump fn registered, no [data-page-number] ancestor, empty source map, no on-page entry) instead of silently returning. Useful for diagnosing when the source map hasn't populated yet because the post-compile query_source_map call is still in flight. Co-Authored-By: Claude Opus 4.7 --- src/components/PdfPreview.tsx | 117 ++++++++++++++++++++++++---------- 1 file changed, 83 insertions(+), 34 deletions(-) diff --git a/src/components/PdfPreview.tsx b/src/components/PdfPreview.tsx index ddbd73d..aee0e50 100644 --- a/src/components/PdfPreview.tsx +++ b/src/components/PdfPreview.tsx @@ -55,6 +55,13 @@ export function PdfPreview({ collapsed, onToggleCollapse, panelWidth, isResizing const [zoom, setZoom] = useState(1.0); const [renderedZoom, setRenderedZoom] = useState(1.0); const renderTimerRef = useRef | null>(null); + // Pointer-drag bookkeeping. We *don't* set `isDragging` or call + // `setPointerCapture` on pointerdown — that interferes with the second + // mousedown of a dblclick pair on some webviews and the dblclick handler + // never fires. Instead, capture is deferred until movement crosses a + // small threshold (the same pattern FileTreeItem already uses). + const dragArmedRef = useRef<{ pointerId: number; el: HTMLDivElement } | null>(null); + const pointerCapturedRef = useRef(false); const clampZoom = (z: number) => Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z)); const zoomPercent = Math.round(zoom * 100); @@ -138,34 +145,56 @@ export function PdfPreview({ collapsed, onToggleCollapse, panelWidth, isResizing return () => el.removeEventListener("wheel", handleWheel); }, [scheduleRender]); - // Drag panning — works whenever content overflows the container - const handlePointerDown = useCallback((e: React.PointerEvent) => { + // Drag panning. `pointerdown` only records the origin and arms a pending + // drag — pointer capture and `isDragging=true` are deferred until the + // pointer actually moves past a 4 px threshold. A plain click/dblclick + // therefore never enters dragging state, so the mousedown/mouseup/dblclick + // sequence is left untouched and the dblclick handler fires reliably. + const handlePointerDown = useCallback((e: React.PointerEvent) => { const el = scrollRef.current; if (!el) return; if (el.scrollWidth <= el.clientWidth && el.scrollHeight <= el.clientHeight) return; - setIsDragging(true); + if (e.button !== 0) return; + dragArmedRef.current = { pointerId: e.pointerId, el }; + pointerCapturedRef.current = false; dragStart.current = { x: e.clientX, y: e.clientY, scrollLeft: el.scrollLeft, scrollTop: el.scrollTop, }; - el.setPointerCapture(e.pointerId); }, []); - const handlePointerMove = useCallback((e: React.PointerEvent) => { - if (!isDragging) return; - const el = scrollRef.current; - if (!el) return; - el.scrollLeft = dragStart.current.scrollLeft - (e.clientX - dragStart.current.x); - el.scrollTop = dragStart.current.scrollTop - (e.clientY - dragStart.current.y); - }, [isDragging]); + const handlePointerMove = useCallback((e: React.PointerEvent) => { + const armed = dragArmedRef.current; + if (!armed || armed.pointerId !== e.pointerId) return; + const dx = e.clientX - dragStart.current.x; + const dy = e.clientY - dragStart.current.y; + if (!pointerCapturedRef.current) { + // Wait until the pointer has actually moved before promoting to a drag. + if (dx * dx + dy * dy < 16) return; + try { + armed.el.setPointerCapture(e.pointerId); + pointerCapturedRef.current = true; + } catch { + // Pointer capture can fail if the element is detached mid-event. + } + setIsDragging(true); + } + armed.el.scrollLeft = dragStart.current.scrollLeft - dx; + armed.el.scrollTop = dragStart.current.scrollTop - dy; + }, []); - const handlePointerUp = useCallback((e: React.PointerEvent) => { - if (!isDragging) return; - setIsDragging(false); - scrollRef.current?.releasePointerCapture(e.pointerId); - }, [isDragging]); + const handlePointerUp = useCallback((e: React.PointerEvent) => { + const armed = dragArmedRef.current; + if (!armed || armed.pointerId !== e.pointerId) return; + if (pointerCapturedRef.current) { + try { armed.el.releasePointerCapture(e.pointerId); } catch { /* noop */ } + pointerCapturedRef.current = false; + setIsDragging(false); + } + dragArmedRef.current = null; + }, []); const pdfData = useMemo(() => { if (!pdfBase64) return null; @@ -209,36 +238,56 @@ export function PdfPreview({ collapsed, onToggleCollapse, panelWidth, isResizing } }, [pdfBase64]); - // Double-click on PDF → jump to the corresponding word in the editor + // Double-click on PDF → jump to the corresponding word in the editor. + // The source map is built by `query_source_map` after each compile and + // contains one entry per word (`__w`) plus one per block (`__track`). const handleDoubleClick = useCallback( (e: React.MouseEvent) => { - if (!sourceMap.length) return; + const jumpFn = (window as any).__lextyp_jumpToBlock as + | ((id: string, off: number) => void) + | undefined; + if (!jumpFn) { + console.warn("[PdfPreview] dblclick: no __lextyp_jumpToBlock registered"); + return; + } - // Find the page element that was clicked (react-pdf sets data-page-number) - const pageEl = (e.target as HTMLElement).closest("[data-page-number]"); - if (!pageEl) return; + // react-pdf sets data-page-number on the inner
. + const pageEl = (e.target as HTMLElement | null)?.closest?.( + "[data-page-number]" + ) as HTMLElement | null; + if (!pageEl) { + console.warn("[PdfPreview] dblclick: no [data-page-number] ancestor for", e.target); + return; + } const pageNum = parseInt(pageEl.getAttribute("data-page-number") ?? "0", 10); if (!pageNum) return; - // Get click position relative to the page element - const pageRect = pageEl.getBoundingClientRect(); - const clickXInPage = e.clientX - pageRect.left; - const clickYInPage = e.clientY - pageRect.top; + if (!sourceMap.length) { + console.warn("[PdfPreview] dblclick: source map is empty — compile may not have completed"); + return; + } - // Convert pixel position to Typst points - // The rendered page width in pixels corresponds to A4_WIDTH_PT + // Click position in points. `getBoundingClientRect` returns the + // visually-transformed rect, which already includes any css zoom scale. + const pageRect = pageEl.getBoundingClientRect(); const pxPerPt = pageRect.width / A4_WIDTH_PT; - const clickYPt = clickYInPage / pxPerPt; - const clickXPt = clickXInPage / pxPerPt; + const clickXPt = (e.clientX - pageRect.left) / pxPerPt; + const clickYPt = (e.clientY - pageRect.top) / pxPerPt; - // Find the nearest source-map marker (word) to the click const target = findNearestEntry(sourceMap, pageNum, clickXPt, clickYPt); - if (!target) return; + if (!target) { + console.warn( + "[PdfPreview] dblclick: no source-map entry on page", + pageNum, + "(map has", + sourceMap.length, + "entries)" + ); + return; + } - // Jump to (block, character offset) in editor - const jumpFn = (window as any).__lextyp_jumpToBlock; - if (jumpFn) jumpFn(target.id, target.off); + jumpFn(target.id, target.off); }, [sourceMap] ); From 9ba69ecf17900fe5ff9483175440f8108f1c885f Mon Sep 17 00:00:00 2001 From: pacer Date: Sat, 9 May 2026 11:21:54 +0800 Subject: [PATCH 3/7] =?UTF-8?q?style(ui):=20phase=202=20=E2=80=94=20surfac?= =?UTF-8?q?es,=20chrome=20boundaries,=20inputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse chrome to 3 surfaces with clear roles instead of 4 near-identical greys separated by invisible 1px lines. Sidebar - Activity bar shares the panel content surface (--bg-secondary) and uses a real --border separator instead of a near-invisible border-light. - Activity bar is now 44 px wide in both collapsed and expanded states, so toggling no longer snaps the column from 36 → 48 px. Resize handles (App.tsx) - New .pane-resize-handle class: 8 px hit-target, transparent body, 1 px center divider in --border that thickens to --accent on hover/active. Replaces the soft 6 px border-light stripes that read as bugs. PDF preview - Toolbar surface bg-elevated → bg-secondary, matching the editor toolbar and sidebar header. The three 44 px chrome rows now read as one continuous chrome layer instead of three different shades. Status bar - Border bumped border-light → border (one shade darker) and bg shifted to --bg-tertiary so the strip visually anchors the bottom of the chrome instead of fading into the panels above. Cover page block - max-width 640 → 100 % so the cover page fills the editor column (was floating un-centered against the body text below). Inputs - Standardise on 32 px height: ref-search 30 → 32, cover-form-input 34 → 32, cover-wordcount-fields 34 → 32, cover-auto-chip 30 → 32. Five distinct heights collapsed to one, plus 28 px for the in-toolbar .compact-control variant. Co-Authored-By: Claude Opus 4.7 --- src/App.tsx | 22 +++++---------- src/components/PdfPreview.tsx | 2 +- src/components/Sidebar.tsx | 24 ++++++++++------- src/components/StatusBar.tsx | 2 +- src/index.css | 51 ++++++++++++++++++++++++++++++----- 5 files changed, 68 insertions(+), 33 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index fb720d3..ea3d018 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -195,15 +195,10 @@ function App() { {!sidebarCollapsed && (
-
- {resizingPanel === "sidebar" && ( -
- )} -
+ /> )} {/* Main content area */} @@ -224,15 +219,10 @@ function App() {
-
- {resizingPanel === "pdf" && ( -
- )} -
+ /> )} {/* PDF Preview panel */} diff --git a/src/components/PdfPreview.tsx b/src/components/PdfPreview.tsx index aee0e50..1655b3d 100644 --- a/src/components/PdfPreview.tsx +++ b/src/components/PdfPreview.tsx @@ -376,7 +376,7 @@ export function PdfPreview({ collapsed, onToggleCollapse, panelWidth, isResizing return (
{/* Toolbar */} -
+
{t("pdf.preview")} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 63c7e20..a628c67 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -162,32 +162,35 @@ export function Sidebar({ ? workspacePath.replace(/\\/g, "/").split("/").pop() || "Workspace" : null; - // Collapsed state — just show the activity bar icons + // Collapsed state — just show the activity bar icons. Same width and + // surface as the activity bar inside the expanded sidebar so toggling + // collapse doesn't snap the column wider. if (collapsed) { return ( -
+
+
} + icon={} active={activeTab === "files"} title={t("sidebar.files")} onClick={() => { setActiveTab("files"); onToggle(); }} /> } + icon={} active={activeTab === "references"} title={t("sidebar.references")} onClick={() => { setActiveTab("references"); onToggle(); }} />
} + icon={} active={activeTab === "settings"} title={t("settings.title")} onClick={() => { setActiveTab("settings"); onToggle(); }} @@ -202,8 +205,11 @@ export function Sidebar({ style={{ width }} className="h-full bg-[var(--bg-secondary)] flex shrink-0 select-none overflow-hidden" > - {/* Activity bar */} -
+ {/* Activity bar — same surface as the panel content; the divider is + carried by the right-edge `border-r` in --border (one shade darker + than --border-light). 44 px matches the collapsed sidebar so + toggling collapse never widens the column. */} +
} active={activeTab === "files"} diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index 9c6a852..ece04b8 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -67,7 +67,7 @@ export function StatusBar() { const showUpdate = updateInfo?.has_update && !updateDismissed; return ( -
+
{/* Left side - Status */}
diff --git a/src/index.css b/src/index.css index 659e831..0c1508d 100644 --- a/src/index.css +++ b/src/index.css @@ -189,6 +189,43 @@ textarea { stroke-width: 1.5; } +/* ─── Pane resize handle ─────────────────────────────────────────────── + The visual handle is a 1 px center divider in `--border`, but the + element itself is 8 px wide so it gives a comfortable hit-target. + Hover/active reveal the accent. The handle reads as the panel border + it sits on, not as a separate stripe. */ +.pane-resize-handle { + flex-shrink: 0; + position: relative; + width: 8px; + cursor: col-resize; + background: transparent; + transition: background-color var(--transition-fast); +} + +.pane-resize-handle::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 1px; + background: var(--border); + transform: translateX(-0.5px); + transition: background-color var(--transition-fast), width var(--transition-fast); +} + +.pane-resize-handle:hover::before, +.pane-resize-handle.is-active::before { + background: var(--accent); + width: 2px; + transform: translateX(-1px); +} + +.pane-resize-handle.is-active { + background: color-mix(in srgb, var(--accent) 10%, transparent); +} + /* ═══════════════════════════════════════ BlockNote Editor Overrides ═══════════════════════════════════════ */ @@ -823,8 +860,8 @@ textarea { display: flex; align-items: center; gap: 8px; - height: 30px; - padding: 0 6px 0 9px; + height: 32px; + padding: 0 8px 0 10px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-md); @@ -1814,7 +1851,9 @@ textarea { container-type: inline-size; position: relative; width: 100%; - max-width: 640px; + /* Match the editor column max so the cover page never floats inside its + own container looking un-centered against the body text below. */ + max-width: 100%; margin: 12px auto; padding: clamp(20px, 5cqi, 36px) clamp(20px, 5cqi, 40px) clamp(24px, 6cqi, 44px); background: var(--bg-elevated); @@ -2129,7 +2168,7 @@ textarea { } .cover-form-input { - height: 34px; + height: 32px; font-size: 13px; } @@ -2166,7 +2205,7 @@ textarea { align-items: center; gap: 6px; padding: 0 10px; - height: 34px; + height: 32px; background: var(--bg-primary); border: 1px solid var(--border); border-radius: var(--radius-md); @@ -2229,7 +2268,7 @@ textarea { display: inline-flex; align-items: center; gap: 5px; - height: 30px; + height: 32px; padding: 0 11px; background: var(--bg-tertiary); border: 1px solid transparent; From 90c30a51440ccf3da60b7cca6d9fbc1298339a57 Mon Sep 17 00:00:00 2001 From: pacer Date: Sat, 9 May 2026 11:40:46 +0800 Subject: [PATCH 4/7] =?UTF-8?q?style(ui):=20phase=203=20=E2=80=94=20unify?= =?UTF-8?q?=20icon=20buttons,=20slim=20editor=20toolbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Button system - .icon-btn now ships in two sizes (28 px default + 24 px .icon-btn-sm) with a canonical .is-active variant. Disabled state is opacity 0.4 with not-allowed cursor, matching .btn. - Migrate every ad-hoc Tailwind icon button to the system: * Sidebar header New/Close/Collapse (was `icon-btn w-6 h-6 hover:…`) * Sidebar ActivityBarButton (was bespoke Tailwind w-[28px] h-[28px]) * PDF toolbar zoom in/out + collapse (was `w-6 h-6 flex items-center justify-center rounded hover:bg-…`) * PDF collapsed-pane and simple-toolbar collapse buttons - Drop the ad-hoc `hover:bg-… hover:text-…` overrides; the .icon-btn hover state is the single source of truth now. Editor toolbar - Drop the dedicated Pilcrow (paragraph) button. setHeading already demotes an active heading back to paragraph on second click, so the button was redundant and added a third unfamiliar glyph (most users don't recognise the pilcrow mark). - Move the citation chip/footnote display toggle (formerly an Eye / EyeOff button) into the StyleChip dropdown as a "Display" radio group above the existing Style picker. The two settings are conceptually one menu — "how citations render" — and consolidating them removes another button from the toolbar. Co-Authored-By: Claude Opus 4.7 --- src/components/EditorTopToolbar.tsx | 102 +++++++++++++++------------- src/components/PdfPreview.tsx | 20 +++--- src/components/Sidebar.tsx | 12 ++-- src/index.css | 30 +++++++- 4 files changed, 98 insertions(+), 66 deletions(-) diff --git a/src/components/EditorTopToolbar.tsx b/src/components/EditorTopToolbar.tsx index 8443595..5e194c3 100644 --- a/src/components/EditorTopToolbar.tsx +++ b/src/components/EditorTopToolbar.tsx @@ -1,17 +1,16 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { Bold, BookMarked, Check, ChevronDown, - Eye, - EyeOff, Italic, List, ListOrdered, - Pilcrow, Quote, + Superscript, + Type, Underline, } from "lucide-react"; import type { BlockNoteEditor } from "@blocknote/core"; @@ -98,13 +97,6 @@ export function EditorTopToolbar({ editor, onInsertCitation }: EditorTopToolbarP focus(); } - function setParagraph() { - const block = editor.getTextCursorPosition().block; - if (!block) return; - editor.updateBlock(block, { type: "paragraph", props: {} as any }); - focus(); - } - function setList(kind: "bullet" | "numbered") { const block = editor.getTextCursorPosition().block; if (!block) return; @@ -129,6 +121,9 @@ export function EditorTopToolbar({ editor, onInsertCitation }: EditorTopToolbarP
+ {/* Headings only — clicking an already-active heading button + demotes the block back to a paragraph (see setHeading below), + so a dedicated Pilcrow paragraph button is redundant. */} H2 - - -
); } -/** - * Eye toggle that flips between `chip` and `footnote` rendering of citation - * tags in the editor. Purely a display setting — the underlying document is - * untouched. - */ -function CitationDisplayToggle() { - const display = useReferenceStore((s) => s.citationDisplay); - const toggle = useReferenceStore((s) => s.toggleCitationDisplay); - const isFootnote = display === "footnote"; - const label = isFootnote - ? "Citation display: footnote — show as chip" - : "Citation display: chip — show as footnote"; - return ( - - ); -} interface BtnProps { label: string; @@ -272,6 +238,8 @@ function ToolbarTextBtn({ label, active, onClick, children }: BtnProps) { function StyleChip() { const citationStyle = useReferenceStore((s) => s.citationStyle); const setCitationStyle = useReferenceStore((s) => s.setCitationStyle); + const display = useReferenceStore((s) => s.citationDisplay); + const toggleDisplay = useReferenceStore((s) => s.toggleCitationDisplay); const [open, setOpen] = useState(false); const [pos, setPos] = useState<{ top: number; left: number } | null>(null); const anchorRef = useRef(null); @@ -279,6 +247,13 @@ function StyleChip() { const editionLabel = STYLE_EDITION[citationStyle.toLowerCase()] ?? ""; + const setDisplay = useCallback( + (mode: "chip" | "footnote") => { + if (display !== mode) toggleDisplay(); + }, + [display, toggleDisplay] + ); + useEffect(() => { if (!open) return; const onDown = (event: MouseEvent) => { @@ -294,7 +269,7 @@ function StyleChip() { useEffect(() => { if (!open || !anchorRef.current) return; const r = anchorRef.current.getBoundingClientRect(); - const menuWidth = 180; + const menuWidth = 200; setPos({ top: r.bottom + 6, left: Math.max(8, r.right - menuWidth), @@ -332,8 +307,43 @@ function StyleChip() { left: pos.left, zIndex: 9999, }} - className="menu-surface w-[180px] py-1 animate-fade-in" + className="menu-surface w-[200px] py-1 animate-fade-in" > + {/* Display group — flips between in-line @key chips and + superscript-numbered footnote markers in the editor. + The PDF output is unaffected; this is purely how citations + render on screen. */} +
+ Display +
+ + + +
+ +
+ Style +
{ordered.map((name, i) => { const active = name === citationStyle; return ( diff --git a/src/components/PdfPreview.tsx b/src/components/PdfPreview.tsx index 1655b3d..d328f2e 100644 --- a/src/components/PdfPreview.tsx +++ b/src/components/PdfPreview.tsx @@ -298,10 +298,10 @@ export function PdfPreview({ collapsed, onToggleCollapse, panelWidth, isResizing
@@ -389,18 +389,18 @@ export function PdfPreview({ collapsed, onToggleCollapse, panelWidth, isResizing
{/* Zoom controls */} -
+
@@ -428,7 +428,7 @@ export function PdfPreview({ collapsed, onToggleCollapse, panelWidth, isResizing {/* Collapse */}
-
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index a628c67..d3d9906 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -249,7 +249,7 @@ export function Sidebar({
diff --git a/src/index.css b/src/index.css index 0c1508d..3acaab2 100644 --- a/src/index.css +++ b/src/index.css @@ -570,8 +570,10 @@ textarea { border-radius: var(--radius-md); } -/* Icon button — square, transparent, 28×28. Distinct from .btn family - because it carries no text and lives in toolbars/headers. */ +/* Icon button — square, transparent, 28×28 by default. Distinct from + the .btn family because it carries no text and lives in toolbars, + panel headers, and activity bars. Compose with `.icon-btn-sm` for + the dense 24 px header variant or `.is-active` for selected state. */ .icon-btn { display: inline-flex; align-items: center; @@ -579,10 +581,12 @@ textarea { width: 28px; height: 28px; border-radius: var(--radius-md); + background: transparent; color: var(--text-tertiary); transition: background-color var(--transition-fast), color var(--transition-fast); cursor: pointer; + flex-shrink: 0; } .icon-btn:hover { @@ -594,6 +598,28 @@ textarea { background: var(--bg-active); } +.icon-btn:disabled, +.icon-btn[aria-disabled="true"] { + opacity: 0.4; + cursor: not-allowed; +} + +/* Selected variant — used by the activity bar's current tab and by any + other "this is on" affordance that lives in a chrome surface. */ +.icon-btn.is-active, +.icon-btn.is-active:hover { + background: var(--accent-light); + color: var(--accent-dark); +} + +/* Smaller variant for dense headers (sidebar header, PDF toolbar). 24 px + matches the Mac OS toolbar baseline and gives a tighter row. */ +.icon-btn-sm { + width: 24px; + height: 24px; + border-radius: var(--radius-sm); +} + /* ═══════════════════════════════════════ Cards & Inputs ═══════════════════════════════════════ */ From fb7b9cdd2ca5e5f3c3cfd29c57e5edfd253d27a8 Mon Sep 17 00:00:00 2001 From: pacer Date: Sat, 9 May 2026 11:55:23 +0800 Subject: [PATCH 5/7] =?UTF-8?q?style(ui):=20phase=204=20=E2=80=94=20chrome?= =?UTF-8?q?=20de-clutter=20(status=20bar,=20sidebar,=20toolbars,=20refs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Status bar - Drop the rotating-tips carousel. It competed with status, word count, update notice, and version on a 32 px bar for a tutorial benefit that wears off after the first session. Sidebar - Header drops the gradient Sparkles logo and the dedicated X (close-workspace) button. The workspace name itself is now a click target opening a small Workspace menu that houses Switch / Close. - Header height 44 → 36 px to match the editor and PDF toolbars. - Drop the bottom footer entirely. Switch-workspace lives in the new Workspace menu; the empty-state already has its own Open button. Editor toolbar - Toolbar height 44 → 36 px; toolbar buttons 28 → 26 px; style chip 26 → 24 px so it stays inside the slimmer row. PDF preview - Toolbar height h-11 → h-9. Both the main and simple-state toolbars now share bg-secondary + border-light to match the rest of the chrome. References panel (folds in C1: single citation-style picker) - Drop the dominant ref-style-card and its dropdown — that picker now lives only in the editor toolbar's StyleChip. The active style is shown read-only inline with the count above the cards. - Sidebar no longer threads styleDropdownOpen / setStyleDropdownOpen / styleDropdownRef / setCitationStyle through to the panel. - Remove the now-dead .ref-style-* CSS rules. Cover-page dialog - Drop the gradient L logo + COVER PAGE tag stripe in the header. The modal context already says what it is; the title alone is enough. Co-Authored-By: Claude Opus 4.7 --- src/components/CoverPageDialog.tsx | 21 ++-- src/components/PdfPreview.tsx | 4 +- src/components/ReferencesPanel.tsx | 88 +++-------------- src/components/Sidebar.tsx | 153 +++++++++++++++++++---------- src/components/StatusBar.tsx | 39 +------- src/index.css | 89 +---------------- 6 files changed, 136 insertions(+), 258 deletions(-) diff --git a/src/components/CoverPageDialog.tsx b/src/components/CoverPageDialog.tsx index 67fcb2f..2b2b34f 100644 --- a/src/components/CoverPageDialog.tsx +++ b/src/components/CoverPageDialog.tsx @@ -87,16 +87,15 @@ export function CoverPageDialog({ open, block, editor, onClose }: Props) { }} >
+ {/* Header — single line with the dialog name on the left and the + preview-toggle + close button on the right. The earlier + decorative L logo + tag + subtitle stripe was three pieces of + chrome conveying one fact ("you're in the cover-page editor"), + which is already obvious from the modal context. */}
-
- -
-
{t("cover.tag")}
-
- {t("cover.dialogSubtitle")} -
+
+
+ {t("cover.dialogSubtitle")}
@@ -112,11 +111,11 @@ export function CoverPageDialog({ open, block, editor, onClose }: Props) {
diff --git a/src/components/PdfPreview.tsx b/src/components/PdfPreview.tsx index d328f2e..5f5e5dd 100644 --- a/src/components/PdfPreview.tsx +++ b/src/components/PdfPreview.tsx @@ -376,7 +376,7 @@ export function PdfPreview({ collapsed, onToggleCollapse, panelWidth, isResizing return (
{/* Toolbar */} -
+
{t("pdf.preview")} @@ -574,7 +574,7 @@ function PdfToolbarSimple({ collapseTitle: string; }) { return ( -
+
{icon || } {label} diff --git a/src/components/ReferencesPanel.tsx b/src/components/ReferencesPanel.tsx index 87ad5dc..1ceb182 100644 --- a/src/components/ReferencesPanel.tsx +++ b/src/components/ReferencesPanel.tsx @@ -1,10 +1,5 @@ import { useState, useMemo, useCallback } from "react"; -import { BookOpen, Search, ChevronDown, Plus, Pencil, Trash2, Quote, FileUp } from "lucide-react"; -import { - getOrderedStyleNames, - getStyleDescription, - PRIMARY_STYLES, -} from "../lib/citation/registry"; +import { BookOpen, Search, Plus, Pencil, Trash2, Quote, FileUp } from "lucide-react"; import { formatCitationPreview, formatEntryMeta } from "../lib/citation-search"; import { useT } from "../lib/i18n"; import { useReferenceStore } from "../stores/reference-store"; @@ -18,10 +13,6 @@ interface ReferencesPanelProps { searchQuery: string; setSearchQuery: (q: string) => void; citationStyle: string; - setCitationStyle: (s: string) => void; - styleDropdownOpen: boolean; - setStyleDropdownOpen: (v: boolean) => void; - styleDropdownRef: React.RefObject; formatter: CitationFormatter; activeDocumentPath: string | null; onInsertCitation: (key: string) => void; @@ -65,10 +56,6 @@ export function ReferencesPanel({ searchQuery, setSearchQuery, citationStyle, - setCitationStyle, - styleDropdownOpen, - setStyleDropdownOpen, - styleDropdownRef, formatter, activeDocumentPath, onInsertCitation, @@ -163,9 +150,10 @@ export function ReferencesPanel({ return ( <> - {/* Toolbar: import + add → search → style card. The trio is the - information density at the top of the panel — every action a user - starts with happens here. */} + {/* Toolbar: import + add → search. The citation-style picker that + previously lived here as a big card has moved to its sole home + on the editor toolbar (StyleChip). The current style is shown + inline with the count above the cards below. */}
)} - - {entries.length > 0 && ( -
- - - {styleDropdownOpen && ( -
- {getOrderedStyleNames().map((s, i) => ( -
- {i === PRIMARY_STYLES.length && ( -
- )} - -
- ))} -
- )} -
- )}
{/* List body */} @@ -291,13 +223,21 @@ export function ReferencesPanel({
)} - {/* Counter row */} + {/* Combined counter + active-style indicator. The style is + read-only here; users change it via the editor toolbar's + StyleChip (the dropdown there is the canonical picker). */}
{displayedEntries.length} {" / "} {entries.length} {t("refs.references")} + + + + {citationStyle.toUpperCase()} + +
{/* Citation cards */} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index d3d9906..b20c450 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -4,15 +4,15 @@ import { FilePlus, FolderPlus, BookOpen, + ChevronDown, ChevronsLeft, ChevronsRight, - Sparkles, FolderOpen, + LogOut, Plus, FileUp, FolderTree, Settings, - X, } from "lucide-react"; import { open } from "@tauri-apps/plugin-dialog"; import { invoke } from "@tauri-apps/api/core"; @@ -54,18 +54,18 @@ export function Sidebar({ const searchQuery = useReferenceStore((s) => s.searchQuery); const setSearchQuery = useReferenceStore((s) => s.setSearchQuery); const citationStyle = useReferenceStore((s) => s.citationStyle); - const setCitationStyle = useReferenceStore((s) => s.setCitationStyle); const setFromRaw = useReferenceStore((s) => s.setFromRaw); const t = useT(); const [activeTab, setActiveTab] = useState("files"); const [showNewMenu, setShowNewMenu] = useState(false); - const [styleDropdownOpen, setStyleDropdownOpen] = useState(false); + const [showWorkspaceMenu, setShowWorkspaceMenu] = useState(false); const [newItemInput, setNewItemInput] = useState<"document" | "folder" | null>(null); const [newItemName, setNewItemName] = useState(""); const newMenuRef = useRef(null); const newMenuPortalRef = useRef(null); - const styleDropdownRef = useRef(null); + const workspaceMenuRef = useRef(null); + const workspaceMenuPortalRef = useRef(null); const newItemInputRef = useRef(null); const formatter = useMemo(() => getFormatter(citationStyle), [citationStyle]); @@ -82,7 +82,7 @@ export function Sidebar({ // Close dropdowns on outside click useEffect(() => { - if (!showNewMenu && !styleDropdownOpen) return; + if (!showNewMenu && !showWorkspaceMenu) return; const handle = (e: MouseEvent) => { if ( showNewMenu && @@ -93,16 +93,17 @@ export function Sidebar({ setShowNewMenu(false); } if ( - styleDropdownOpen && - styleDropdownRef.current && - !styleDropdownRef.current.contains(e.target as Node) + showWorkspaceMenu && + workspaceMenuRef.current && + !workspaceMenuRef.current.contains(e.target as Node) && + (!workspaceMenuPortalRef.current || !workspaceMenuPortalRef.current.contains(e.target as Node)) ) { - setStyleDropdownOpen(false); + setShowWorkspaceMenu(false); } }; document.addEventListener("mousedown", handle); return () => document.removeEventListener("mousedown", handle); - }, [showNewMenu, styleDropdownOpen]); + }, [showNewMenu, showWorkspaceMenu]); // Focus new item input useEffect(() => { @@ -234,15 +235,40 @@ export function Sidebar({ {/* Panel content */}
- {/* Header */} -
-
-
- -
- - {workspaceName || "LexTyp"} - + {/* Header — 36 px tall to match the editor and PDF toolbars; the + workspace name doubles as the trigger for switch/close so the + close-workspace X button (and the standalone footer below) can + both go away. */} +
+
+ {workspacePath ? ( + + ) : ( + + LexTyp + + )} + {showWorkspaceMenu && workspacePath && ( + { + setShowWorkspaceMenu(false); + handleOpenWorkspace(); + }} + onClose={() => { + setShowWorkspaceMenu(false); + closeWorkspace().catch(console.error); + }} + /> + )}
{workspacePath && activeTab === "files" && ( @@ -263,15 +289,6 @@ export function Sidebar({ />}
)} - {workspacePath && ( - - )} - ) : activeTab === "files" ? ( - - ) : null} -
); @@ -446,3 +439,57 @@ function NewMenuDropdown({ document.body ); } + +/* ─── Workspace dropdown — opened from clicking the workspace name in + the header. Houses the actions that previously lived in the sidebar + footer (Switch workspace) and the standalone X button (Close + workspace). ─── */ + +function WorkspaceMenuDropdown({ + anchorRef, + portalRef, + onSwitch, + onClose, +}: { + anchorRef: React.RefObject; + portalRef: React.RefObject; + onSwitch: () => void; + onClose: () => void; +}) { + const t = useT(); + const [pos, setPos] = useState<{ top: number; left: number } | null>(null); + + useEffect(() => { + const el = anchorRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + setPos({ top: rect.bottom + 4, left: rect.left }); + }, [anchorRef]); + + if (!pos) return null; + + return createPortal( +
+ +
+ +
, + document.body + ); +} diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index ece04b8..ad61bf5 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -1,23 +1,10 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { invoke } from "@tauri-apps/api/core"; import { useAppStore } from "../stores/app-store"; import { useWorkspaceStore } from "../stores/workspace-store"; -import { Circle, Loader2, AlertCircle, CheckCircle, Lightbulb, ArrowDownCircle, X } from "lucide-react"; +import { Circle, Loader2, AlertCircle, CheckCircle, ArrowDownCircle, X } from "lucide-react"; import { useT } from "../lib/i18n"; -const TIP_KEYS = [ - "tip.slash", - "tip.citationStyle", - "tip.pdfDoubleClick", - "tip.collapsePanel", - "tip.outline", - "tip.darkMode", - "tip.bold", - "tip.bibImport", -] as const; - -const TIP_INTERVAL = 15_000; // rotate every 15 seconds - export function StatusBar() { const t = useT(); const compiling = useAppStore((s) => s.compiling); @@ -31,16 +18,6 @@ export function StatusBar() { const totalWordCount = useAppStore((s) => s.totalWordCount); const activeDocumentPath = useWorkspaceStore((s) => s.activeDocumentPath); - // --- Rotating tips --- - const [tipIndex, setTipIndex] = useState(() => Math.floor(Math.random() * TIP_KEYS.length)); - - useEffect(() => { - const timer = setInterval(() => { - setTipIndex((i) => (i + 1) % TIP_KEYS.length); - }, TIP_INTERVAL); - return () => clearInterval(timer); - }, []); - // --- Silent background update check --- useEffect(() => { let cancelled = false; @@ -68,20 +45,14 @@ export function StatusBar() { return (
- {/* Left side - Status */} + {/* Left side — compile status only. The previous rotating-tips + carousel competed with the four other widgets on this 32px bar + for a tutorial benefit that wears off after the first session. */}
{statusConfig.icon} {statusConfig.text}
- - {/* Tip */} -
- - - {t(TIP_KEYS[tipIndex])} - -
{/* Right side - Word count + Update + App info (citation style chip lives in the editor toolbar) */} diff --git a/src/index.css b/src/index.css index 3acaab2..75fff2b 100644 --- a/src/index.css +++ b/src/index.css @@ -926,85 +926,6 @@ textarea { color: var(--text-tertiary); } -/* Style card — the dominant control under the toolbar. Click expands the - dropdown of available styles in the same surface. */ -.ref-style-card { - width: 100%; - display: flex; - align-items: center; - gap: 10px; - padding: 8px 10px; - background: var(--bg-elevated); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - text-align: left; - cursor: pointer; - transition: border-color var(--transition-fast), - background-color var(--transition-fast), - box-shadow var(--transition-fast); -} - -.ref-style-card:hover { - border-color: var(--border-hover); - background: var(--bg-hover); -} - -.ref-style-card.is-open { - border-color: var(--accent); - background: var(--bg-elevated); - box-shadow: var(--shadow-focus); -} - -.ref-style-icon { - flex-shrink: 0; - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-md); - background: var(--accent-light); - color: var(--accent); -} - -.ref-style-meta { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; -} - -.ref-style-name { - font-family: var(--font-ui); - font-size: 12px; - font-weight: 700; - letter-spacing: 0.06em; - color: var(--text-primary); - line-height: 1.15; -} - -.ref-style-desc { - font-family: var(--font-ui); - font-size: 11px; - color: var(--text-tertiary); - line-height: 1.3; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.ref-style-chevron { - flex-shrink: 0; - color: var(--text-tertiary); - transition: transform var(--transition-fast), color var(--transition-fast); -} - -.ref-style-card.is-open .ref-style-chevron { - transform: rotate(180deg); - color: var(--accent); -} - /* Type pills — segmented filter under the search. Each carries a count that shrinks slightly inside the active pill to keep the label dominant. */ .ref-pill { @@ -1538,7 +1459,7 @@ textarea { .editor-toolbar { flex-shrink: 0; - height: 44px; + height: 36px; background: var(--bg-secondary); border-bottom: 1px solid var(--border-light); } @@ -1571,8 +1492,8 @@ textarea { display: inline-flex; align-items: center; justify-content: center; - width: 28px; - height: 28px; + width: 26px; + height: 26px; border-radius: var(--radius-md); color: var(--text-secondary); background: transparent; @@ -1587,7 +1508,7 @@ textarea { } .editor-toolbar-btn-text { - width: 28px; + width: 26px; padding: 0; font-family: var(--font-ui); font-size: 11px; @@ -1604,7 +1525,7 @@ textarea { display: inline-flex; align-items: center; gap: 6px; - height: 26px; + height: 24px; padding: 0 6px 0 9px; border-radius: var(--radius-md); background: transparent; From ce0aa137be12c1445692ae09980b65e49c51cef9 Mon Sep 17 00:00:00 2001 From: pacer Date: Sat, 9 May 2026 11:58:43 +0800 Subject: [PATCH 6/7] =?UTF-8?q?style(ui):=20phase=205=20=E2=80=94=20unify?= =?UTF-8?q?=20empty=20states=20(C7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app had five "nothing here yet" prompts (App empty, Sidebar no-workspace, PdfPreview empty, PdfPreview compiling, References need-document, References no-refs) each rolling its own dashed-border box at different sizes, fonts, paddings, and button treatments. Extract a shared shell and route every site through it. - New components/EmptyState.tsx — 56 px tertiary-bg icon container, 13 px primary title, 12 px tertiary description, optional CTA slot. No dashed borders, no second-grey panels. - App.tsx no-document state uses EmptyState (Sparkles "Open example" CTA preserved). - Sidebar no-workspace state uses EmptyState; the previous loud btn-primary btn-lg drops to btn-soft to match the editor empty state's CTA weight. - PdfPreview compiling and waiting states both use EmptyState. The decorative dashed PDF stub box is gone. - ReferencesPanel needDocument and noRefs states both use EmptyState. Picker-and-empty-state dedup work (originally split between phase 4 and phase 5) is now complete. Co-Authored-By: Claude Opus 4.7 --- src/App.tsx | 47 +++++++++++++---------------- src/components/EmptyState.tsx | 48 ++++++++++++++++++++++++++++++ src/components/PdfPreview.tsx | 40 ++++++++----------------- src/components/ReferencesPanel.tsx | 30 ++++++++----------- src/components/Sidebar.tsx | 31 ++++++++----------- 5 files changed, 106 insertions(+), 90 deletions(-) create mode 100644 src/components/EmptyState.tsx diff --git a/src/App.tsx b/src/App.tsx index ea3d018..a5856c7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { useCallback, useState, useEffect, useRef } from "react"; import { openUrl } from "@tauri-apps/plugin-opener"; import { PencilLine, Sparkles } from "lucide-react"; import { Editor } from "./components/Editor"; +import { EmptyState as EmptyStateShell } from "./components/EmptyState"; import { PdfPreview } from "./components/PdfPreview"; import { StatusBar } from "./components/StatusBar"; import { Sidebar } from "./components/Sidebar"; @@ -271,32 +272,26 @@ function EmptyState() { }, [workspacePath, createDocumentFromTemplate, openingExample]); return ( -
-
- -
-
-

- {t("editor.noDocument")} -

-

- {t("editor.noDocumentHint")} -

-
- {workspacePath && ( - - )} -
+ } + title={t("editor.noDocument")} + description={t("editor.noDocumentHint")} + cta={ + workspacePath ? ( + + ) : undefined + } + /> ); } diff --git a/src/components/EmptyState.tsx b/src/components/EmptyState.tsx new file mode 100644 index 0000000..1c2f067 --- /dev/null +++ b/src/components/EmptyState.tsx @@ -0,0 +1,48 @@ +import type { ReactNode } from "react"; + +/** + * Shared empty-state shell. Three places in the app show "nothing here + * yet" prompts (no workspace, no document, no PDF) and they previously + * each rolled their own dashed-border boxes at different sizes, fonts, + * and button treatments. This component standardises the visual scale + * so they read as the same kind of moment, even when the content + * differs. + * + * The icon prop receives the icon node (sized however the caller likes, + * but 22 px works best inside the 56 px container). The CTA is optional + * — leave it out for purely informative states. + */ +export function EmptyState({ + icon, + title, + description, + cta, + className = "", +}: { + icon: ReactNode; + title: ReactNode; + description?: ReactNode; + cta?: ReactNode; + className?: string; +}) { + return ( +
+
+ {icon} +
+
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+ {cta &&
{cta}
} +
+ ); +} diff --git a/src/components/PdfPreview.tsx b/src/components/PdfPreview.tsx index 5f5e5dd..b910c9d 100644 --- a/src/components/PdfPreview.tsx +++ b/src/components/PdfPreview.tsx @@ -17,6 +17,7 @@ import { writeFile } from "@tauri-apps/plugin-fs"; import { useAppStore } from "../stores/app-store"; import type { SourceMapEntry } from "../stores/app-store"; import { useT } from "../lib/i18n"; +import { EmptyState } from "./EmptyState"; pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`; @@ -318,33 +319,18 @@ export function PdfPreview({ collapsed, onToggleCollapse, panelWidth, isResizing return (
-
- {compiling ? ( -
-
- -
- - {t("pdf.compiling")} - -
- ) : ( - <> -
- - PDF -
-
- - {t("pdf.willAppear")} - - - {t("pdf.startTyping")} - -
- - )} -
+ {compiling ? ( + } + title={t("pdf.compiling")} + /> + ) : ( + } + title={t("pdf.willAppear")} + description={t("pdf.startTyping")} + /> + )}
); } diff --git a/src/components/ReferencesPanel.tsx b/src/components/ReferencesPanel.tsx index 1ceb182..79d812c 100644 --- a/src/components/ReferencesPanel.tsx +++ b/src/components/ReferencesPanel.tsx @@ -1,5 +1,6 @@ import { useState, useMemo, useCallback } from "react"; import { BookOpen, Search, Plus, Pencil, Trash2, Quote, FileUp } from "lucide-react"; +import { EmptyState } from "./EmptyState"; import { formatCitationPreview, formatEntryMeta } from "../lib/citation-search"; import { useT } from "../lib/i18n"; import { useReferenceStore } from "../stores/reference-store"; @@ -125,15 +126,11 @@ export function ReferencesPanel({ if (!activeDocumentPath) { return ( -
- -

- {t("refs.needDocument")} -

-

- {t("refs.needDocumentHint")} -

-
+ } + title={t("refs.needDocument")} + description={t("refs.needDocumentHint")} + /> ); } @@ -189,15 +186,12 @@ export function ReferencesPanel({ {/* List body */}
{entries.length === 0 ? ( -
- -

- {t("refs.noRefs")} -

-

- {t("refs.noRefsHint")} -

-
+ } + title={t("refs.noRefs")} + description={t("refs.noRefsHint")} + className="py-6" + /> ) : ( <> {/* Type filter pills with counts */} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index b20c450..ad3a2a0 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -21,6 +21,7 @@ import { useReferenceStore } from "../stores/reference-store"; import { getFormatter } from "../lib/citation/registry"; import { filterBibEntries } from "../lib/citation-search"; import { useT } from "../lib/i18n"; +import { EmptyState } from "./EmptyState"; import { FilesPanel } from "./FilesPanel"; import { ReferencesPanel } from "./ReferencesPanel"; import { SettingsPanel } from "./SettingsPanel"; @@ -304,25 +305,17 @@ export function Sidebar({ {activeTab === "settings" ? ( ) : !workspacePath ? ( -
-
- -
-
-

- {t("sidebar.noWorkspace")} -

-

- {t("sidebar.noWorkspaceHint")} -

-
- -
+ } + title={t("sidebar.noWorkspace")} + description={t("sidebar.noWorkspaceHint")} + cta={ + + } + /> ) : activeTab === "files" ? ( Date: Sat, 9 May 2026 13:58:40 +0800 Subject: [PATCH 7/7] fix(ui): restore status-bar tips, jump highlight, icon contrast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Status bar - Restore the rotating-tips carousel dropped in fb7b9cd. The bar still reads cleanly with the tip beside the compile-status pill, and new users were losing the only in-app discovery surface for slash menu / outline / dark-mode / .bib import / Ctrl+B etc. - Update tip.citationStyle (en + zh) to point at the editor toolbar — the StyleChip moved out of the status bar in fb7b9cd. PDF → editor jump highlight - The flash overlay was landing on the wrong row. flashCurrentLine appends an absolutely-positioned .lextyp-line-highlight to the editor's scroll container with coordinates computed against that container's scroll origin (scrollTop + contentRect.top - containerRect.top + …), but the container had no `position` set, so the overlay resolved against a higher ancestor and ended up ~chrome-height off. Add `relative` to the scroll div so the math resolves correctly. - Drop the four diagnostic console.warn calls in handleDoubleClick from c1e6e03 — they served their purpose; early-return logic stays. Icon contrast - .icon-btn resting color: --text-tertiary → --text-secondary. Toolbar / sidebar / panel buttons jump from Stone-400 (~2.5:1 against panel surfaces) to Stone-600 (~7:1) without touching the hover state. - --text-tertiary itself: Stone-400 → Stone-500 in light, mirrored one-step lift (#6B6560 → #7A736D) in dark, so captions, hints, placeholders, and decoration-tier icons all step one shade more legible while keeping a real gap below secondary so the tier hierarchy doesn't collapse. Co-Authored-By: Claude Opus 4.7 --- src/components/Editor.tsx | 7 ++++++- src/components/PdfPreview.tsx | 26 ++++-------------------- src/components/StatusBar.tsx | 38 ++++++++++++++++++++++++++++++----- src/index.css | 19 +++++++++++++----- src/lib/i18n.ts | 4 ++-- 5 files changed, 59 insertions(+), 35 deletions(-) diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 02e1352..44411b3 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -667,7 +667,12 @@ export function Editor() { editor={editor} onInsertCitation={openCitationPicker} /> -
e.preventDefault()}> + {/* `relative` is required: flashCurrentLine appends an absolutely- + positioned overlay to this element with coordinates computed + relative to its scroll origin. Without a positioned ancestor + here, the overlay would resolve against the viewport (or some + ancestor) and land on the wrong row. */} +
e.preventDefault()}>
void) | undefined; - if (!jumpFn) { - console.warn("[PdfPreview] dblclick: no __lextyp_jumpToBlock registered"); - return; - } + if (!jumpFn) return; // react-pdf sets data-page-number on the inner
. const pageEl = (e.target as HTMLElement | null)?.closest?.( "[data-page-number]" ) as HTMLElement | null; - if (!pageEl) { - console.warn("[PdfPreview] dblclick: no [data-page-number] ancestor for", e.target); - return; - } + if (!pageEl) return; const pageNum = parseInt(pageEl.getAttribute("data-page-number") ?? "0", 10); if (!pageNum) return; - if (!sourceMap.length) { - console.warn("[PdfPreview] dblclick: source map is empty — compile may not have completed"); - return; - } + if (!sourceMap.length) return; // Click position in points. `getBoundingClientRect` returns the // visually-transformed rect, which already includes any css zoom scale. @@ -277,16 +268,7 @@ export function PdfPreview({ collapsed, onToggleCollapse, panelWidth, isResizing const clickYPt = (e.clientY - pageRect.top) / pxPerPt; const target = findNearestEntry(sourceMap, pageNum, clickXPt, clickYPt); - if (!target) { - console.warn( - "[PdfPreview] dblclick: no source-map entry on page", - pageNum, - "(map has", - sourceMap.length, - "entries)" - ); - return; - } + if (!target) return; jumpFn(target.id, target.off); }, diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index ad61bf5..f9a847e 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -1,10 +1,23 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { useAppStore } from "../stores/app-store"; import { useWorkspaceStore } from "../stores/workspace-store"; -import { Circle, Loader2, AlertCircle, CheckCircle, ArrowDownCircle, X } from "lucide-react"; +import { Circle, Loader2, AlertCircle, CheckCircle, Lightbulb, ArrowDownCircle, X } from "lucide-react"; import { useT } from "../lib/i18n"; +const TIP_KEYS = [ + "tip.slash", + "tip.citationStyle", + "tip.pdfDoubleClick", + "tip.collapsePanel", + "tip.outline", + "tip.darkMode", + "tip.bold", + "tip.bibImport", +] as const; + +const TIP_INTERVAL = 15_000; // rotate every 15 seconds + export function StatusBar() { const t = useT(); const compiling = useAppStore((s) => s.compiling); @@ -18,6 +31,16 @@ export function StatusBar() { const totalWordCount = useAppStore((s) => s.totalWordCount); const activeDocumentPath = useWorkspaceStore((s) => s.activeDocumentPath); + // --- Rotating tips --- + const [tipIndex, setTipIndex] = useState(() => Math.floor(Math.random() * TIP_KEYS.length)); + + useEffect(() => { + const timer = setInterval(() => { + setTipIndex((i) => (i + 1) % TIP_KEYS.length); + }, TIP_INTERVAL); + return () => clearInterval(timer); + }, []); + // --- Silent background update check --- useEffect(() => { let cancelled = false; @@ -45,14 +68,19 @@ export function StatusBar() { return (
- {/* Left side — compile status only. The previous rotating-tips - carousel competed with the four other widgets on this 32px bar - for a tutorial benefit that wears off after the first session. */} + {/* Left side - Status + rotating tip */}
{statusConfig.icon} {statusConfig.text}
+ +
+ + + {t(TIP_KEYS[tipIndex])} + +
{/* Right side - Word count + Update + App info (citation style chip lives in the editor toolbar) */} diff --git a/src/index.css b/src/index.css index 75fff2b..4cdf6d6 100644 --- a/src/index.css +++ b/src/index.css @@ -27,10 +27,14 @@ --bg-active: rgba(0, 0, 0, 0.06); --bg-sunken: #F0EEEA; - /* Text — strong hierarchy through opacity */ + /* Text — strong hierarchy through opacity. + Ladder is Stone-900 / 600 / 500 / ~350 — tertiary used to sit at Stone-400 + (#A8A29E) but read as washed-out at chrome icon sizes (~2.5:1 against + panel surfaces). Stone-500 lifts contrast to ~5:1 without crossing into + secondary territory. */ --text-primary: #1C1917; --text-secondary: #57534E; - --text-tertiary: #A8A29E; + --text-tertiary: #78716C; --text-muted: #C4BBB2; --text-inverse: #FAFAF8; @@ -86,10 +90,11 @@ --bg-active: rgba(255, 255, 255, 0.08); --bg-sunken: #131110; - /* Text */ + /* Text — mirrors the light-mode lift on tertiary (one Stone step brighter) + so chrome captions/icons stay legible against the dark panels. */ --text-primary: #F5F1ED; --text-secondary: #A8A29E; - --text-tertiary: #6B6560; + --text-tertiary: #7A736D; --text-muted: #4A453F; --text-inverse: #171412; @@ -582,7 +587,11 @@ textarea { height: 28px; border-radius: var(--radius-md); background: transparent; - color: var(--text-tertiary); + /* Resting color is `--text-secondary`, not tertiary. Tertiary is the + "decoration" tier (status dot, tip lightbulb, file-tree chevrons); + icon-btn is the actionable tier and needs a stronger weight so the + row doesn't read as a wash of pale strokes. */ + color: var(--text-secondary); transition: background-color var(--transition-fast), color var(--transition-fast); cursor: pointer; diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index b6ed88d..465243a 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -110,7 +110,7 @@ const translations = { // Status bar tips "tip.slash": "Type / to open the slash menu and insert headings, lists, or citations", - "tip.citationStyle": "Switch citation styles anytime from the chip in the status bar", + "tip.citationStyle": "Switch citation styles anytime from the chip in the editor toolbar", "tip.pdfDoubleClick": "Double-click the PDF preview to jump to that paragraph in the editor", "tip.collapsePanel": "Collapse the sidebar or PDF panel for distraction-free writing", "tip.outline": "Use the outline button at the bottom-left to navigate long documents", @@ -282,7 +282,7 @@ const translations = { // Status bar tips "tip.slash": "输入 / 打开斜杠菜单,插入标题、列表或引用", - "tip.citationStyle": "可在状态栏的引注样式标签随时切换", + "tip.citationStyle": "可在编辑器工具栏的引注样式标签随时切换", "tip.pdfDoubleClick": "双击 PDF 预览可跳转到编辑器中的对应段落", "tip.collapsePanel": "收起侧边栏或 PDF 面板,获得无干扰的写作体验", "tip.outline": "使用左下角的大纲按钮浏览长文档",