mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 11:07:04 +00:00
changes
This commit is contained in:
1 parent
11df8697b1
commit
c090fb595f
61 files changed
+52534
-107
No files matched your search
+6
-1
@@ -4,8 +4,10 @@ import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { Library } from "./components/Library";
|
||||
import { EditorView } from "./components/EditorView";
|
||||
import { BackupSettings } from "./components/BackupSettings";
|
||||
import { ExportPreview } from "./components/ExportPreview";
|
||||
import { useBook } from "./store/useBook";
|
||||
import { useBackup } from "./store/useBackup";
|
||||
import { useExportPreview } from "./store/useExportPreview";
|
||||
import { isDesktop } from "./ipc";
|
||||
import { createAndOpenBook, saveBook } from "./library";
|
||||
import { runExport } from "./export/run";
|
||||
@@ -21,7 +23,9 @@ function App() {
|
||||
const unlisten = listen<string>("menu-action", (event) => {
|
||||
const state = useBook.getState();
|
||||
if (event.payload === "new-book") createAndOpenBook(state.openBook, state.setNotice);
|
||||
else if (event.payload === "export-pdf") runExport("pdf");
|
||||
else if (event.payload === "export-pdf") {
|
||||
if (useBook.getState().book) useExportPreview.getState().openPreview();
|
||||
}
|
||||
else if (event.payload === "export-epub") runExport("epub");
|
||||
else if (event.payload === "check-updates") checkForUpdates(false);
|
||||
});
|
||||
@@ -82,6 +86,7 @@ function App() {
|
||||
<>
|
||||
{book ? <EditorView /> : <Library onOpen={openBook} />}
|
||||
{isDesktop && <BackupSettings />}
|
||||
{isDesktop && <ExportPreview />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+154
-57
@@ -14,6 +14,7 @@ import { FloatingToolbar } from "../editor/FloatingToolbar";
|
||||
import type { ProofCoords, ProofIssue, ProofingStorage } from "../editor/proofing";
|
||||
import { COVER_ID, useBook } from "../store/useBook";
|
||||
import { useBackup } from "../store/useBackup";
|
||||
import { useExportPreview } from "../store/useExportPreview";
|
||||
import { useProofing } from "../store/useProofing";
|
||||
import { useTheme } from "../store/useTheme";
|
||||
import { useWidth } from "../store/useWidth";
|
||||
@@ -21,6 +22,7 @@ import { WIDTH_OPTIONS } from "../width";
|
||||
import { bodyNumber, chapterKind, partNumber, partRoman } from "../model/book";
|
||||
import { saveBook } from "../library";
|
||||
import { isDesktop } from "../ipc";
|
||||
import { useCompact } from "../useMedia";
|
||||
import { issueSignature, rememberWord, runProof } from "../proofing";
|
||||
import { runExport } from "../export/run";
|
||||
|
||||
@@ -40,10 +42,13 @@ export function EditorView() {
|
||||
const width = useWidth((s) => s.width);
|
||||
const setWidth = useWidth((s) => s.setWidth);
|
||||
|
||||
const isCompact = useCompact();
|
||||
const [editor, setEditor] = useState<TiptapEditor | null>(null);
|
||||
const [dock, setDock] = useState(true);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(!isCompact);
|
||||
const [dock, setDock] = useState(!isCompact);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [widthOpen, setWidthOpen] = useState(false);
|
||||
const [findOpen, setFindOpen] = useState(false);
|
||||
const [findExpanded, setFindExpanded] = useState(false);
|
||||
@@ -58,6 +63,21 @@ export function EditorView() {
|
||||
const coverActive = activeChapterId === COVER_ID;
|
||||
const proofingAvailable = isDesktop && book?.metadata.language === "en";
|
||||
|
||||
const toggleSidebar = () =>
|
||||
setSidebarOpen((open) => {
|
||||
if (!open && isCompact) setDock(false);
|
||||
return !open;
|
||||
});
|
||||
const toggleDock = () =>
|
||||
setDock((open) => {
|
||||
if (!open && isCompact) setSidebarOpen(false);
|
||||
return !open;
|
||||
});
|
||||
const closeDrawers = () => {
|
||||
setSidebarOpen(false);
|
||||
setDock(false);
|
||||
};
|
||||
|
||||
const openFind = useCallback((expanded: boolean) => {
|
||||
setFindExpanded(expanded);
|
||||
setFindOpen(true);
|
||||
@@ -112,6 +132,11 @@ export function EditorView() {
|
||||
return () => clearTimeout(timer);
|
||||
}, [notice]);
|
||||
|
||||
useEffect(() => {
|
||||
setSidebarOpen(!isCompact);
|
||||
setDock(!isCompact);
|
||||
}, [isCompact]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const storage = (editor.storage as unknown as Record<string, ProofingStorage>).proofing;
|
||||
@@ -147,7 +172,8 @@ export function EditorView() {
|
||||
|
||||
const handleExport = (format: "pdf" | "epub") => {
|
||||
setExportOpen(false);
|
||||
runExport(format);
|
||||
if (format === "pdf" && isDesktop) useExportPreview.getState().openPreview();
|
||||
else runExport(format);
|
||||
};
|
||||
|
||||
if (!book) return null;
|
||||
@@ -165,18 +191,23 @@ export function EditorView() {
|
||||
: "Back matter";
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="app" data-compact={isCompact} data-sidebar={sidebarOpen} data-dock={dock}>
|
||||
<header className="titlebar" data-tauri-drag-region>
|
||||
<div className="lead">
|
||||
<button className="icon-btn" data-on={sidebarOpen} onClick={toggleSidebar} title="Toggle chapters" aria-label="Toggle chapters">
|
||||
<Icon d="M3 4.5h18v15H3zM9 4.5v15" />
|
||||
</button>
|
||||
</div>
|
||||
<button className="doc-title" onClick={() => setSettingsOpen(true)} title="Book setup">
|
||||
{book.metadata.title || "Untitled"}
|
||||
{dirty && <span className="dirty-dot" />}
|
||||
</button>
|
||||
<div className="actions">
|
||||
{isDesktop && <BackupButton />}
|
||||
{isDesktop && !isCompact && <BackupButton />}
|
||||
<button className="icon-btn" data-on={findOpen} onClick={() => (findOpen ? setFindOpen(false) : openFind(false))} title="Find (⌘F)">
|
||||
<Icon d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" />
|
||||
</button>
|
||||
{proofingAvailable && !coverActive && (
|
||||
{!isCompact && proofingAvailable && !coverActive && (
|
||||
<>
|
||||
<button className="icon-btn" data-on={spelling} onClick={toggleSpelling} title="Check spelling">
|
||||
<Icon d="M4 17l4-10 4 10M5.4 13.4h5.2M15 17l2.5 2.5L22 14" />
|
||||
@@ -186,57 +217,120 @@ export function EditorView() {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="menu-wrap">
|
||||
<button className="icon-btn" data-on={widthOpen} onClick={() => setWidthOpen((v) => !v)} title="Editor width">
|
||||
<Icon d="M3 5v14M21 5v14M7 12h10M7 12l3-3M7 12l3 3M17 12l-3-3M17 12l-3 3" />
|
||||
{!isCompact && (
|
||||
<div className="menu-wrap">
|
||||
<button className="icon-btn" data-on={widthOpen} onClick={() => setWidthOpen((v) => !v)} title="Editor width">
|
||||
<Icon d="M3 5v14M21 5v14M7 12h10M7 12l3-3M7 12l3 3M17 12l-3-3M17 12l-3 3" />
|
||||
</button>
|
||||
{widthOpen && (
|
||||
<>
|
||||
<div className="menu-backdrop" onClick={() => setWidthOpen(false)} />
|
||||
<div className="menu">
|
||||
{WIDTH_OPTIONS.map((w) => (
|
||||
<button
|
||||
key={w.id}
|
||||
data-on={width === w.id}
|
||||
onClick={() => {
|
||||
setWidth(w.id);
|
||||
setWidthOpen(false);
|
||||
}}
|
||||
>
|
||||
{w.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isCompact && (
|
||||
<div className="menu-wrap">
|
||||
<button className="icon-btn" data-on={exportOpen} onClick={() => setExportOpen((v) => !v)} title="Export">
|
||||
<Icon d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" />
|
||||
</button>
|
||||
{exportOpen && (
|
||||
<>
|
||||
<div className="menu-backdrop" onClick={() => setExportOpen(false)} />
|
||||
<div className="menu">
|
||||
<button onClick={() => handleExport("pdf")}>Export PDF…</button>
|
||||
<button onClick={() => handleExport("epub")}>Export EPUB…</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isCompact && (
|
||||
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? "Light mode" : "Dark mode"}>
|
||||
{theme === "dark" ? (
|
||||
<Icon>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4" />
|
||||
</Icon>
|
||||
) : (
|
||||
<Icon d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
|
||||
)}
|
||||
</button>
|
||||
{widthOpen && (
|
||||
<>
|
||||
<div className="menu-backdrop" onClick={() => setWidthOpen(false)} />
|
||||
<div className="menu">
|
||||
{WIDTH_OPTIONS.map((w) => (
|
||||
<button
|
||||
key={w.id}
|
||||
data-on={width === w.id}
|
||||
onClick={() => {
|
||||
setWidth(w.id);
|
||||
setWidthOpen(false);
|
||||
}}
|
||||
>
|
||||
{w.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="menu-wrap">
|
||||
<button className="icon-btn" data-on={exportOpen} onClick={() => setExportOpen((v) => !v)} title="Export">
|
||||
<Icon d="M5 13v6h14v-6M12 16V3M8 7l4-4 4 4" />
|
||||
</button>
|
||||
{exportOpen && (
|
||||
<>
|
||||
<div className="menu-backdrop" onClick={() => setExportOpen(false)} />
|
||||
<div className="menu">
|
||||
<button onClick={() => handleExport("pdf")}>Export PDF…</button>
|
||||
<button onClick={() => handleExport("epub")}>Export EPUB…</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button className="icon-btn" onClick={toggleTheme} title={theme === "dark" ? "Light mode" : "Dark mode"}>
|
||||
{theme === "dark" ? (
|
||||
<Icon>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4" />
|
||||
</Icon>
|
||||
) : (
|
||||
<Icon d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
|
||||
)}
|
||||
</button>
|
||||
<button className="icon-btn" data-on={dock} onClick={() => setDock(!dock)} title="Toggle preview">
|
||||
)}
|
||||
<button className="icon-btn" data-on={dock} onClick={toggleDock} title="Toggle preview">
|
||||
<Icon d="M3 4.5h18v15H3zM14 4.5v15" />
|
||||
</button>
|
||||
{isCompact && (
|
||||
<div className="menu-wrap">
|
||||
<button className="icon-btn" data-on={moreOpen} onClick={() => setMoreOpen((v) => !v)} title="More" aria-label="More options">
|
||||
<Icon d="M5 12h.01M12 12h.01M19 12h.01" />
|
||||
</button>
|
||||
{moreOpen && (
|
||||
<>
|
||||
<div className="menu-backdrop" onClick={() => setMoreOpen(false)} />
|
||||
<div className="menu">
|
||||
<div className="menu-label">Editor width</div>
|
||||
{WIDTH_OPTIONS.map((w) => (
|
||||
<button
|
||||
key={w.id}
|
||||
data-on={width === w.id}
|
||||
onClick={() => {
|
||||
setWidth(w.id);
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
>
|
||||
{w.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="menu-sep" />
|
||||
<button
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
handleExport("pdf");
|
||||
}}
|
||||
>
|
||||
Export PDF…
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMoreOpen(false);
|
||||
handleExport("epub");
|
||||
}}
|
||||
>
|
||||
Export EPUB…
|
||||
</button>
|
||||
{proofingAvailable && !coverActive && (
|
||||
<>
|
||||
<div className="menu-sep" />
|
||||
<button data-on={spelling} onClick={toggleSpelling}>
|
||||
Check spelling
|
||||
</button>
|
||||
<button data-on={grammar} onClick={toggleGrammar}>
|
||||
Check grammar
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="menu-sep" />
|
||||
<button onClick={toggleTheme}>{theme === "dark" ? "Light mode" : "Dark mode"}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -248,8 +342,8 @@ export function EditorView() {
|
||||
/>
|
||||
|
||||
<div className="body">
|
||||
<Sidebar />
|
||||
<ResizeHandle pane="sidebar" />
|
||||
<Sidebar onNavigate={() => isCompact && setSidebarOpen(false)} />
|
||||
{sidebarOpen && !isCompact && <ResizeHandle pane="sidebar" />}
|
||||
<main className="editor-pane">
|
||||
{coverActive ? (
|
||||
<CoverView />
|
||||
@@ -259,7 +353,7 @@ export function EditorView() {
|
||||
<header className="chapter-opener">
|
||||
<div className="chapter-num">{eyebrow}</div>
|
||||
<ChapterTitleInput
|
||||
value={chapter.noTitle ? "" : chapter.title}
|
||||
value={chapter.title}
|
||||
placeholder={
|
||||
chapter.noTitle ? "No title" : kind === "body" ? "Chapter title" : kind === "part" ? "Part title (optional)" : "Page title"
|
||||
}
|
||||
@@ -293,8 +387,11 @@ export function EditorView() {
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
{dock && <ResizeHandle pane="dock" />}
|
||||
{dock && !isCompact && <ResizeHandle pane="dock" />}
|
||||
{dock && <Dock />}
|
||||
{isCompact && (sidebarOpen || dock) && (
|
||||
<button className="drawer-scrim" aria-label="Close panel" onClick={closeDrawers} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{proofPopover && editor && (
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import * as pdfjs from "pdfjs-dist";
|
||||
import type { PDFDocumentProxy, RenderTask } from "pdfjs-dist";
|
||||
import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
||||
import { useBook } from "../store/useBook";
|
||||
import { useExportPreview } from "../store/useExportPreview";
|
||||
import { bookToPdfInputs, unsupportedScripts } from "../export/typst";
|
||||
import { compilePdf } from "../ipc";
|
||||
import { saveBytes } from "../project";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
|
||||
|
||||
const PDF_FILTER = [{ name: "PDF", extensions: ["pdf"] }];
|
||||
const ZOOM_MIN = 0.5;
|
||||
const ZOOM_MAX = 2.5;
|
||||
const ZOOM_STEP = 0.25;
|
||||
|
||||
interface Frame {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function measureEditorPane(): Frame | null {
|
||||
const pane = document.querySelector(".editor-pane");
|
||||
if (!pane) return null;
|
||||
const r = pane.getBoundingClientRect();
|
||||
return {
|
||||
left: Math.round(r.left),
|
||||
top: Math.round(r.top),
|
||||
width: Math.round(r.width),
|
||||
height: Math.round(r.height),
|
||||
};
|
||||
}
|
||||
|
||||
export function ExportPreview() {
|
||||
const open = useExportPreview((s) => s.open);
|
||||
const book = useBook((s) => s.book);
|
||||
if (!open || !book) return null;
|
||||
return <ExportPreviewModal />;
|
||||
}
|
||||
|
||||
function ExportPreviewModal() {
|
||||
const book = useBook((s) => s.book)!;
|
||||
const close = useExportPreview((s) => s.close);
|
||||
const [pdf, setPdf] = useState<Uint8Array | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pages, setPages] = useState(0);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [frame, setFrame] = useState<Frame | null>(measureEditorPane);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const zoomRef = useRef(zoom);
|
||||
zoomRef.current = zoom;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const update = () => setFrame(measureEditorPane());
|
||||
update();
|
||||
const pane = document.querySelector(".editor-pane");
|
||||
const observer = new ResizeObserver(update);
|
||||
if (pane) observer.observe(pane);
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setPdf(null);
|
||||
setError(null);
|
||||
const { source, images, fonts } = bookToPdfInputs(book);
|
||||
compilePdf(source, images, false, fonts)
|
||||
.then((bytes) => !cancelled && setPdf(bytes))
|
||||
.catch((e) => !cancelled && setError(String(e)));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [book]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [close]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = panelRef.current;
|
||||
if (!el) return;
|
||||
const clamp = (z: number) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z));
|
||||
const snap = (z: number) => clamp(Math.round(z / ZOOM_STEP) * ZOOM_STEP);
|
||||
let start = 1;
|
||||
let accum = 0;
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (!e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
accum += e.deltaY;
|
||||
if (accum <= -40) {
|
||||
accum = 0;
|
||||
setZoom((z) => clamp(z + ZOOM_STEP));
|
||||
} else if (accum >= 40) {
|
||||
accum = 0;
|
||||
setZoom((z) => clamp(z - ZOOM_STEP));
|
||||
}
|
||||
};
|
||||
const onGestureStart = (e: Event) => {
|
||||
e.preventDefault();
|
||||
start = zoomRef.current;
|
||||
};
|
||||
const onGestureChange = (e: Event) => {
|
||||
e.preventDefault();
|
||||
const next = snap(start * (e as unknown as { scale: number }).scale);
|
||||
setZoom((z) => (z === next ? z : next));
|
||||
};
|
||||
const onGestureEnd = (e: Event) => e.preventDefault();
|
||||
|
||||
el.addEventListener("wheel", onWheel, { passive: false });
|
||||
el.addEventListener("gesturestart", onGestureStart);
|
||||
el.addEventListener("gesturechange", onGestureChange);
|
||||
el.addEventListener("gestureend", onGestureEnd);
|
||||
return () => {
|
||||
el.removeEventListener("wheel", onWheel);
|
||||
el.removeEventListener("gesturestart", onGestureStart);
|
||||
el.removeEventListener("gesturechange", onGestureChange);
|
||||
el.removeEventListener("gestureend", onGestureEnd);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scripts = unsupportedScripts(book);
|
||||
|
||||
const save = async () => {
|
||||
if (!pdf) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveBytes(pdf, `${book.metadata.title || "Untitled"}.pdf`, PDF_FILTER);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const adjustZoom = (delta: number) =>
|
||||
setZoom((z) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round((z + delta) * 100) / 100)));
|
||||
|
||||
const compact = (frame?.width ?? 1000) < 460;
|
||||
|
||||
return (
|
||||
<div className="overlay preview-overlay" onClick={close}>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="panel preview-panel"
|
||||
style={frame ? { position: "fixed", left: frame.left, top: frame.top, width: frame.width, height: frame.height } : undefined}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="preview-bar">
|
||||
<button className="icon-btn" onClick={close} title="Close (Esc)">
|
||||
<Icon d="M6 6l12 12M18 6L6 18" />
|
||||
</button>
|
||||
<div className="preview-title">{book.metadata.title || "Untitled"}</div>
|
||||
{pdf && !compact && (
|
||||
<span className="preview-count">
|
||||
{pages} {pages === 1 ? "page" : "pages"}
|
||||
</span>
|
||||
)}
|
||||
<div className="preview-zoom">
|
||||
<button className="icon-btn" disabled={!pdf || zoom <= ZOOM_MIN} onClick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out">
|
||||
<Icon d="M5 12h14" />
|
||||
</button>
|
||||
{!compact && <span>{Math.round(zoom * 100)}%</span>}
|
||||
<button className="icon-btn" disabled={!pdf || zoom >= ZOOM_MAX} onClick={() => adjustZoom(ZOOM_STEP)} title="Zoom in">
|
||||
<Icon d="M12 5v14M5 12h14" />
|
||||
</button>
|
||||
</div>
|
||||
<button className="btn-primary" disabled={!pdf || saving} onClick={save}>
|
||||
{saving ? "Saving…" : compact ? "Save" : "Save PDF…"}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{pdf && scripts.length > 0 && (
|
||||
<div className="preview-warn">
|
||||
Some {scripts.join(", ")} characters may not render — the PDF fonts cover Latin scripts only.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<div className="preview-stage">
|
||||
<pre className="dock-error">{error}</pre>
|
||||
</div>
|
||||
) : pdf ? (
|
||||
<BookPages data={pdf} zoom={zoom} onPages={setPages} />
|
||||
) : (
|
||||
<div className="preview-stage">
|
||||
<div className="preview-loading">
|
||||
<div className="spinner spinner-dark" />
|
||||
<p>Typesetting your book…</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BookPages({ data, zoom, onPages }: { data: Uint8Array; zoom: number; onPages: (n: number) => void }) {
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
|
||||
const [baseRatio, setBaseRatio] = useState<number | null>(null);
|
||||
const [stageWidth, setStageWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const task = pdfjs.getDocument({ data: data.slice() });
|
||||
task.promise
|
||||
.then(async (d) => {
|
||||
if (cancelled) return;
|
||||
const first = (await d.getPage(1)).getViewport({ scale: 1 });
|
||||
if (cancelled) return;
|
||||
setDoc(d);
|
||||
setBaseRatio(first.height / first.width);
|
||||
onPages(d.numPages);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
task.destroy();
|
||||
};
|
||||
}, [data, onPages]);
|
||||
|
||||
useEffect(() => {
|
||||
const stage = stageRef.current;
|
||||
if (!stage) return;
|
||||
const update = () => setStageWidth(stage.clientWidth);
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(stage);
|
||||
return () => observer.disconnect();
|
||||
}, [doc]);
|
||||
|
||||
const fit = Math.max(280, stageWidth - 56);
|
||||
const displayWidth = Math.round(fit * zoom);
|
||||
|
||||
return (
|
||||
<div className="preview-stage" ref={stageRef}>
|
||||
{doc && baseRatio !== null && stageWidth > 0 && (
|
||||
<div className="preview-col">
|
||||
{Array.from({ length: doc.numPages }, (_, i) => (
|
||||
<PdfPage
|
||||
key={i + 1}
|
||||
doc={doc}
|
||||
pageNumber={i + 1}
|
||||
root={stageRef.current}
|
||||
displayWidth={displayWidth}
|
||||
baseRatio={baseRatio}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PdfPage({
|
||||
doc,
|
||||
pageNumber,
|
||||
root,
|
||||
displayWidth,
|
||||
baseRatio,
|
||||
}: {
|
||||
doc: PDFDocumentProxy;
|
||||
pageNumber: number;
|
||||
root: HTMLElement | null;
|
||||
displayWidth: number;
|
||||
baseRatio: number;
|
||||
}) {
|
||||
const holder = useRef<HTMLDivElement>(null);
|
||||
const [show, setShow] = useState(false);
|
||||
const [ratio, setRatio] = useState(baseRatio);
|
||||
|
||||
useEffect(() => {
|
||||
const el = holder.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(([entry]) => setShow(entry.isIntersecting), {
|
||||
root,
|
||||
rootMargin: "1400px 0px",
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [root]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = holder.current;
|
||||
if (!el) return;
|
||||
if (!show) {
|
||||
el.replaceChildren();
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
let task: RenderTask | undefined;
|
||||
(async () => {
|
||||
try {
|
||||
const page = await doc.getPage(pageNumber);
|
||||
if (cancelled) return;
|
||||
const natural = page.getViewport({ scale: 1 });
|
||||
setRatio(natural.height / natural.width);
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const viewport = page.getViewport({ scale: (displayWidth / natural.width) * dpr });
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.className = "preview-canvas";
|
||||
canvas.width = Math.ceil(viewport.width);
|
||||
canvas.height = Math.ceil(viewport.height);
|
||||
task = page.render({ canvas, viewport });
|
||||
await task.promise;
|
||||
if (cancelled) return;
|
||||
el.replaceChildren(canvas);
|
||||
} catch {
|
||||
// render cancelled or page failed; keep the previous canvas
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
task?.cancel();
|
||||
};
|
||||
}, [show, doc, pageNumber, displayWidth]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={holder}
|
||||
className="preview-page"
|
||||
style={{ width: displayWidth, height: Math.round(displayWidth * ratio) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,15 @@ interface RowMenuProps {
|
||||
label: string;
|
||||
onDuplicate?: () => void;
|
||||
onDelete: () => void;
|
||||
onToggleTitle?: () => void;
|
||||
titleHidden?: boolean;
|
||||
onToggleMargin?: () => void;
|
||||
marginHidden?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RowMenu({ label, onDuplicate, onDelete, onOpenChange, className = "" }: RowMenuProps) {
|
||||
export function RowMenu({ label, onDuplicate, onDelete, onToggleTitle, titleHidden, onToggleMargin, marginHidden, onOpenChange, className = "" }: RowMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -46,6 +50,18 @@ export function RowMenu({ label, onDuplicate, onDelete, onOpenChange, className
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const toggleTitle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
onToggleTitle?.();
|
||||
};
|
||||
|
||||
const toggleMargin = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
onToggleMargin?.();
|
||||
};
|
||||
|
||||
const duplicate = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
@@ -73,6 +89,26 @@ export function RowMenu({ label, onDuplicate, onDelete, onOpenChange, className
|
||||
{open &&
|
||||
createPortal(
|
||||
<div ref={popRef} className="row-menu-pop" style={{ top: coords.top, right: coords.right }}>
|
||||
{onToggleTitle && (
|
||||
<button className="row-menu-item" onClick={toggleTitle}>
|
||||
{titleHidden ? (
|
||||
<Icon d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z" size={14} />
|
||||
) : (
|
||||
<Icon d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24M1 1l22 22" size={14} />
|
||||
)}
|
||||
{titleHidden ? "Show title" : "Hide title"}
|
||||
</button>
|
||||
)}
|
||||
{onToggleMargin && (
|
||||
<button className="row-menu-item" onClick={toggleMargin}>
|
||||
{marginHidden ? (
|
||||
<Icon d="M4 5h16 M12 10v10 M8 16l4 4 4-4" size={14} />
|
||||
) : (
|
||||
<Icon d="M4 5h16 M12 20V10 M8 14l4-4 4 4" size={14} />
|
||||
)}
|
||||
{marginHidden ? "Show top margin" : "Hide top margin"}
|
||||
</button>
|
||||
)}
|
||||
{onDuplicate && (
|
||||
<button className="row-menu-item" onClick={duplicate}>
|
||||
<Icon d="M9 9h11v11h-11z M6 15V5h9" size={14} />
|
||||
|
||||
@@ -39,7 +39,7 @@ function formatEdited(ts: number, now: number): string {
|
||||
return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const chapters = useBook((s) => s.book?.chapters ?? []);
|
||||
const activeChapterId = useBook((s) => s.activeChapterId);
|
||||
const setActiveChapter = useBook((s) => s.setActiveChapter);
|
||||
@@ -47,6 +47,8 @@ export function Sidebar() {
|
||||
const addPage = useBook((s) => s.addPage);
|
||||
const addPart = useBook((s) => s.addPart);
|
||||
const duplicateChapter = useBook((s) => s.duplicateChapter);
|
||||
const setChapterNoTitle = useBook((s) => s.setChapterNoTitle);
|
||||
const setChapterNoMargin = useBook((s) => s.setChapterNoMargin);
|
||||
const deleteChapter = useBook((s) => s.deleteChapter);
|
||||
const moveChapter = useBook((s) => s.moveChapter);
|
||||
const closeBook = useBook((s) => s.closeBook);
|
||||
@@ -150,6 +152,7 @@ export function Sidebar() {
|
||||
return;
|
||||
}
|
||||
setActiveChapter(id);
|
||||
onNavigate?.();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -161,7 +164,10 @@ export function Sidebar() {
|
||||
<button
|
||||
className="cover-item"
|
||||
data-active={activeChapterId === COVER_ID}
|
||||
onClick={() => setActiveChapter(COVER_ID)}
|
||||
onClick={() => {
|
||||
setActiveChapter(COVER_ID);
|
||||
onNavigate?.();
|
||||
}}
|
||||
>
|
||||
<Icon d="M5 4h11l3 3v13H5zM16 4v4h3" size={15} />
|
||||
<span className="title">Cover</span>
|
||||
@@ -224,6 +230,10 @@ export function Sidebar() {
|
||||
onOpenChange={(open) =>
|
||||
setMenuOpenId((cur) => (open ? row.chapter.id : cur === row.chapter.id ? null : cur))
|
||||
}
|
||||
titleHidden={!!row.chapter.noTitle}
|
||||
onToggleTitle={() => setChapterNoTitle(row.chapter.id, !row.chapter.noTitle)}
|
||||
marginHidden={!!row.chapter.noMargin}
|
||||
onToggleMargin={() => setChapterNoMargin(row.chapter.id, !row.chapter.noMargin)}
|
||||
onDuplicate={() => duplicateChapter(row.chapter.id)}
|
||||
onDelete={() => setPendingDelete({ id: row.chapter.id, title: rowLabel })}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useEffect, useReducer, useRef, useState, type ReactNode } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { Icon } from "../components/Icon";
|
||||
import type { Alignment } from "./align";
|
||||
|
||||
const ALIGN_ICONS: Record<Alignment, string> = {
|
||||
left: "M4 7h16M4 12h10M4 17h13",
|
||||
center: "M4 7h16M7 12h10M5 17h14",
|
||||
right: "M4 7h16M10 12h10M7 17h13",
|
||||
};
|
||||
|
||||
function readImage(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -23,6 +30,7 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
|
||||
const linkInputRef = useRef<HTMLInputElement>(null);
|
||||
const [linkOpen, setLinkOpen] = useState(false);
|
||||
const [linkValue, setLinkValue] = useState("");
|
||||
const [alignOpen, setAlignOpen] = useState(false);
|
||||
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -67,6 +75,13 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
|
||||
setLinkOpen(false);
|
||||
};
|
||||
|
||||
const align: Alignment = (editor.getAttributes("heading").align ?? editor.getAttributes("paragraph").align) || "left";
|
||||
|
||||
const setAlign = (value: Alignment) => {
|
||||
editor.chain().focus().setTextAlign(value).run();
|
||||
setAlignOpen(false);
|
||||
};
|
||||
|
||||
const tool = (active: boolean, onClick: () => void, title: string, content: ReactNode) => (
|
||||
<button
|
||||
className="tool"
|
||||
@@ -87,6 +102,28 @@ export function FloatingToolbar({ editor }: { editor: Editor | null }) {
|
||||
{tool(editor.isActive("heading", { level: 2 }), () => editor.chain().focus().toggleHeading({ level: 2 }).run(), "Heading", <Icon d="M5 5v14M5 12h8M13 5v14" />)}
|
||||
{tool(editor.isActive("blockquote"), () => editor.chain().focus().toggleBlockquote().run(), "Quote", <Icon d="M7 8h4v4a4 4 0 0 1-4 4M14 8h4v4a4 4 0 0 1-4 4" />)}
|
||||
{tool(editor.isActive("bulletList"), () => editor.chain().focus().toggleBulletList().run(), "Bulleted list", <Icon d="M8 6h12M8 12h12M8 18h12M3.5 6h.01M3.5 12h.01M3.5 18h.01" />)}
|
||||
<span className="tool-wrap">
|
||||
{tool(alignOpen || align !== "left", () => setAlignOpen((v) => !v), "Align", <Icon d={ALIGN_ICONS[align]} />)}
|
||||
{alignOpen && (
|
||||
<>
|
||||
<div className="link-pop-backdrop" onMouseDown={() => setAlignOpen(false)} />
|
||||
<div className="align-pop" onMouseDown={(e) => e.stopPropagation()}>
|
||||
{(["left", "center", "right"] as Alignment[]).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
className="tool"
|
||||
data-on={align === value}
|
||||
title={`Align ${value}`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => setAlign(value)}
|
||||
>
|
||||
<Icon d={ALIGN_ICONS[value]} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className="tool-sep" />
|
||||
<span className="tool-wrap">
|
||||
{tool(editor.isActive("link") || linkOpen, () => (linkOpen ? setLinkOpen(false) : openLink()), "Link", <Icon d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1" />)}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
|
||||
export type Alignment = "left" | "center" | "right";
|
||||
|
||||
const ALIGNED_TYPES = ["paragraph", "heading"];
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
textAlign: {
|
||||
setTextAlign: (align: Alignment) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const TextAlign = Extension.create({
|
||||
name: "textAlign",
|
||||
|
||||
addGlobalAttributes() {
|
||||
return [
|
||||
{
|
||||
types: ALIGNED_TYPES,
|
||||
attributes: {
|
||||
align: {
|
||||
default: null,
|
||||
keepOnSplit: true,
|
||||
parseHTML: (el) => {
|
||||
const value = el.style.textAlign || el.getAttribute("data-align");
|
||||
return value === "center" || value === "right" ? value : null;
|
||||
},
|
||||
renderHTML: (attrs) =>
|
||||
attrs.align ? { "data-align": attrs.align, style: `text-align: ${attrs.align}` } : {},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setTextAlign:
|
||||
(align) =>
|
||||
({ tr, dispatch }) => {
|
||||
const value = align === "left" ? null : align;
|
||||
const { from, to } = tr.selection;
|
||||
let changed = false;
|
||||
tr.doc.nodesBetween(from, to, (node, pos) => {
|
||||
if (ALIGNED_TYPES.includes(node.type.name) && node.attrs.align !== value) {
|
||||
if (dispatch) tr.setNodeAttribute(pos, "align", value);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
"Mod-Shift-l": () => this.editor.commands.setTextAlign("left"),
|
||||
"Mod-Shift-e": () => this.editor.commands.setTextAlign("center"),
|
||||
"Mod-Shift-r": () => this.editor.commands.setTextAlign("right"),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -3,8 +3,10 @@ import StarterKit from "@tiptap/starter-kit";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { Figure } from "./figure";
|
||||
import { ParagraphIndent } from "./indent";
|
||||
import { TextAlign } from "./align";
|
||||
import { SearchHighlight } from "./search";
|
||||
import { Proofing } from "./proofing";
|
||||
import { Paste } from "./paste";
|
||||
|
||||
export const editorExtensions: Extensions = [
|
||||
StarterKit.configure({
|
||||
@@ -17,6 +19,8 @@ export const editorExtensions: Extensions = [
|
||||
}),
|
||||
Figure,
|
||||
ParagraphIndent,
|
||||
TextAlign,
|
||||
SearchHighlight,
|
||||
Proofing,
|
||||
Paste,
|
||||
];
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import type { JSONContent } from "@tiptap/core";
|
||||
import { Plugin } from "@tiptap/pm/state";
|
||||
|
||||
function readImage(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function imageFiles(data: DataTransfer | null): File[] {
|
||||
if (!data) return [];
|
||||
const files = Array.from(data.files).filter((f) => f.type.startsWith("image/"));
|
||||
if (files.length) return files;
|
||||
return Array.from(data.items)
|
||||
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((f): f is File => f !== null);
|
||||
}
|
||||
|
||||
function plainContent(text: string): JSONContent[] {
|
||||
return text
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.split(/\n{2,}/)
|
||||
.map((block) => {
|
||||
const content: JSONContent[] = [];
|
||||
block.split("\n").forEach((line, i) => {
|
||||
if (i > 0) content.push({ type: "hardBreak" });
|
||||
if (line) content.push({ type: "text", text: line });
|
||||
});
|
||||
return content.length ? { type: "paragraph", content } : { type: "paragraph" };
|
||||
});
|
||||
}
|
||||
|
||||
export const Paste = Extension.create({
|
||||
name: "pasteHandler",
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
const editor = this.editor;
|
||||
return {
|
||||
"Mod-Shift-v": () => {
|
||||
navigator.clipboard
|
||||
.readText()
|
||||
.then((text) => {
|
||||
if (text) editor.chain().focus().insertContent(plainContent(text)).run();
|
||||
})
|
||||
.catch(() => {});
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const editor = this.editor;
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
handlePaste(_view, event) {
|
||||
const files = imageFiles(event.clipboardData);
|
||||
if (!files.length) return false;
|
||||
event.preventDefault();
|
||||
files.forEach((file) =>
|
||||
readImage(file).then((src) =>
|
||||
editor.chain().focus().insertContent({ type: "figure", attrs: { src, placement: "full-width" } }).run(),
|
||||
),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -37,10 +37,11 @@ interface ProofingState {
|
||||
|
||||
export const proofingKey = new PluginKey<ProofingState>("proofing");
|
||||
|
||||
function buildDecorations(doc: PMNode, issues: ProofIssue[]): DecorationSet {
|
||||
function buildDecorations(doc: PMNode, issues: ProofIssue[], cursor: number | null): DecorationSet {
|
||||
const size = doc.content.size;
|
||||
const decos = issues
|
||||
.filter((i) => i.from < i.to && i.to <= size)
|
||||
.filter((i) => cursor === null || cursor < i.from || cursor > i.to)
|
||||
.map((i) => Decoration.inline(i.from, i.to, { class: `proof-mark sev-${severityFor(i.category)}` }));
|
||||
return DecorationSet.create(doc, decos);
|
||||
}
|
||||
@@ -73,9 +74,11 @@ export const Proofing = Extension.create<unknown, ProofingStorage>({
|
||||
state: {
|
||||
init: () => ({ issues: [], decorations: DecorationSet.empty }),
|
||||
apply(tr, value, _old, newState) {
|
||||
const sel = newState.selection;
|
||||
const cursor = sel.empty ? sel.head : null;
|
||||
const meta = tr.getMeta(proofingKey) as { issues: ProofIssue[] } | { clear: true } | undefined;
|
||||
if (meta && "issues" in meta) {
|
||||
return { issues: meta.issues, decorations: buildDecorations(newState.doc, meta.issues) };
|
||||
return { issues: meta.issues, decorations: buildDecorations(newState.doc, meta.issues, cursor) };
|
||||
}
|
||||
if (meta && "clear" in meta) {
|
||||
return { issues: [], decorations: DecorationSet.empty };
|
||||
@@ -84,7 +87,10 @@ export const Proofing = Extension.create<unknown, ProofingStorage>({
|
||||
const issues = value.issues
|
||||
.map((i) => ({ ...i, from: tr.mapping.map(i.from), to: tr.mapping.map(i.to) }))
|
||||
.filter((i) => i.to > i.from);
|
||||
return { issues, decorations: value.decorations.map(tr.mapping, tr.doc) };
|
||||
return { issues, decorations: buildDecorations(newState.doc, issues, cursor) };
|
||||
}
|
||||
if (tr.selectionSet && value.issues.length) {
|
||||
return { issues: value.issues, decorations: buildDecorations(newState.doc, value.issues, cursor) };
|
||||
}
|
||||
return value;
|
||||
},
|
||||
|
||||
+10
-4
@@ -132,17 +132,23 @@ function figure(node: JSONContent, paths: Map<string, string>): string {
|
||||
return `<figure class="placement-${attr(placement)}"${style}><img src="${attr(path)}" alt="${alt}"/>${caption}</figure>`;
|
||||
}
|
||||
|
||||
function alignStyle(align: unknown): string {
|
||||
return align === "center" || align === "right" ? ` style="text-align:${align}"` : "";
|
||||
}
|
||||
|
||||
function block(node: JSONContent, paths: Map<string, string>): string {
|
||||
switch (node.type) {
|
||||
case "paragraph": {
|
||||
const body = inlines(node.content);
|
||||
if (!body.trim()) return "<p> </p>";
|
||||
const align = node.attrs?.align;
|
||||
if (align === "center" || align === "right") return `<p${alignStyle(align)}>${body}</p>`;
|
||||
return `<p${node.attrs?.indent ? ' data-indent="true"' : ""}>${body}</p>`;
|
||||
}
|
||||
case "heading":
|
||||
return node.attrs?.level === 3
|
||||
? `<h3>${inlines(node.content)}</h3>`
|
||||
: `<h2>${inlines(node.content)}</h2>`;
|
||||
case "heading": {
|
||||
const tag = node.attrs?.level === 3 ? "h3" : "h2";
|
||||
return `<${tag}${alignStyle(node.attrs?.align)}>${inlines(node.content)}</${tag}>`;
|
||||
}
|
||||
case "blockquote":
|
||||
return `<blockquote>${(node.content ?? []).map((n) => block(n, paths)).join("")}</blockquote>`;
|
||||
case "bulletList":
|
||||
|
||||
+23
-6
@@ -37,6 +37,15 @@ function str(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function localeParams(tag: string): string {
|
||||
const parts = (tag || "en").trim().split(/[-_]/);
|
||||
const primary = parts[0] ?? "";
|
||||
const lang = /^[a-zA-Z]{2,3}$/.test(primary) ? primary.toLowerCase() : "en";
|
||||
const region = parts.slice(1).find((p) => /^[a-zA-Z]{2}$/.test(p));
|
||||
const params = `lang: ${str(lang)}`;
|
||||
return region ? `${params}, region: ${str(region.toUpperCase())}` : params;
|
||||
}
|
||||
|
||||
function inline(node: JSONContent, atLineStart: boolean): string {
|
||||
if (node.type === "text") {
|
||||
const marks = node.marks ?? [];
|
||||
@@ -80,15 +89,22 @@ function figure(node: JSONContent, paths: Map<string, string>): string {
|
||||
return `#figure(image(${str(path)}, width: ${width}%)${caption}${float})`;
|
||||
}
|
||||
|
||||
function aligned(align: unknown, body: string): string {
|
||||
if (align === "center" || align === "right") return `#align(${align})[#set par(justify: false)\n${body}]`;
|
||||
return body;
|
||||
}
|
||||
|
||||
function block(node: JSONContent, paths: Map<string, string>): string {
|
||||
switch (node.type) {
|
||||
case "paragraph": {
|
||||
const body = inlines(node.content, true);
|
||||
if (!body.trim()) return "~";
|
||||
const align = node.attrs?.align;
|
||||
if (align === "center" || align === "right") return aligned(align, body);
|
||||
return node.attrs?.indent ? `#h(1.3em)${body}` : body;
|
||||
}
|
||||
case "heading":
|
||||
return `#heading(level: ${node.attrs?.level ?? 2})[${inlines(node.content, true)}]`;
|
||||
return aligned(node.attrs?.align, `#heading(level: ${node.attrs?.level ?? 2})[${inlines(node.content, true)}]`);
|
||||
case "blockquote":
|
||||
return `#blockquote[${(node.content ?? []).map((n) => block(n, paths)).join("\n\n")}]`;
|
||||
case "bulletList":
|
||||
@@ -124,7 +140,7 @@ function preamble(book: Book): string {
|
||||
margin: (inside: 0.875in, outside: 0.625in + ${bleed}, top: 0.8in + ${bleed}, bottom: 0.8in + ${bleed}),
|
||||
binding: left,
|
||||
)
|
||||
#set text(font: ${str(body)}, size: 11pt, lang: ${str(meta.language || "en")}, hyphenate: true)
|
||||
#set text(font: ${str(body)}, size: 11pt, ${localeParams(meta.language)}, hyphenate: true)
|
||||
#set par(justify: true, leading: 0.72em, spacing: 0.72em)
|
||||
#show heading: set text(font: ${str(heading)}, weight: "medium")
|
||||
#show heading.where(level: 1): set text(size: 22pt)
|
||||
@@ -153,7 +169,7 @@ function preamble(book: Book): string {
|
||||
#let openchapter(info) = {
|
||||
pagebreak(weak: true)
|
||||
[#metadata(info) <chap>]
|
||||
v(2.1in)
|
||||
if not info.nomargin { v(2.1in) }
|
||||
align(center)[
|
||||
#if info.kind == "body" {
|
||||
text(font: "Hanken Grotesk", size: 8.5pt, weight: "semibold", tracking: 0.28em)[#upper("Chapter " + info.num)]
|
||||
@@ -169,7 +185,7 @@ function preamble(book: Book): string {
|
||||
#let openpart(info) = {
|
||||
pagebreak(to: "odd", weak: true)
|
||||
[#metadata(info) <chap>]
|
||||
v(3.1in)
|
||||
if not info.nomargin { v(3.1in) }
|
||||
align(center)[
|
||||
#text(font: "Hanken Grotesk", size: 10pt, weight: "semibold", tracking: 0.34em)[#upper("Part " + info.numeral)]
|
||||
#if not info.notitle and info.title != "" {
|
||||
@@ -340,14 +356,15 @@ function openerCall(book: Book, index: number): string {
|
||||
const chapter = book.chapters[index];
|
||||
const kind = chapterKind(chapter);
|
||||
const notitle = !!chapter.noTitle;
|
||||
const nomargin = !!chapter.noMargin;
|
||||
if (kind === "part") {
|
||||
const numeral = partRoman(partNumber(book.chapters, index) ?? 0);
|
||||
const title = notitle ? "" : cleanTitle(chapter.title);
|
||||
return `#openpart((kind: "part", numeral: ${str(numeral)}, title: ${str(title)}, notitle: ${notitle}, toc: ${inToc(book, index)}))`;
|
||||
return `#openpart((kind: "part", numeral: ${str(numeral)}, title: ${str(title)}, notitle: ${notitle}, nomargin: ${nomargin}, toc: ${inToc(book, index)}))`;
|
||||
}
|
||||
const num = kind === "body" ? String(bodyNumber(book.chapters, index)) : "";
|
||||
const title = notitle ? "" : cleanTitle(chapter.title) || "Untitled";
|
||||
return `#openchapter((kind: ${str(kind)}, num: ${str(num)}, title: ${str(title)}, notitle: ${notitle}, toc: ${inToc(book, index)}))`;
|
||||
return `#openchapter((kind: ${str(kind)}, num: ${str(num)}, title: ${str(title)}, notitle: ${notitle}, nomargin: ${nomargin}, toc: ${inToc(book, index)}))`;
|
||||
}
|
||||
|
||||
export function bookToTypst(book: Book, paths: Map<string, string> = new Map(), coverPath?: string): string {
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import type { JSONContent } from "@tiptap/core";
|
||||
import { type Book, type BookMetadata, type Chapter, type Cover, createCover } from "../model/book";
|
||||
import { type Book, type BookMetadata, type Chapter, type Cover, clampLanguage, createCover } from "../model/book";
|
||||
import { DEFAULT_FONTS } from "../model/fonts";
|
||||
|
||||
export interface RawFile {
|
||||
@@ -508,7 +508,7 @@ export function filesToBook(files: RawFile[], fallbackName = "Imported book"): B
|
||||
subtitle: "",
|
||||
author: localText(opf, "creator"),
|
||||
isbn: findIsbn(opf),
|
||||
language: localText(opf, "language") || "en",
|
||||
language: clampLanguage(localText(opf, "language") || "en"),
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
+10
-1
@@ -73,6 +73,7 @@ export interface Chapter {
|
||||
updatedAt: number;
|
||||
kind?: ChapterKind;
|
||||
noTitle?: boolean;
|
||||
noMargin?: boolean;
|
||||
}
|
||||
|
||||
export interface PageType {
|
||||
@@ -182,11 +183,19 @@ export function createCover(): Cover {
|
||||
const DEFAULT_METADATA: BookMetadata = { title: "Untitled", subtitle: "", author: "", isbn: "", language: "en" };
|
||||
const DEFAULT_SETTINGS: BookSettings = { trim: "6x9", bleed: true, fonts: DEFAULT_FONTS };
|
||||
|
||||
export const SUPPORTED_LANGUAGES = ["en", "es", "fr", "de", "it", "pt"] as const;
|
||||
|
||||
export function clampLanguage(tag: string): string {
|
||||
const primary = (tag || "").trim().split(/[-_]/)[0]?.toLowerCase() ?? "";
|
||||
return (SUPPORTED_LANGUAGES as readonly string[]).includes(primary) ? primary : "en";
|
||||
}
|
||||
|
||||
export function normalizeBook(book: Book): Book {
|
||||
const chapters = (Array.isArray(book.chapters) ? book.chapters : []).filter(Boolean);
|
||||
const metadata = { ...DEFAULT_METADATA, ...book.metadata };
|
||||
return {
|
||||
...book,
|
||||
metadata: { ...DEFAULT_METADATA, ...book.metadata },
|
||||
metadata: { ...metadata, language: clampLanguage(metadata.language) },
|
||||
settings: { ...DEFAULT_SETTINGS, ...book.settings, fonts: { ...DEFAULT_FONTS, ...book.settings?.fonts } },
|
||||
cover: book.cover ? { ...createCover(), ...book.cover } : createCover(),
|
||||
chapters: (chapters.length ? chapters : [createChapter()]).map((c) =>
|
||||
|
||||
@@ -33,6 +33,7 @@ interface BookState {
|
||||
setChapterContent: (id: string, content: JSONContent) => void;
|
||||
setChapterTitle: (id: string, title: string) => void;
|
||||
setChapterNoTitle: (id: string, noTitle: boolean) => void;
|
||||
setChapterNoMargin: (id: string, noMargin: boolean) => void;
|
||||
addChapter: () => void;
|
||||
addPage: (group: "front" | "back", title: string) => void;
|
||||
addPart: () => void;
|
||||
@@ -87,7 +88,13 @@ export const useBook = create<BookState>((set, get) => ({
|
||||
setChapterNoTitle: (id, noTitle) =>
|
||||
set((s) =>
|
||||
s.book
|
||||
? { dirty: true, book: { ...s.book, chapters: s.book.chapters.map((c) => (c.id === id ? { ...c, noTitle, title: noTitle ? "" : c.title, updatedAt: Date.now() } : c)) } }
|
||||
? { dirty: true, book: { ...s.book, chapters: s.book.chapters.map((c) => (c.id === id ? { ...c, noTitle, updatedAt: Date.now() } : c)) } }
|
||||
: {}
|
||||
),
|
||||
setChapterNoMargin: (id, noMargin) =>
|
||||
set((s) =>
|
||||
s.book
|
||||
? { dirty: true, book: { ...s.book, chapters: s.book.chapters.map((c) => (c.id === id ? { ...c, noMargin, updatedAt: Date.now() } : c)) } }
|
||||
: {}
|
||||
),
|
||||
addChapter: () =>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface ExportPreviewState {
|
||||
open: boolean;
|
||||
openPreview: () => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export const useExportPreview = create<ExportPreviewState>((set) => ({
|
||||
open: false,
|
||||
openPreview: () => set({ open: true }),
|
||||
close: () => set({ open: false }),
|
||||
}));
|
||||
+277
-6
@@ -8,8 +8,17 @@ body,
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
background: var(--shell);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-ui);
|
||||
@@ -35,12 +44,15 @@ button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.titlebar {
|
||||
flex: none;
|
||||
height: 46px;
|
||||
position: relative;
|
||||
z-index: 45;
|
||||
height: var(--titlebar-h);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
@@ -49,6 +61,13 @@ button {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.titlebar .lead {
|
||||
grid-column: 1;
|
||||
justify-self: start;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.titlebar .doc-title {
|
||||
grid-column: 2;
|
||||
font-size: var(--t-2);
|
||||
@@ -150,6 +169,17 @@ body.resizing {
|
||||
padding: 6px 0 10px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
transition: width 220ms var(--ease);
|
||||
}
|
||||
|
||||
.app:not([data-compact="true"])[data-sidebar="false"] .sidebar {
|
||||
width: 0;
|
||||
border-right-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body.resizing .sidebar {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.brand {
|
||||
@@ -657,14 +687,14 @@ body.resizing {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 15px 0 0;
|
||||
padding: 0;
|
||||
padding: 0 0 0.3em;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: center;
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 500;
|
||||
font-size: 31px;
|
||||
line-height: 1.16;
|
||||
line-height: 1.4;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ink);
|
||||
resize: none;
|
||||
@@ -818,7 +848,7 @@ body.resizing {
|
||||
|
||||
.editor-toolbar {
|
||||
position: sticky;
|
||||
bottom: 22px;
|
||||
bottom: max(22px, env(safe-area-inset-bottom));
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -891,6 +921,24 @@ body.resizing {
|
||||
box-shadow: var(--shadow-pop);
|
||||
}
|
||||
|
||||
.align-pop {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 10px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
padding: 5px;
|
||||
background: var(--glass);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
box-shadow: var(--shadow-pop);
|
||||
}
|
||||
|
||||
.link-input {
|
||||
width: 220px;
|
||||
height: 28px;
|
||||
@@ -1076,6 +1124,122 @@ body.resizing {
|
||||
padding: 44px 0;
|
||||
}
|
||||
|
||||
.preview-overlay {
|
||||
z-index: 55;
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preview-bar {
|
||||
flex: none;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 12px;
|
||||
background: var(--paper);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: var(--font-book);
|
||||
font-size: 15px;
|
||||
color: var(--ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-count {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-2);
|
||||
color: var(--ink-faint);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.preview-zoom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.preview-zoom span {
|
||||
width: 44px;
|
||||
text-align: center;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-2);
|
||||
color: var(--ink-soft);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.preview-warn {
|
||||
flex: none;
|
||||
padding: 9px 18px;
|
||||
background: var(--accent-wash);
|
||||
color: var(--accent-ink);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-2);
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.preview-stage {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 28px;
|
||||
background: var(--shell);
|
||||
}
|
||||
|
||||
.preview-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.preview-page {
|
||||
flex: none;
|
||||
background: #fff;
|
||||
border-radius: 2px;
|
||||
box-shadow: var(--shadow-page);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.preview-loading {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.preview-loading p {
|
||||
margin: 0;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--t-3);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.spinner-dark {
|
||||
border-color: var(--line-strong);
|
||||
border-top-color: var(--accent);
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -1089,8 +1253,7 @@ body.resizing {
|
||||
}
|
||||
|
||||
.panel {
|
||||
width: 480px;
|
||||
max-width: 100%;
|
||||
width: min(480px, calc(100vw - 32px));
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1231,6 +1394,21 @@ body.resizing {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
padding: 7px 11px 3px;
|
||||
font-size: var(--t-1);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.menu-sep {
|
||||
height: 1px;
|
||||
margin: 5px 7px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.export-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -1286,6 +1464,7 @@ body.resizing {
|
||||
|
||||
.library {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow-y: auto;
|
||||
background: var(--shell);
|
||||
}
|
||||
@@ -2489,3 +2668,95 @@ body.resizing {
|
||||
font-size: var(--t-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Compact / mobile layout: panes become slide-in drawers over a full-width editor */
|
||||
|
||||
.app[data-compact="true"] {
|
||||
--titlebar-h: calc(46px + env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.app[data-compact="true"] .titlebar {
|
||||
padding: env(safe-area-inset-top) max(12px, env(safe-area-inset-right)) 0 max(12px, env(safe-area-inset-left));
|
||||
}
|
||||
|
||||
.app[data-compact="true"] .sidebar,
|
||||
.app[data-compact="true"] .dock {
|
||||
position: fixed;
|
||||
top: var(--titlebar-h);
|
||||
bottom: 0;
|
||||
z-index: 40;
|
||||
box-shadow: var(--shadow-pop);
|
||||
}
|
||||
|
||||
.app[data-compact="true"] .sidebar {
|
||||
left: 0;
|
||||
width: min(82vw, 320px);
|
||||
padding-left: env(safe-area-inset-left);
|
||||
border-right: 1px solid var(--line);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 260ms var(--ease);
|
||||
}
|
||||
|
||||
.app[data-compact="true"][data-sidebar="true"] .sidebar {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.app[data-compact="true"] .dock {
|
||||
right: 0;
|
||||
width: min(92vw, 440px);
|
||||
padding-right: max(28px, env(safe-area-inset-right));
|
||||
border-left: 1px solid var(--line);
|
||||
animation: drawer-in-right 260ms var(--ease);
|
||||
}
|
||||
|
||||
@keyframes drawer-in-right {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.drawer-scrim {
|
||||
position: fixed;
|
||||
inset: var(--titlebar-h) 0 0 0;
|
||||
z-index: 35;
|
||||
background: rgba(0, 0, 0, 0.42);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
backdrop-filter: blur(2px);
|
||||
animation: scrim-in 200ms var(--ease);
|
||||
}
|
||||
|
||||
@keyframes scrim-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.app[data-compact="true"] .sheet {
|
||||
padding: 56px 18px 160px;
|
||||
}
|
||||
|
||||
.app[data-compact="true"] .chapter-title {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
@media (max-width: 899px) {
|
||||
.overlay {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.library-head {
|
||||
padding: max(8px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) 0 max(12px, env(safe-area-inset-left));
|
||||
}
|
||||
|
||||
.shelf {
|
||||
padding: 20px max(18px, env(safe-area-inset-right)) 60px max(18px, env(safe-area-inset-left));
|
||||
gap: 18px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
--pane-sidebar: 248px;
|
||||
--pane-dock: 384px;
|
||||
--measure: 46em;
|
||||
--titlebar-h: 46px;
|
||||
|
||||
--t-1: 11px;
|
||||
--t-2: 12px;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(query);
|
||||
const onChange = () => setMatches(mq.matches);
|
||||
onChange();
|
||||
mq.addEventListener("change", onChange);
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}, [query]);
|
||||
return matches;
|
||||
}
|
||||
|
||||
export const useCompact = () => useMediaQuery("(max-width: 899px)");
|
||||
Reference in new issue
Block a user