fix the issue docker
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@ ENV NODE_ENV=development
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
RUN npm install
|
||||
|
||||
FROM base AS development
|
||||
COPY . .
|
||||
|
||||
@@ -15,7 +15,6 @@ import { triggerPDFDownload } from './lib/pdfExport';
|
||||
import { CustomConfirmationModal } from './components/custom/CustomConfirmationModal';
|
||||
import type { CustomConfirmationOptions } from './components/custom/CustomConfirmationModal';
|
||||
import { PDFViewer } from './viewer/PDFViewer';
|
||||
import { WhiteboardView } from './simple_draw';
|
||||
import { CreatePDFModal } from './features/document-creator/components/CreatePDFModal';
|
||||
import type { PageLayout } from './features/document-creator/model/PaginationEngine';
|
||||
import type { PDFViewerRef } from './viewer/PDFViewer';
|
||||
@@ -1156,9 +1155,7 @@ function App() {
|
||||
)}
|
||||
|
||||
<div className="relative min-h-0 flex-1">
|
||||
{activeTool === 'whiteboard' ? (
|
||||
<WhiteboardView />
|
||||
) : activeTool === 'create_pdf' || createPdfModalOpen ? (
|
||||
{activeTool === 'create_pdf' || createPdfModalOpen ? (
|
||||
<CreatePDFModal
|
||||
key={createPdfKey}
|
||||
isOpen={true}
|
||||
|
||||
@@ -73,10 +73,6 @@ const TOOL_META: Record<ToolId, { label: string; icon: React.ReactNode }> = {
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
whiteboard: {
|
||||
label: 'Whiteboard',
|
||||
icon: <DrawIcon size={17} />
|
||||
},
|
||||
watermark: {
|
||||
label: 'Watermark',
|
||||
icon: (
|
||||
@@ -102,8 +98,6 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
||||
pendingRedactionCount = 0, onApplyRedactions, onClearRedactions, onRedactPages, onOpenWatermark,
|
||||
selectedAnnotation, onUpdateAnnotation, onDeleteAnnotation, onDeselectAnnotation
|
||||
}) => {
|
||||
if (activeTool === 'whiteboard') return null;
|
||||
|
||||
const meta = TOOL_META[activeTool];
|
||||
const isRedact = activeTool === 'redact';
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ export type ToolId =
|
||||
| 'strikeout'
|
||||
| 'squiggly'
|
||||
| 'stream_edit'
|
||||
| 'whiteboard'
|
||||
| 'create_pdf'
|
||||
| 'watermark';
|
||||
|
||||
@@ -47,7 +46,6 @@ export const TOOL_SHORTCUTS: Record<string, ToolId> = {
|
||||
h: 'pan',
|
||||
k: 'highlight',
|
||||
d: 'draw',
|
||||
b: 'whiteboard',
|
||||
c: 'comment',
|
||||
t: 'textbox',
|
||||
e: 'edit_text',
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { BoardCanvas } from "./components/BoardCanvas";
|
||||
import { PageIndicator } from "./components/PageIndicator";
|
||||
import { PageSidebar } from "./components/PageSidebar";
|
||||
import { SelectionBar } from "./components/SelectionBar";
|
||||
import { Toolbar } from "./components/Toolbar";
|
||||
import { insertImageFile } from "./persistence/insertImage";
|
||||
import { createNotebook, listNotebooks } from "./persistence/notebooks";
|
||||
import { loadToolPrefs, startPrefsSync } from "./persistence/prefs";
|
||||
import { flushViewStateSave, openNotebook, readLastNotebookId } from "./persistence/session";
|
||||
import { exportPagePng } from "./persistence/exportImage";
|
||||
import { exportNotebookPdf } from "./persistence/exportPdf";
|
||||
import { useBoardStore } from "./store/useBoardStore";
|
||||
import "./styles.css";
|
||||
|
||||
let initStarted = false;
|
||||
|
||||
interface WhiteboardTab {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function WhiteboardView() {
|
||||
const notebookId = useBoardStore((state) => state.notebookId);
|
||||
const presentation = useBoardStore((state) => state.presentation);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [tabs, setTabs] = useState<WhiteboardTab[]>([
|
||||
{ id: 'tab-1', title: 'Whiteboard 1' }
|
||||
]);
|
||||
const [activeTabId, setActiveTabId] = useState('tab-1');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const viewPageIndex = useBoardStore((state) => state.viewPageIndex);
|
||||
const currentPageId = useBoardStore((state) => state.pages[state.viewPageIndex]?.id);
|
||||
|
||||
const {
|
||||
addPage,
|
||||
clearPage,
|
||||
} = useBoardStore.getState();
|
||||
|
||||
useEffect(() => {
|
||||
if (initStarted) {
|
||||
setReady(true);
|
||||
return;
|
||||
}
|
||||
initStarted = true;
|
||||
startPrefsSync();
|
||||
void (async () => {
|
||||
try {
|
||||
useBoardStore.setState(loadToolPrefs());
|
||||
const notebooks = await listNotebooks();
|
||||
if (notebooks.length === 0) {
|
||||
const meta = await createNotebook("Whiteboard 1");
|
||||
await openNotebook(meta.id);
|
||||
} else {
|
||||
const last = notebooks.find((n) => n.id === readLastNotebookId());
|
||||
if (last) await openNotebook(last.id);
|
||||
else await openNotebook(notebooks[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize whiteboard storage", error);
|
||||
} finally {
|
||||
setReady(true);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const target = event.target;
|
||||
const typing =
|
||||
target instanceof HTMLElement &&
|
||||
(target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable);
|
||||
if (event.key === "Escape") {
|
||||
const state = useBoardStore.getState();
|
||||
if (state.presentation) {
|
||||
state.setPresentation(false);
|
||||
} else if (!typing && state.selection) {
|
||||
state.setSelection(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typing) return;
|
||||
if (event.key === "Delete") {
|
||||
const state = useBoardStore.getState();
|
||||
if (state.selection) {
|
||||
event.preventDefault();
|
||||
state.deleteSelection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!(event.metaKey || event.ctrlKey)) return;
|
||||
const key = event.key.toLowerCase();
|
||||
const state = useBoardStore.getState();
|
||||
if (key === "z" && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
state.redo();
|
||||
} else if (key === "z") {
|
||||
event.preventDefault();
|
||||
state.undo();
|
||||
} else if (key === "y") {
|
||||
event.preventDefault();
|
||||
state.redo();
|
||||
} else if (key === "x" && state.selection) {
|
||||
event.preventDefault();
|
||||
state.cutSelection();
|
||||
} else if (key === "c" && state.selection) {
|
||||
event.preventDefault();
|
||||
state.copySelection();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onPaste = (event: ClipboardEvent) => {
|
||||
const target = event.target;
|
||||
if (
|
||||
target instanceof HTMLElement &&
|
||||
(target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const state = useBoardStore.getState();
|
||||
if (!state.notebookId) return;
|
||||
const file = [...(event.clipboardData?.files ?? [])].find((f) => f.type.startsWith("image/"));
|
||||
if (file) {
|
||||
event.preventDefault();
|
||||
void insertImageFile(file).catch((error: unknown) => {
|
||||
console.error("Failed to paste image", error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (state.clipboard.strokes.length > 0 || state.clipboard.images.length > 0) {
|
||||
event.preventDefault();
|
||||
state.pasteClipboard();
|
||||
}
|
||||
};
|
||||
window.addEventListener("paste", onPaste);
|
||||
return () => window.removeEventListener("paste", onPaste);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onPageHide = () => void flushViewStateSave();
|
||||
window.addEventListener("pagehide", onPageHide);
|
||||
return () => window.removeEventListener("pagehide", onPageHide);
|
||||
}, []);
|
||||
|
||||
const handleAddTab = () => {
|
||||
const newId = `tab-${Date.now()}`;
|
||||
const newTitle = `Whiteboard ${tabs.length + 1}`;
|
||||
setTabs((prev) => [...prev, { id: newId, title: newTitle }]);
|
||||
setActiveTabId(newId);
|
||||
addPage();
|
||||
};
|
||||
|
||||
const handleCloseTab = (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (tabs.length <= 1) return;
|
||||
const nextTabs = tabs.filter((t) => t.id !== id);
|
||||
setTabs(nextTabs);
|
||||
if (activeTabId === id) {
|
||||
setActiveTabId(nextTabs[nextTabs.length - 1].id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportPng = async () => {
|
||||
const page = useBoardStore.getState().pages[viewPageIndex];
|
||||
if (!page) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
await exportPagePng("Whiteboard", viewPageIndex, page);
|
||||
} catch (err) {
|
||||
console.error("Failed to export PNG", err);
|
||||
alert("Failed to export PNG");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportPdf = async () => {
|
||||
const { notebookTitle, pages } = useBoardStore.getState();
|
||||
setExporting(true);
|
||||
try {
|
||||
await exportNotebookPdf(notebookTitle || "Whiteboard", pages);
|
||||
} catch (err) {
|
||||
console.error("Failed to export PDF", err);
|
||||
alert("Failed to export PDF");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!ready) return <div className="flex h-full w-full items-center justify-center bg-bg-primary text-text-tertiary">Loading Whiteboard...</div>;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden bg-[#f8fafc] dark:bg-[#0f172a]">
|
||||
{/* Top Whiteboard Tabs Bar */}
|
||||
<div className="flex h-10 shrink-0 items-center border-b border-border-primary bg-bg-secondary px-3">
|
||||
<div className="flex items-center gap-1 overflow-x-auto scrollbar-none">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTabId(tab.id)}
|
||||
className={`group flex h-7 cursor-pointer items-center gap-2 rounded-t-lg border-t border-x px-3 text-xs font-semibold transition-all ${isActive
|
||||
? "border-border-primary bg-bg-primary text-text-primary shadow-xs"
|
||||
: "border-transparent text-text-tertiary hover:bg-bg-tertiary hover:text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
<span>{tab.title}</span>
|
||||
{tabs.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleCloseTab(tab.id, e)}
|
||||
className="flex h-4 w-4 items-center justify-center rounded-full text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddTab}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg text-text-tertiary transition-colors hover:bg-bg-tertiary hover:text-text-primary"
|
||||
title="New Whiteboard Tab"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportPng}
|
||||
disabled={exporting}
|
||||
className="flex h-7 items-center gap-1 rounded-md border border-border-primary bg-bg-primary px-2 text-[11px] font-semibold text-text-secondary hover:bg-bg-tertiary hover:text-text-primary disabled:opacity-50"
|
||||
>
|
||||
Export PNG
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportPdf}
|
||||
disabled={exporting}
|
||||
className="flex h-7 items-center gap-1 rounded-md bg-brand-primary px-2.5 text-[11px] font-semibold text-white hover:bg-brand-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Export PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Canvas Workspace */}
|
||||
<div className="relative flex flex-1 overflow-hidden">
|
||||
{notebookId && (
|
||||
<>
|
||||
<BoardCanvas />
|
||||
{!presentation && <PageSidebar />}
|
||||
{presentation ? null : <Toolbar />}
|
||||
{!presentation && <SelectionBar />}
|
||||
{!presentation && <PageIndicator />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Bottom Left Canvas Actions */}
|
||||
<div className="absolute bottom-4 left-4 z-20 flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addPage}
|
||||
className="flex items-center gap-2 rounded-xl border border-border-primary bg-bg-primary/95 px-3 py-2 text-xs font-semibold text-text-primary shadow-lg backdrop-blur-md transition-all hover:bg-bg-tertiary"
|
||||
>
|
||||
<span className="text-base font-bold">+</span>
|
||||
<span>Add page</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (currentPageId && window.confirm("Clear board?")) {
|
||||
clearPage(currentPageId);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 rounded-xl border border-border-primary bg-bg-primary/95 px-3 py-2 text-xs font-semibold text-red-600 shadow-lg backdrop-blur-md transition-all hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
<span>Clear board</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Board } from "../engine/board";
|
||||
import type { ToolKind } from "../model/stroke";
|
||||
import { startAutosave } from "../persistence/autosave";
|
||||
import { scheduleViewStateSave } from "../persistence/session";
|
||||
import { useBoardStore } from "../store/useBoardStore";
|
||||
|
||||
function cursorForTool(tool: ToolKind): string {
|
||||
if (tool === "eraser") return "cell";
|
||||
if (tool === "laser") return "none";
|
||||
if (tool === "select") return "default";
|
||||
return "crosshair";
|
||||
}
|
||||
|
||||
export function BoardCanvas() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const sidebarOpen = useBoardStore((state) => state.sidebarOpen);
|
||||
const presentation = useBoardStore((state) => state.presentation);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const board = new Board(container, {
|
||||
getTool: () => {
|
||||
const { tool, color, size } = useBoardStore.getState();
|
||||
return { tool, color, size };
|
||||
},
|
||||
onCommitStroke: (pageId, stroke) => useBoardStore.getState().addStroke(pageId, stroke),
|
||||
onEraseStroke: (pageId, strokeId) => useBoardStore.getState().removeStroke(pageId, strokeId),
|
||||
onViewChange: (index) => {
|
||||
if (useBoardStore.getState().viewPageIndex !== index) {
|
||||
useBoardStore.getState().setViewPageIndex(index);
|
||||
}
|
||||
},
|
||||
onSelectionChange: (selection) => useBoardStore.getState().setSelection(selection),
|
||||
onSelectionAnchor: (anchor) => useBoardStore.getState().setSelectionAnchor(anchor),
|
||||
onTransformSelection: (before, after) =>
|
||||
useBoardStore.getState().transformSelection(before, after),
|
||||
onViewportChange: (viewState) => {
|
||||
const { notebookId } = useBoardStore.getState();
|
||||
if (notebookId) scheduleViewStateSave(notebookId, viewState);
|
||||
},
|
||||
});
|
||||
board.syncPages(useBoardStore.getState().pages);
|
||||
const viewState = useBoardStore.getState().viewState;
|
||||
if (viewState) board.restoreViewState(viewState);
|
||||
container.style.cursor = cursorForTool(useBoardStore.getState().tool);
|
||||
const stopAutosave = startAutosave();
|
||||
const unsubscribe = useBoardStore.subscribe((state, prev) => {
|
||||
if (state.pages !== prev.pages) board.syncPages(state.pages);
|
||||
if (state.selection !== prev.selection) board.syncSelection(state.selection);
|
||||
if (state.tool !== prev.tool) {
|
||||
container.style.cursor = cursorForTool(state.tool);
|
||||
if (state.tool !== "select" && state.selection) {
|
||||
useBoardStore.getState().setSelection(null);
|
||||
}
|
||||
}
|
||||
if (
|
||||
state.pendingScrollToPage !== null &&
|
||||
state.pendingScrollToPage !== prev.pendingScrollToPage
|
||||
) {
|
||||
board.scrollToPage(state.pendingScrollToPage);
|
||||
useBoardStore.getState().clearPendingScroll();
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
stopAutosave();
|
||||
unsubscribe();
|
||||
board.destroy();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const shifted = sidebarOpen && !presentation;
|
||||
return <div ref={containerRef} className={shifted ? "board board-shifted" : "board"} />;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { type CSSProperties, useEffect, useState } from "react";
|
||||
import { normalizeHex } from "../model/color";
|
||||
|
||||
export function ColorField({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (color: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="color-field">
|
||||
<label
|
||||
className="color-picker"
|
||||
style={{ "--value": value } as CSSProperties}
|
||||
title="Custom color"
|
||||
>
|
||||
<input type="color" value={value} onChange={(e) => onChange(e.target.value)} />
|
||||
</label>
|
||||
<HexInput value={value} onCommit={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HexInput({ value, onCommit }: { value: string; onCommit: (color: string) => void }) {
|
||||
const [text, setText] = useState(value);
|
||||
useEffect(() => setText(value), [value]);
|
||||
|
||||
const commit = () => {
|
||||
const normalized = normalizeHex(text);
|
||||
if (normalized && normalized !== value) onCommit(normalized);
|
||||
setText(normalized ?? value);
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
className="hex-input"
|
||||
value={text}
|
||||
aria-label="Hex color value"
|
||||
spellCheck={false}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") e.currentTarget.blur();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { NotebookRecord } from "../persistence/db";
|
||||
import { importPdfFile } from "../persistence/importPdf";
|
||||
import {
|
||||
createNotebook,
|
||||
deleteNotebook,
|
||||
listNotebooks,
|
||||
mergeNotebooks,
|
||||
renameNotebook,
|
||||
} from "../persistence/notebooks";
|
||||
import { downloadNotebook, importNotebookFile } from "../persistence/transfer";
|
||||
import { formatRelativeTime } from "./formatTime";
|
||||
import { ExportIcon, RenameIcon, TrashIcon } from "./Icons";
|
||||
|
||||
interface HomeProps {
|
||||
onOpen: (id: string) => void;
|
||||
}
|
||||
|
||||
export function Home({ onOpen }: HomeProps) {
|
||||
const [notebooks, setNotebooks] = useState<NotebookRecord[] | null>(null);
|
||||
const [pdfProgress, setPdfProgress] = useState<{ done: number; total: number } | null>(null);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const pdfInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const list = await listNotebooks();
|
||||
setNotebooks(list);
|
||||
setSelected((prev) => prev.filter((id) => list.some((n) => n.id === id)));
|
||||
} catch (error) {
|
||||
console.error("Failed to load notebooks", error);
|
||||
window.alert("Failed to load notebooks. Local storage may be unavailable.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const createAndOpen = async () => {
|
||||
try {
|
||||
const meta = await createNotebook(`Notebook ${(notebooks?.length ?? 0) + 1}`);
|
||||
onOpen(meta.id);
|
||||
} catch (error) {
|
||||
console.error("Failed to create notebook", error);
|
||||
window.alert("Failed to create a notebook.");
|
||||
}
|
||||
};
|
||||
|
||||
const rename = async (notebook: NotebookRecord) => {
|
||||
const title = window.prompt("Notebook name", notebook.title)?.trim();
|
||||
if (!title || title === notebook.title) return;
|
||||
try {
|
||||
await renameNotebook(notebook.id, title);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
console.error("Failed to rename notebook", error);
|
||||
window.alert("Failed to rename the notebook.");
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (notebook: NotebookRecord) => {
|
||||
if (!window.confirm(`Delete "${notebook.title}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
await deleteNotebook(notebook.id);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete notebook", error);
|
||||
window.alert("Failed to delete the notebook.");
|
||||
}
|
||||
};
|
||||
|
||||
const importFile = async (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
try {
|
||||
await importNotebookFile(file);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
window.alert(error instanceof Error ? error.message : "Import failed");
|
||||
}
|
||||
};
|
||||
|
||||
const importPdf = async (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
setPdfProgress({ done: 0, total: 0 });
|
||||
try {
|
||||
const id = await importPdfFile(file, (done, total) => setPdfProgress({ done, total }));
|
||||
await refresh();
|
||||
onOpen(id);
|
||||
} catch (error) {
|
||||
window.alert(error instanceof Error ? error.message : "PDF import failed");
|
||||
} finally {
|
||||
setPdfProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||
};
|
||||
|
||||
const merge = async () => {
|
||||
if (selected.length === 0) return;
|
||||
const first = notebooks?.find((n) => n.id === selected[0]);
|
||||
const fallback = selected.length === 1 && first ? `${first.title} copy` : "Merged notebook";
|
||||
const title = window.prompt("Name for the merged notebook", fallback)?.trim();
|
||||
if (!title) return;
|
||||
try {
|
||||
const id = await mergeNotebooks(selected, title);
|
||||
setSelected([]);
|
||||
await refresh();
|
||||
onOpen(id);
|
||||
} catch (error) {
|
||||
console.error("Merge failed", error);
|
||||
window.alert("Merge failed.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="home">
|
||||
<header className="home-header">
|
||||
<h1>vas</h1>
|
||||
<div className="home-actions">
|
||||
<button type="button" className="primary" onClick={() => void createAndOpen()}>
|
||||
New notebook
|
||||
</button>
|
||||
<button type="button" onClick={() => fileInputRef.current?.click()}>
|
||||
Import
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,.zip,application/json,application/zip"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
void importFile(e.target.files?.[0]);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pdfProgress !== null}
|
||||
onClick={() => pdfInputRef.current?.click()}
|
||||
>
|
||||
{pdfProgress
|
||||
? pdfProgress.total > 0
|
||||
? `Importing PDF… ${pdfProgress.done}/${pdfProgress.total}`
|
||||
: "Importing PDF…"
|
||||
: "Import PDF"}
|
||||
</button>
|
||||
<input
|
||||
ref={pdfInputRef}
|
||||
type="file"
|
||||
accept=".pdf,application/pdf"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
void importPdf(e.target.files?.[0]);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selected.length === 0}
|
||||
title="Merge selected notebooks"
|
||||
onClick={() => void merge()}
|
||||
>
|
||||
{selected.length > 0 ? `Merge (${selected.length})` : "Merge"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{notebooks !== null && notebooks.length === 0 && (
|
||||
<p className="home-empty">No notebooks yet. Create one to start writing.</p>
|
||||
)}
|
||||
{notebooks !== null && notebooks.length > 0 && (
|
||||
<div className="notebook-grid">
|
||||
{notebooks.map((notebook) => (
|
||||
<div
|
||||
key={notebook.id}
|
||||
className={
|
||||
selected.includes(notebook.id) ? "notebook-card selected" : "notebook-card"
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="notebook-checkbox"
|
||||
checked={selected.includes(notebook.id)}
|
||||
onChange={() => toggleSelect(notebook.id)}
|
||||
aria-label={`Select ${notebook.title}`}
|
||||
/>
|
||||
<button type="button" className="notebook-open" onClick={() => onOpen(notebook.id)}>
|
||||
<span className="notebook-title">{notebook.title}</span>
|
||||
<span className="notebook-meta">
|
||||
{notebook.pageCount} {notebook.pageCount === 1 ? "page" : "pages"} ·{" "}
|
||||
{formatRelativeTime(notebook.updatedAt)}
|
||||
</span>
|
||||
</button>
|
||||
<div className="notebook-actions">
|
||||
<button type="button" title="Rename" onClick={() => void rename(notebook)}>
|
||||
<RenameIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Export"
|
||||
onClick={() => {
|
||||
void downloadNotebook(notebook.id).catch((error: unknown) => {
|
||||
console.error("Export failed", error);
|
||||
window.alert("Export failed.");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ExportIcon />
|
||||
</button>
|
||||
<button type="button" title="Delete" onClick={() => void remove(notebook)}>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
function Icon({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<title>{title}</title>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PenIcon() {
|
||||
return (
|
||||
<Icon title="Pen">
|
||||
<path d="M4 20l1.5-4.5L16 5l3 3L8.5 18.5 4 20z" />
|
||||
<path d="M13.5 7.5l3 3" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function HighlighterIcon() {
|
||||
return (
|
||||
<Icon title="Highlighter">
|
||||
<path d="M5 15L14 6l4 4-9 9H5v-4z" />
|
||||
<path d="M13 20h7" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function EraserIcon() {
|
||||
return (
|
||||
<Icon title="Eraser">
|
||||
<path d="M7 21l-4-4L13 7l4 4L7 21z" />
|
||||
<path d="M11 9l4 4" />
|
||||
<path d="M13 21h8" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsIcon() {
|
||||
return (
|
||||
<Icon title="Settings">
|
||||
<path d="M4 8h16" />
|
||||
<path d="M4 16h16" />
|
||||
<circle cx="9" cy="8" r="2" />
|
||||
<circle cx="15" cy="16" r="2" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function BackIcon() {
|
||||
return (
|
||||
<Icon title="Back to notebooks">
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function UndoIcon() {
|
||||
return (
|
||||
<Icon title="Undo">
|
||||
<path d="M9 14L4 9l5-5" />
|
||||
<path d="M4 9h10a6 6 0 0 1 0 12h-3" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function RedoIcon() {
|
||||
return (
|
||||
<Icon title="Redo">
|
||||
<path d="M15 14l5-5-5-5" />
|
||||
<path d="M20 9H10a6 6 0 0 0 0 12h3" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrashIcon() {
|
||||
return (
|
||||
<Icon title="Delete">
|
||||
<path d="M4 7h16" />
|
||||
<path d="M9 7V4h6v3" />
|
||||
<path d="M6 7l1 13h10l1-13" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddPageIcon() {
|
||||
return (
|
||||
<Icon title="Add page">
|
||||
<rect x="5" y="3" width="14" height="18" rx="2" />
|
||||
<path d="M12 8v8" />
|
||||
<path d="M8 12h8" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeletePageIcon() {
|
||||
return (
|
||||
<Icon title="Delete page">
|
||||
<rect x="5" y="3" width="14" height="18" rx="2" />
|
||||
<path d="M9.5 9.5l5 5" />
|
||||
<path d="M14.5 9.5l-5 5" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarIcon() {
|
||||
return (
|
||||
<Icon title="Pages panel">
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
<path d="M9 4v16" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function PresentIcon() {
|
||||
return (
|
||||
<Icon title="Present">
|
||||
<rect x="3" y="4" width="18" height="12" rx="2" />
|
||||
<path d="M12 16v4" />
|
||||
<path d="M9 20h6" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function PasteIcon() {
|
||||
return (
|
||||
<Icon title="Paste">
|
||||
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageIcon() {
|
||||
return (
|
||||
<Icon title="Insert image">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<path d="M21 15l-5-5L5 21" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function RenameIcon() {
|
||||
return (
|
||||
<Icon title="Rename">
|
||||
<path d="M4 20l1.5-4.5L16 5l3 3L8.5 18.5 4 20z" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExportIcon() {
|
||||
return (
|
||||
<Icon title="Export">
|
||||
<path d="M12 4v12" />
|
||||
<path d="M7 11l5 5 5-5" />
|
||||
<path d="M4 20h16" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExitIcon() {
|
||||
return (
|
||||
<Icon title="Exit presentation">
|
||||
<path d="M6 6l12 12" />
|
||||
<path d="M18 6L6 18" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { useBoardStore } from "../store/useBoardStore";
|
||||
|
||||
export function PageIndicator() {
|
||||
const index = useBoardStore((state) => state.viewPageIndex);
|
||||
const count = useBoardStore((state) => state.pages.length);
|
||||
return (
|
||||
<div className="page-indicator">
|
||||
{index + 1} / {count}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
import { type PointerEvent as ReactPointerEvent, useEffect, useRef, useState } from "react";
|
||||
import { onImageLoaded } from "../engine/imageCache";
|
||||
import { paintPage } from "../engine/renderPage";
|
||||
import { PAGE_WIDTH, type Page } from "../model/page";
|
||||
import { useBoardStore } from "../store/useBoardStore";
|
||||
|
||||
const THUMB_WIDTH = 336;
|
||||
const LONG_PRESS_MS = 500;
|
||||
const MOVE_CANCEL_PX = 10;
|
||||
const EDGE_SCROLL_PX = 48;
|
||||
const EDGE_SCROLL_STEP = 14;
|
||||
|
||||
interface Interaction {
|
||||
phase: "pressing" | "dragging";
|
||||
pointerId: number;
|
||||
fromIndex: number;
|
||||
startY: number;
|
||||
offsetY: number;
|
||||
dropPos: number;
|
||||
element: HTMLElement;
|
||||
}
|
||||
|
||||
interface DragRender {
|
||||
fromIndex: number;
|
||||
offsetY: number;
|
||||
dropPos: number;
|
||||
}
|
||||
|
||||
export function PageSidebar() {
|
||||
const open = useBoardStore((state) => state.sidebarOpen);
|
||||
const pages = useBoardStore((state) => state.pages);
|
||||
const viewPageIndex = useBoardStore((state) => state.viewPageIndex);
|
||||
const [drag, setDrag] = useState<DragRender | null>(null);
|
||||
const asideRef = useRef<HTMLElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const interactionRef = useRef<Interaction | null>(null);
|
||||
const timerRef = useRef<number | undefined>(undefined);
|
||||
const suppressClickRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const cancelTimer = () => {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = undefined;
|
||||
};
|
||||
const endInteraction = (commit: boolean) => {
|
||||
const it = interactionRef.current;
|
||||
interactionRef.current = null;
|
||||
cancelTimer();
|
||||
setDrag(null);
|
||||
if (!it) return;
|
||||
it.element.style.touchAction = "";
|
||||
if (commit && it.phase === "dragging") {
|
||||
if (it.dropPos !== it.fromIndex) {
|
||||
useBoardStore.getState().movePage(it.fromIndex, it.dropPos);
|
||||
}
|
||||
suppressClickRef.current = true;
|
||||
}
|
||||
};
|
||||
const onMove = (event: PointerEvent) => {
|
||||
const it = interactionRef.current;
|
||||
if (!it || event.pointerId !== it.pointerId) return;
|
||||
if (event.pointerType === "mouse" && event.buttons === 0) {
|
||||
endInteraction(false);
|
||||
return;
|
||||
}
|
||||
if (it.phase === "pressing") {
|
||||
if (Math.abs(event.clientY - it.startY) > MOVE_CANCEL_PX) endInteraction(false);
|
||||
return;
|
||||
}
|
||||
it.offsetY = event.clientY - it.startY;
|
||||
const items = [...(listRef.current?.querySelectorAll(".thumbnail") ?? [])];
|
||||
const others = items.filter((_, i) => i !== it.fromIndex);
|
||||
let dropPos = 0;
|
||||
for (const item of others) {
|
||||
const rect = item.getBoundingClientRect();
|
||||
if (event.clientY > rect.top + rect.height / 2) dropPos++;
|
||||
}
|
||||
it.dropPos = dropPos;
|
||||
const aside = asideRef.current;
|
||||
if (aside) {
|
||||
const rect = aside.getBoundingClientRect();
|
||||
if (event.clientY < rect.top + EDGE_SCROLL_PX) aside.scrollTop -= EDGE_SCROLL_STEP;
|
||||
else if (event.clientY > rect.bottom - EDGE_SCROLL_PX) aside.scrollTop += EDGE_SCROLL_STEP;
|
||||
}
|
||||
setDrag({ fromIndex: it.fromIndex, offsetY: it.offsetY, dropPos: it.dropPos });
|
||||
};
|
||||
const onEnd = (event: PointerEvent) => {
|
||||
const it = interactionRef.current;
|
||||
if (!it || event.pointerId !== it.pointerId) return;
|
||||
endInteraction(event.type !== "pointercancel");
|
||||
};
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", onEnd);
|
||||
window.addEventListener("pointercancel", onEnd);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onEnd);
|
||||
window.removeEventListener("pointercancel", onEnd);
|
||||
cancelTimer();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handlePointerDown = (event: ReactPointerEvent<HTMLElement>, index: number) => {
|
||||
suppressClickRef.current = false;
|
||||
if (interactionRef.current) return;
|
||||
if (event.pointerType === "mouse" && event.button !== 0) return;
|
||||
interactionRef.current = {
|
||||
phase: "pressing",
|
||||
pointerId: event.pointerId,
|
||||
fromIndex: index,
|
||||
startY: event.clientY,
|
||||
offsetY: 0,
|
||||
dropPos: index,
|
||||
element: event.currentTarget,
|
||||
};
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
const it = interactionRef.current;
|
||||
if (it?.phase !== "pressing") return;
|
||||
it.phase = "dragging";
|
||||
it.element.style.touchAction = "none";
|
||||
setDrag({ fromIndex: it.fromIndex, offsetY: 0, dropPos: it.dropPos });
|
||||
}, LONG_PRESS_MS);
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const othersCount = pages.length - 1;
|
||||
const indicatorBeforeIndex =
|
||||
drag && drag.dropPos !== drag.fromIndex && drag.dropPos < othersCount
|
||||
? drag.dropPos + (drag.dropPos >= drag.fromIndex ? 1 : 0)
|
||||
: null;
|
||||
const indicatorAfterIndex =
|
||||
drag && drag.dropPos !== drag.fromIndex && drag.dropPos === othersCount
|
||||
? othersCount - 1 >= drag.fromIndex
|
||||
? othersCount
|
||||
: othersCount - 1
|
||||
: null;
|
||||
|
||||
return (
|
||||
<aside ref={asideRef} className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<span>Pages</span>
|
||||
<button
|
||||
type="button"
|
||||
title="Close pages panel"
|
||||
onClick={() => useBoardStore.getState().toggleSidebar()}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div ref={listRef} className="sidebar-list">
|
||||
{pages.map((page, index) => (
|
||||
<PageThumbnail
|
||||
key={page.id}
|
||||
page={page}
|
||||
index={index}
|
||||
active={index === viewPageIndex}
|
||||
dragging={drag?.fromIndex === index}
|
||||
dropBefore={index === indicatorBeforeIndex}
|
||||
dropAfter={index === indicatorAfterIndex}
|
||||
dragOffset={drag?.fromIndex === index ? drag.offsetY : 0}
|
||||
onPointerDown={(e) => handlePointerDown(e, index)}
|
||||
onOpen={() => {
|
||||
if (suppressClickRef.current) {
|
||||
suppressClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
useBoardStore.getState().requestScrollToPage(index);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function PageThumbnail({
|
||||
page,
|
||||
index,
|
||||
active,
|
||||
dragging,
|
||||
dropBefore,
|
||||
dropAfter,
|
||||
dragOffset,
|
||||
onPointerDown,
|
||||
onOpen,
|
||||
}: {
|
||||
page: Page;
|
||||
index: number;
|
||||
active: boolean;
|
||||
dragging: boolean;
|
||||
dropBefore: boolean;
|
||||
dropAfter: boolean;
|
||||
dragOffset: number;
|
||||
onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const scale = THUMB_WIDTH / PAGE_WIDTH;
|
||||
paintPage(canvas, page, scale);
|
||||
if (page.images.length === 0) return;
|
||||
return onImageLoaded(() => paintPage(canvas, page, scale));
|
||||
}, [page]);
|
||||
|
||||
const className = [
|
||||
"thumbnail",
|
||||
active ? "active" : "",
|
||||
dragging ? "dragging" : "",
|
||||
dropBefore ? "drop-before" : "",
|
||||
dropAfter ? "drop-after" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
style={dragging ? { transform: `translateY(${dragOffset}px)` } : undefined}
|
||||
title={`Go to page ${index + 1}`}
|
||||
onPointerDown={onPointerDown}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onClick={onOpen}
|
||||
>
|
||||
<canvas ref={canvasRef} />
|
||||
<span>{index + 1}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { COLORS, useBoardStore } from "../store/useBoardStore";
|
||||
import { ColorField } from "./ColorField";
|
||||
|
||||
export function SelectionBar() {
|
||||
const selection = useBoardStore((state) => state.selection);
|
||||
const anchor = useBoardStore((state) => state.selectionAnchor);
|
||||
const tool = useBoardStore((state) => state.tool);
|
||||
const inkColor = useBoardStore((state) => {
|
||||
const sel = state.selection;
|
||||
if (!sel) return null;
|
||||
const page = state.pages.find((p) => p.id === sel.pageId);
|
||||
return page?.strokes.find((s) => s.id === sel.strokeIds[0])?.color ?? null;
|
||||
});
|
||||
if (!selection || !anchor || tool !== "select") return null;
|
||||
|
||||
const { recolorSelection, cutSelection, copySelection, deleteSelection } =
|
||||
useBoardStore.getState();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="selection-bar"
|
||||
style={{
|
||||
left: `clamp(${Math.min(190, (window.innerWidth - 12) / 2)}px, ${anchor.x}px, calc(100vw - ${Math.min(190, (window.innerWidth - 12) / 2)}px))`,
|
||||
top: Math.max(anchor.y, 52),
|
||||
}}
|
||||
role="toolbar"
|
||||
aria-label="Selection actions"
|
||||
>
|
||||
{selection.strokeIds.length > 0 && (
|
||||
<>
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
title={`Color ${c}`}
|
||||
className="swatch"
|
||||
style={{ "--swatch": c } as CSSProperties}
|
||||
onClick={() => recolorSelection(c)}
|
||||
>
|
||||
<span />
|
||||
</button>
|
||||
))}
|
||||
<ColorField value={inkColor ?? COLORS[0]} onChange={recolorSelection} />
|
||||
<div className="selection-divider" />
|
||||
</>
|
||||
)}
|
||||
<button type="button" className="text-btn" onClick={cutSelection}>
|
||||
Cut
|
||||
</button>
|
||||
<button type="button" className="text-btn" onClick={copySelection}>
|
||||
Copy
|
||||
</button>
|
||||
<button type="button" className="text-btn" onClick={deleteSelection}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
import { type CSSProperties, useRef, useState } from "react";
|
||||
import { PAGE_PATTERNS, type PagePattern } from "../model/page";
|
||||
import { SHAPE_KINDS, type ShapeKind } from "../model/stroke";
|
||||
import { exportPagePng } from "../persistence/exportImage";
|
||||
import { exportNotebookPdf } from "../persistence/exportPdf";
|
||||
import { rasterizePdf, saveRasterizedImages } from "../persistence/importPdf";
|
||||
import { insertImageFile } from "../persistence/insertImage";
|
||||
import { COLORS, PAPER_COLORS, SIZES, useBoardStore } from "../store/useBoardStore";
|
||||
import { ColorField } from "./ColorField";
|
||||
import {
|
||||
AddPageIcon,
|
||||
DeletePageIcon,
|
||||
ImageIcon,
|
||||
PasteIcon,
|
||||
PresentIcon,
|
||||
RedoIcon,
|
||||
SidebarIcon,
|
||||
TrashIcon,
|
||||
UndoIcon,
|
||||
} from "./Icons";
|
||||
|
||||
const PATTERN_LABELS: Record<PagePattern, string> = {
|
||||
blank: "Blank",
|
||||
lined: "Lines",
|
||||
grid: "Grid",
|
||||
dots: "Dots",
|
||||
rice: "Rice",
|
||||
};
|
||||
|
||||
const SHAPE_LABELS: Record<ShapeKind, string> = {
|
||||
line: "Line",
|
||||
arrow: "Arrow",
|
||||
rect: "Rect",
|
||||
ellipse: "Ellipse",
|
||||
};
|
||||
|
||||
export function SettingsPanel() {
|
||||
const tool = useBoardStore((state) => state.tool);
|
||||
const inkColor = useBoardStore((state) => state.color);
|
||||
const size = useBoardStore((state) => state.size);
|
||||
const paperColor = useBoardStore(
|
||||
(state) => state.pages[state.viewPageIndex]?.paperColor ?? state.paperColor,
|
||||
);
|
||||
const pattern = useBoardStore(
|
||||
(state) => state.pages[state.viewPageIndex]?.pattern ?? state.pattern,
|
||||
);
|
||||
const canUndo = useBoardStore((state) => state.past.length > 0);
|
||||
const canRedo = useBoardStore((state) => state.future.length > 0);
|
||||
const currentPageId = useBoardStore((state) => state.pages[state.viewPageIndex]?.id);
|
||||
const canClear = useBoardStore((state) => {
|
||||
const page = state.pages[state.viewPageIndex];
|
||||
return (page?.strokes.length ?? 0) > 0 || (page?.images.some((i) => !i.locked) ?? false);
|
||||
});
|
||||
const canDeletePage = useBoardStore((state) => state.pages.length > 1);
|
||||
const canPaste = useBoardStore(
|
||||
(state) => state.clipboard.strokes.length > 0 || state.clipboard.images.length > 0,
|
||||
);
|
||||
const sidebarOpen = useBoardStore((state) => state.sidebarOpen);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [pdfImporting, setPdfImporting] = useState<{ done: number; total: number } | null>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const pdfInputRef = useRef<HTMLInputElement>(null);
|
||||
const {
|
||||
setTool,
|
||||
setColor,
|
||||
setSize,
|
||||
setPaperColor,
|
||||
setPattern,
|
||||
setPresentation,
|
||||
toggleSidebar,
|
||||
undo,
|
||||
redo,
|
||||
clearPage,
|
||||
addPage,
|
||||
deletePage,
|
||||
pasteClipboard,
|
||||
} = useBoardStore.getState();
|
||||
|
||||
const confirmClear = () => {
|
||||
if (currentPageId && window.confirm("Clear this page?")) clearPage(currentPageId);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (currentPageId && window.confirm("Delete this page and everything on it?")) {
|
||||
deletePage(currentPageId);
|
||||
}
|
||||
};
|
||||
|
||||
const exportPdf = async () => {
|
||||
const { notebookTitle: title, pages } = useBoardStore.getState();
|
||||
setExporting(true);
|
||||
try {
|
||||
await exportNotebookPdf(title || "vas notebook", pages);
|
||||
} catch (error) {
|
||||
console.error("PDF export failed", error);
|
||||
window.alert("PDF export failed.");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportPng = async () => {
|
||||
const { notebookTitle: title, pages, viewPageIndex } = useBoardStore.getState();
|
||||
const page = pages[viewPageIndex];
|
||||
if (!page) return;
|
||||
try {
|
||||
await exportPagePng(title || "vas notebook", viewPageIndex, page);
|
||||
} catch (error) {
|
||||
console.error("PNG export failed", error);
|
||||
window.alert("PNG export failed.");
|
||||
}
|
||||
};
|
||||
|
||||
const pickImage = async (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
try {
|
||||
await insertImageFile(file);
|
||||
} catch (error) {
|
||||
console.error("Failed to insert image", error);
|
||||
window.alert("Failed to insert image.");
|
||||
}
|
||||
};
|
||||
|
||||
const importPdf = async (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
setPdfImporting({ done: 0, total: 0 });
|
||||
try {
|
||||
const rasterized = await rasterizePdf(file, (done, total) =>
|
||||
setPdfImporting({ done, total }),
|
||||
);
|
||||
await saveRasterizedImages(rasterized);
|
||||
useBoardStore.getState().insertPdfPages(rasterized);
|
||||
} catch (error) {
|
||||
console.error("PDF import failed", error);
|
||||
window.alert(error instanceof Error ? error.message : "PDF import failed");
|
||||
} finally {
|
||||
setPdfImporting(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-panel">
|
||||
<section className="settings-section">
|
||||
<div className="settings-label">Tool</div>
|
||||
<div className="settings-row">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={tool === "pen"}
|
||||
className={tool === "pen" ? "text-option active" : "text-option"}
|
||||
onClick={() => setTool("pen")}
|
||||
>
|
||||
Pen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={tool === "highlighter"}
|
||||
className={tool === "highlighter" ? "text-option active" : "text-option"}
|
||||
onClick={() => setTool("highlighter")}
|
||||
>
|
||||
Highlighter
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={tool === "laser"}
|
||||
className={tool === "laser" ? "text-option active" : "text-option"}
|
||||
onClick={() => setTool("laser")}
|
||||
>
|
||||
Laser
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={tool === "select"}
|
||||
className={tool === "select" ? "text-option active" : "text-option"}
|
||||
onClick={() => setTool("select")}
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section className="settings-section">
|
||||
<div className="settings-label">Shape</div>
|
||||
<div className="settings-row">
|
||||
{SHAPE_KINDS.map((kind) => (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
aria-pressed={tool === kind}
|
||||
className={tool === kind ? "text-option active" : "text-option"}
|
||||
onClick={() => setTool(kind)}
|
||||
>
|
||||
{SHAPE_LABELS[kind]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="settings-section">
|
||||
<div className="settings-label">Ink</div>
|
||||
<div className="settings-row">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
title={`Color ${c}`}
|
||||
aria-pressed={inkColor === c}
|
||||
className={inkColor === c ? "swatch active" : "swatch"}
|
||||
style={{ "--swatch": c } as CSSProperties}
|
||||
onClick={() => setColor(c)}
|
||||
>
|
||||
<span />
|
||||
</button>
|
||||
))}
|
||||
<ColorField value={inkColor} onChange={setColor} />
|
||||
</div>
|
||||
</section>
|
||||
<section className="settings-section">
|
||||
<div className="settings-label">Size</div>
|
||||
<div className="settings-row">
|
||||
{SIZES.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
title={`Size ${s}`}
|
||||
aria-pressed={size === s}
|
||||
className={size === s ? "size-option active" : "size-option"}
|
||||
onClick={() => setSize(s)}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: Math.round(3 + s * 1.6),
|
||||
height: Math.round(3 + s * 1.6),
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
type="range"
|
||||
className="size-slider"
|
||||
min={0.5}
|
||||
max={12}
|
||||
step={0.5}
|
||||
value={size}
|
||||
aria-label="Stroke size"
|
||||
onChange={(e) => setSize(Number(e.target.value))}
|
||||
/>
|
||||
<span className="size-value">{size}</span>
|
||||
</div>
|
||||
</section>
|
||||
<section className="settings-section">
|
||||
<div className="settings-label">Paper</div>
|
||||
<div className="settings-row">
|
||||
{PAPER_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
title={`Paper ${c}`}
|
||||
aria-pressed={paperColor === c}
|
||||
className={paperColor === c ? "swatch active" : "swatch"}
|
||||
style={{ "--swatch": c } as CSSProperties}
|
||||
onClick={() => setPaperColor(c)}
|
||||
>
|
||||
<span />
|
||||
</button>
|
||||
))}
|
||||
<ColorField value={paperColor} onChange={setPaperColor} />
|
||||
</div>
|
||||
</section>
|
||||
<section className="settings-section">
|
||||
<div className="settings-label">Template</div>
|
||||
<div className="settings-row">
|
||||
{PAGE_PATTERNS.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
aria-pressed={pattern === p}
|
||||
className={pattern === p ? "text-option active" : "text-option"}
|
||||
onClick={() => setPattern(p)}
|
||||
>
|
||||
{PATTERN_LABELS[p]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="settings-section">
|
||||
<div className="settings-label">Actions</div>
|
||||
<div className="settings-row">
|
||||
<button type="button" title="Undo" disabled={!canUndo} onClick={undo}>
|
||||
<UndoIcon />
|
||||
</button>
|
||||
<button type="button" title="Redo" disabled={!canRedo} onClick={redo}>
|
||||
<RedoIcon />
|
||||
</button>
|
||||
<button type="button" title="Clear page" disabled={!canClear} onClick={confirmClear}>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
<button type="button" title="Add page" onClick={addPage}>
|
||||
<AddPageIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Delete page"
|
||||
disabled={!canDeletePage}
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
<DeletePageIcon />
|
||||
</button>
|
||||
<button type="button" title="Paste" disabled={!canPaste} onClick={pasteClipboard}>
|
||||
<PasteIcon />
|
||||
</button>
|
||||
<button type="button" title="Insert image" onClick={() => imageInputRef.current?.click()}>
|
||||
<ImageIcon />
|
||||
</button>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
void pickImage(e.target.files?.[0]);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<button type="button" title="Present" onClick={() => setPresentation(true)}>
|
||||
<PresentIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Pages panel"
|
||||
aria-pressed={sidebarOpen}
|
||||
className={sidebarOpen ? "active" : ""}
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section className="settings-section">
|
||||
<div className="settings-label">File</div>
|
||||
<div className="settings-row">
|
||||
<button
|
||||
type="button"
|
||||
className="text-option"
|
||||
disabled={pdfImporting !== null}
|
||||
onClick={() => pdfInputRef.current?.click()}
|
||||
>
|
||||
{pdfImporting
|
||||
? pdfImporting.total > 0
|
||||
? `Importing… ${pdfImporting.done}/${pdfImporting.total}`
|
||||
: "Importing…"
|
||||
: "Import PDF"}
|
||||
</button>
|
||||
<input
|
||||
ref={pdfInputRef}
|
||||
type="file"
|
||||
accept=".pdf,application/pdf"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
void importPdf(e.target.files?.[0]);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-option"
|
||||
disabled={exporting}
|
||||
onClick={() => void exportPdf()}
|
||||
>
|
||||
{exporting ? "Exporting…" : "PDF (vector)"}
|
||||
</button>
|
||||
<button type="button" className="text-option" onClick={() => void exportPng()}>
|
||||
PNG (this page)
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { closeNotebook } from "../persistence/session";
|
||||
import { useBoardStore } from "../store/useBoardStore";
|
||||
import { BackIcon, EraserIcon, HighlighterIcon, PenIcon, SettingsIcon } from "./Icons";
|
||||
import { SettingsPanel } from "./SettingsPanel";
|
||||
|
||||
export function Toolbar() {
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const tool = useBoardStore((state) => state.tool);
|
||||
const lastPenKind = useBoardStore((state) => state.lastPenKind);
|
||||
const { setTool } = useBoardStore.getState();
|
||||
|
||||
const penActive = tool === "pen" || tool === "highlighter";
|
||||
const shownKind = penActive ? tool : lastPenKind;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="toolbar" role="toolbar" aria-label="Drawing tools">
|
||||
<button
|
||||
type="button"
|
||||
title={shownKind === "highlighter" ? "Highlighter" : "Pen"}
|
||||
aria-pressed={penActive}
|
||||
className={penActive ? "active" : ""}
|
||||
onClick={() => setTool(penActive ? tool : lastPenKind)}
|
||||
>
|
||||
{shownKind === "highlighter" ? <HighlighterIcon /> : <PenIcon />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Eraser"
|
||||
aria-pressed={tool === "eraser"}
|
||||
className={tool === "eraser" ? "active" : ""}
|
||||
onClick={() => setTool("eraser")}
|
||||
>
|
||||
<EraserIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Settings"
|
||||
aria-expanded={panelOpen}
|
||||
className={panelOpen ? "active" : ""}
|
||||
onClick={() => setPanelOpen((open) => !open)}
|
||||
>
|
||||
<SettingsIcon />
|
||||
</button>
|
||||
<button type="button" title="Back to notebooks" onClick={() => void closeNotebook()}>
|
||||
<BackIcon />
|
||||
</button>
|
||||
</div>
|
||||
{panelOpen && <SettingsPanel />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatRelativeTime } from "./formatTime";
|
||||
|
||||
const now = new Date("2026-07-23T12:00:00").getTime();
|
||||
|
||||
describe("formatRelativeTime", () => {
|
||||
it("reports just now for under a minute", () => {
|
||||
expect(formatRelativeTime(now - 30_000, now)).toBe("just now");
|
||||
});
|
||||
|
||||
it("reports minutes", () => {
|
||||
expect(formatRelativeTime(now - 5 * 60_000, now)).toBe("5 min ago");
|
||||
});
|
||||
|
||||
it("reports hours", () => {
|
||||
expect(formatRelativeTime(now - 3 * 3_600_000, now)).toBe("3 h ago");
|
||||
});
|
||||
|
||||
it("reports days", () => {
|
||||
expect(formatRelativeTime(now - 2 * 86_400_000, now)).toBe("2 d ago");
|
||||
});
|
||||
|
||||
it("falls back to a date for older timestamps", () => {
|
||||
const result = formatRelativeTime(now - 30 * 86_400_000, now);
|
||||
expect(result).not.toContain("ago");
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("treats future timestamps as just now", () => {
|
||||
expect(formatRelativeTime(now + 60_000, now)).toBe("just now");
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
export function formatRelativeTime(timestamp: number, now = Date.now()): string {
|
||||
const diff = Math.max(0, now - timestamp);
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes} min ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) return `${days} d ago`;
|
||||
return new Date(timestamp).toLocaleDateString();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
||||
export function get2dContext(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Canvas 2D context is unavailable");
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { getImage } from "../persistence/images";
|
||||
|
||||
const cache = new Map<string, HTMLImageElement>();
|
||||
const failed = new Set<string>();
|
||||
const pending = new Map<string, Promise<HTMLImageElement | null>>();
|
||||
const listeners = new Set<(imageId: string) => void>();
|
||||
|
||||
export function getImageBitmap(imageId: string): HTMLImageElement | null {
|
||||
const hit = cache.get(imageId);
|
||||
if (hit) return hit;
|
||||
if (!failed.has(imageId)) void ensureImageLoaded(imageId);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ensureImageLoaded(imageId: string): Promise<HTMLImageElement | null> {
|
||||
const hit = cache.get(imageId);
|
||||
if (hit) return Promise.resolve(hit);
|
||||
if (failed.has(imageId)) return Promise.resolve(null);
|
||||
const existing = pending.get(imageId);
|
||||
if (existing) return existing;
|
||||
const task = load(imageId);
|
||||
pending.set(imageId, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
export function primeImage(imageId: string, image: HTMLImageElement): void {
|
||||
cache.set(imageId, image);
|
||||
for (const listener of listeners) listener(imageId);
|
||||
}
|
||||
|
||||
export function onImageLoaded(listener: (imageId: string) => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function decodeBlob(blob: Blob): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error("Failed to decode image"));
|
||||
};
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
async function load(imageId: string): Promise<HTMLImageElement | null> {
|
||||
let record: Awaited<ReturnType<typeof getImage>>;
|
||||
try {
|
||||
record = await getImage(imageId);
|
||||
} catch {
|
||||
pending.delete(imageId);
|
||||
return null;
|
||||
}
|
||||
if (!record) {
|
||||
failed.add(imageId);
|
||||
pending.delete(imageId);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const image = await decodeBlob(record.blob);
|
||||
cache.set(imageId, image);
|
||||
for (const listener of listeners) listener(imageId);
|
||||
return image;
|
||||
} catch {
|
||||
failed.add(imageId);
|
||||
return null;
|
||||
} finally {
|
||||
pending.delete(imageId);
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createPage, type Page } from "../model/page";
|
||||
import type { Stroke } from "../model/stroke";
|
||||
import { canAppendToCache } from "./pageCache";
|
||||
|
||||
function stroke(id: string): Stroke {
|
||||
return {
|
||||
id,
|
||||
pen: "pen",
|
||||
color: "#1a1a1a",
|
||||
size: 2,
|
||||
simulatePressure: false,
|
||||
points: [{ x: 1, y: 1, pressure: 0.5 }],
|
||||
};
|
||||
}
|
||||
|
||||
function pageWith(strokes: Stroke[], images: Page["images"] = []): Page {
|
||||
return { ...createPage("#ffffff"), strokes, images };
|
||||
}
|
||||
|
||||
describe("canAppendToCache", () => {
|
||||
it("allows appending new strokes to an unchanged prefix", () => {
|
||||
const s1 = stroke("s1");
|
||||
const cached = { page: pageWith([s1]), renderedCount: 1 };
|
||||
expect(canAppendToCache(cached, pageWith([s1, stroke("s2")]))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects when strokes were removed or replaced", () => {
|
||||
const s1 = stroke("s1");
|
||||
const s2 = stroke("s2");
|
||||
const cached = { page: pageWith([s1, s2]), renderedCount: 2 };
|
||||
expect(canAppendToCache(cached, pageWith([s1]))).toBe(false);
|
||||
expect(canAppendToCache(cached, pageWith([s1, stroke("s3")]))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects when images changed even if strokes are identical", () => {
|
||||
const s1 = stroke("s1");
|
||||
const cached = { page: pageWith([s1]), renderedCount: 1 };
|
||||
const withImage = pageWith(
|
||||
[s1],
|
||||
[{ id: "i1", imageId: "blob", x: 0, y: 0, width: 10, height: 10 }],
|
||||
);
|
||||
expect(canAppendToCache(cached, withImage)).toBe(false);
|
||||
expect(canAppendToCache({ page: withImage, renderedCount: 1 }, pageWith([s1]))).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts identical image references", () => {
|
||||
const s1 = stroke("s1");
|
||||
const s2 = stroke("s2");
|
||||
const images = [{ id: "i1", imageId: "blob", x: 0, y: 0, width: 10, height: 10 }];
|
||||
const cached = { page: pageWith([s1], images), renderedCount: 1 };
|
||||
expect(canAppendToCache(cached, pageWith([s1, s2], images))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects when an image item was replaced by a new object", () => {
|
||||
const cached = {
|
||||
page: pageWith([], [{ id: "i1", imageId: "blob", x: 0, y: 0, width: 10, height: 10 }]),
|
||||
renderedCount: 0,
|
||||
};
|
||||
const moved = pageWith([], [{ id: "i1", imageId: "blob", x: 5, y: 5, width: 10, height: 10 }]);
|
||||
expect(canAppendToCache(cached, moved)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import type { ImageItem } from "../model/image";
|
||||
import { PAGE_HEIGHT, PAGE_WIDTH, type Page } from "../model/page";
|
||||
import { get2dContext } from "./canvas";
|
||||
import { paintPage } from "./renderPage";
|
||||
import { drawStroke } from "./renderStroke";
|
||||
|
||||
const MAX_CACHE_PIXELS = 16_000_000;
|
||||
export const MAX_CACHE_RENDER_SCALE = Math.sqrt(MAX_CACHE_PIXELS / (PAGE_WIDTH * PAGE_HEIGHT));
|
||||
|
||||
interface CacheEntry {
|
||||
canvas: HTMLCanvasElement;
|
||||
renderScale: number;
|
||||
page: Page;
|
||||
renderedCount: number;
|
||||
}
|
||||
|
||||
export class PageCache {
|
||||
private entries = new Map<string, CacheEntry>();
|
||||
|
||||
sync(page: Page, renderScale: number): HTMLCanvasElement {
|
||||
renderScale = Math.min(renderScale, MAX_CACHE_RENDER_SCALE);
|
||||
const entry = this.entries.get(page.id);
|
||||
if (
|
||||
entry &&
|
||||
entry.renderScale === renderScale &&
|
||||
entry.page.paperColor === page.paperColor &&
|
||||
entry.page.pattern === page.pattern
|
||||
) {
|
||||
if (entry.page === page) return entry.canvas;
|
||||
if (canAppendToCache(entry, page)) {
|
||||
const ctx = get2dContext(entry.canvas);
|
||||
for (const stroke of page.strokes.slice(entry.renderedCount)) drawStroke(ctx, stroke);
|
||||
entry.renderedCount = page.strokes.length;
|
||||
entry.page = page;
|
||||
return entry.canvas;
|
||||
}
|
||||
}
|
||||
return this.render(page, renderScale);
|
||||
}
|
||||
|
||||
peek(pageId: string): HTMLCanvasElement | undefined {
|
||||
return this.entries.get(pageId)?.canvas;
|
||||
}
|
||||
|
||||
drop(pageId: string): void {
|
||||
this.entries.delete(pageId);
|
||||
}
|
||||
|
||||
prune(keepIds: Set<string>): void {
|
||||
for (const id of this.entries.keys()) {
|
||||
if (!keepIds.has(id)) this.entries.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
private render(page: Page, renderScale: number): HTMLCanvasElement {
|
||||
let entry = this.entries.get(page.id);
|
||||
if (!entry) {
|
||||
entry = { canvas: document.createElement("canvas"), renderScale, page, renderedCount: 0 };
|
||||
this.entries.set(page.id, entry);
|
||||
}
|
||||
paintPage(entry.canvas, page, renderScale);
|
||||
entry.renderScale = renderScale;
|
||||
entry.page = page;
|
||||
entry.renderedCount = page.strokes.length;
|
||||
return entry.canvas;
|
||||
}
|
||||
}
|
||||
|
||||
export function canAppendToCache(
|
||||
cached: { page: Page; renderedCount: number },
|
||||
next: Page,
|
||||
): boolean {
|
||||
if (cached.renderedCount > next.strokes.length) return false;
|
||||
if (!sameImages(cached.page.images, next.images)) return false;
|
||||
for (let i = 0; i < cached.renderedCount; i++) {
|
||||
if (cached.page.strokes[i] !== next.strokes[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function sameImages(a: ImageItem[], b: ImageItem[]): boolean {
|
||||
return a.length === b.length && a.every((image, index) => image === b[index]);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { isDarkColor } from "../model/color";
|
||||
import type { PagePattern } from "../model/page";
|
||||
import { PATTERN_DASH, patternLayout } from "../model/patternLayout";
|
||||
|
||||
export function drawPagePattern(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
pattern: PagePattern,
|
||||
paperColor: string,
|
||||
): void {
|
||||
if (pattern === "blank") return;
|
||||
const { lines, dots } = patternLayout(pattern);
|
||||
const dark = isDarkColor(paperColor);
|
||||
const color = dark ? "rgba(255, 255, 255, 0.22)" : "rgba(0, 0, 0, 0.16)";
|
||||
ctx.save();
|
||||
ctx.strokeStyle = color;
|
||||
ctx.fillStyle = color;
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
ctx.beginPath();
|
||||
for (const line of lines) {
|
||||
if (line.dashed) continue;
|
||||
ctx.moveTo(line.x1, line.y1);
|
||||
ctx.lineTo(line.x2, line.y2);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
ctx.setLineDash([...PATTERN_DASH]);
|
||||
ctx.beginPath();
|
||||
for (const line of lines) {
|
||||
if (!line.dashed) continue;
|
||||
ctx.moveTo(line.x1, line.y1);
|
||||
ctx.lineTo(line.x2, line.y2);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
for (const dot of dots) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(dot.x, dot.y, 1.2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { PAGE_HEIGHT, PAGE_WIDTH, type Page } from "../model/page";
|
||||
import { get2dContext } from "./canvas";
|
||||
import { getImageBitmap } from "./imageCache";
|
||||
import { drawPagePattern } from "./patterns";
|
||||
import { drawStroke } from "./renderStroke";
|
||||
|
||||
export function paintPage(canvas: HTMLCanvasElement, page: Page, renderScale: number): void {
|
||||
canvas.width = Math.max(1, Math.round(PAGE_WIDTH * renderScale));
|
||||
canvas.height = Math.max(1, Math.round(PAGE_HEIGHT * renderScale));
|
||||
const ctx = get2dContext(canvas);
|
||||
ctx.setTransform(renderScale, 0, 0, renderScale, 0, 0);
|
||||
ctx.fillStyle = page.paperColor;
|
||||
ctx.fillRect(0, 0, PAGE_WIDTH, PAGE_HEIGHT);
|
||||
drawPagePattern(ctx, page.pattern, page.paperColor);
|
||||
for (const image of page.images) {
|
||||
const bitmap = getImageBitmap(image.imageId);
|
||||
if (bitmap) ctx.drawImage(bitmap, image.x, image.y, image.width, image.height);
|
||||
}
|
||||
for (const stroke of page.strokes) drawStroke(ctx, stroke);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Stroke } from "../model/stroke";
|
||||
import { getOutlinePoints } from "./renderStroke";
|
||||
|
||||
function makeStroke(overrides: Partial<Stroke> = {}): Stroke {
|
||||
return {
|
||||
id: "s1",
|
||||
pen: "pen",
|
||||
color: "#1a1a1a",
|
||||
size: 6,
|
||||
simulatePressure: false,
|
||||
points: [
|
||||
{ x: 0, y: 0, pressure: 0.4 },
|
||||
{ x: 20, y: 6, pressure: 0.6 },
|
||||
{ x: 40, y: 2, pressure: 0.5 },
|
||||
{ x: 60, y: 12, pressure: 0.7 },
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("getOutlinePoints", () => {
|
||||
it("produces a finite outline for a multi-point stroke", () => {
|
||||
const outline = getOutlinePoints(makeStroke());
|
||||
expect(outline.length).toBeGreaterThan(4);
|
||||
for (const [x, y] of outline) {
|
||||
expect(Number.isFinite(x)).toBe(true);
|
||||
expect(Number.isFinite(y)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("renders a dot for a single-point stroke", () => {
|
||||
const outline = getOutlinePoints(makeStroke({ points: [{ x: 5, y: 5, pressure: 0.5 }] }));
|
||||
expect(outline.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("supports highlighter strokes", () => {
|
||||
expect(getOutlinePoints(makeStroke({ pen: "highlighter" })).length).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
it("renders the highlighter wider than the pen at the same size", () => {
|
||||
const yExtent = (outline: number[][]) => {
|
||||
const ys = outline.map(([, y]) => y);
|
||||
return Math.max(...ys) - Math.min(...ys);
|
||||
};
|
||||
const penExtent = yExtent(getOutlinePoints(makeStroke({ pen: "pen" })));
|
||||
const highlighterExtent = yExtent(getOutlinePoints(makeStroke({ pen: "highlighter" })));
|
||||
expect(highlighterExtent).toBeGreaterThan(penExtent);
|
||||
});
|
||||
|
||||
it("supports simulated pressure for mouse input", () => {
|
||||
expect(getOutlinePoints(makeStroke({ simulatePressure: true })).length).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
it("keeps the tail open for an in-progress stroke", () => {
|
||||
const stroke = makeStroke();
|
||||
const inProgress = getOutlinePoints(stroke, false);
|
||||
const completed = getOutlinePoints(stroke, true);
|
||||
expect(inProgress.length).toBeGreaterThan(0);
|
||||
expect(completed.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import { getStroke } from "perfect-freehand";
|
||||
import { effectiveStrokeSize, type Stroke } from "../model/stroke";
|
||||
import { shapePath } from "./shapes";
|
||||
|
||||
const PEN_OPTIONS = { thinning: 0.75, smoothing: 0.5, streamline: 0.5 };
|
||||
const HIGHLIGHTER_OPTIONS = { thinning: 0.35, smoothing: 0.6, streamline: 0.5 };
|
||||
export const HIGHLIGHTER_ALPHA = 0.35;
|
||||
|
||||
export function getOutlinePoints(stroke: Stroke, complete = true): number[][] {
|
||||
const base = stroke.pen === "highlighter" ? HIGHLIGHTER_OPTIONS : PEN_OPTIONS;
|
||||
return getStroke(stroke.points, {
|
||||
...base,
|
||||
size: effectiveStrokeSize(stroke),
|
||||
simulatePressure: stroke.simulatePressure,
|
||||
last: complete,
|
||||
});
|
||||
}
|
||||
|
||||
export function drawStroke(ctx: CanvasRenderingContext2D, stroke: Stroke, complete = true): void {
|
||||
if (stroke.shape) {
|
||||
const path = shapePath(stroke);
|
||||
if (!path) return;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = stroke.color;
|
||||
ctx.lineWidth = stroke.size;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.stroke(path);
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
const outline = getOutlinePoints(stroke, complete);
|
||||
if (outline.length === 0) return;
|
||||
const path = new Path2D();
|
||||
path.moveTo(outline[0][0], outline[0][1]);
|
||||
for (let i = 1; i < outline.length; i++) {
|
||||
path.lineTo(outline[i][0], outline[i][1]);
|
||||
}
|
||||
path.closePath();
|
||||
ctx.save();
|
||||
if (stroke.pen === "highlighter") ctx.globalAlpha = HIGHLIGHTER_ALPHA;
|
||||
ctx.fillStyle = stroke.color;
|
||||
ctx.fill(path);
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { arrowHead } from "../model/shapeGeometry";
|
||||
|
||||
describe("arrowHead", () => {
|
||||
it("returns two points at the head length from the tip", () => {
|
||||
const [left, right] = arrowHead({ x: 0, y: 0 }, { x: 100, y: 0 }, 5);
|
||||
for (const point of [left, right]) {
|
||||
expect(Math.hypot(100 - point.x, point.y)).toBeCloseTo(20);
|
||||
}
|
||||
});
|
||||
|
||||
it("is symmetric about the shaft direction", () => {
|
||||
const [left, right] = arrowHead({ x: 0, y: 0 }, { x: 100, y: 0 }, 5);
|
||||
expect(left.y).toBeCloseTo(-right.y);
|
||||
expect(left.x).toBeCloseTo(right.x);
|
||||
});
|
||||
|
||||
it("enforces a minimum head length", () => {
|
||||
const [left] = arrowHead({ x: 0, y: 0 }, { x: 0, y: 50 }, 1);
|
||||
expect(Math.hypot(left.x, 50 - left.y)).toBeCloseTo(10);
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import { arrowHead } from "../model/shapeGeometry";
|
||||
import type { Stroke } from "../model/stroke";
|
||||
|
||||
export function shapePath(stroke: Stroke): Path2D | null {
|
||||
const [a, b] = stroke.points;
|
||||
if (!stroke.shape || !a || !b) return null;
|
||||
const path = new Path2D();
|
||||
switch (stroke.shape) {
|
||||
case "line":
|
||||
path.moveTo(a.x, a.y);
|
||||
path.lineTo(b.x, b.y);
|
||||
break;
|
||||
case "arrow": {
|
||||
const [left, right] = arrowHead(a, b, stroke.size);
|
||||
path.moveTo(a.x, a.y);
|
||||
path.lineTo(b.x, b.y);
|
||||
path.moveTo(left.x, left.y);
|
||||
path.lineTo(b.x, b.y);
|
||||
path.lineTo(right.x, right.y);
|
||||
break;
|
||||
}
|
||||
case "rect":
|
||||
path.rect(Math.min(a.x, b.x), Math.min(a.y, b.y), Math.abs(b.x - a.x), Math.abs(b.y - a.y));
|
||||
break;
|
||||
case "ellipse":
|
||||
path.ellipse(
|
||||
(a.x + b.x) / 2,
|
||||
(a.y + b.y) / 2,
|
||||
Math.abs(b.x - a.x) / 2,
|
||||
Math.abs(b.y - a.y) / 2,
|
||||
0,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
break;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { contentHeight, PAGE_WIDTH } from "../model/page";
|
||||
import {
|
||||
clampScale,
|
||||
createViewport,
|
||||
fitScale,
|
||||
panBy,
|
||||
type ScreenSize,
|
||||
screenToWorld,
|
||||
type Viewport,
|
||||
visiblePageRange,
|
||||
zoomAt,
|
||||
} from "./viewport";
|
||||
|
||||
const screen: ScreenSize = { width: 818, height: 1000 };
|
||||
|
||||
describe("viewport", () => {
|
||||
it("fits the page width with margins", () => {
|
||||
const scale = fitScale(screen.width);
|
||||
expect(scale * (PAGE_WIDTH + 24)).toBeCloseTo(screen.width);
|
||||
});
|
||||
|
||||
it("centers the page horizontally at fit scale", () => {
|
||||
const vp = createViewport(screen, 1);
|
||||
expect(vp.x).toBeCloseTo((PAGE_WIDTH - screen.width / vp.scale) / 2);
|
||||
});
|
||||
|
||||
it("top-aligns content taller than the screen", () => {
|
||||
const vp = createViewport(screen, 5);
|
||||
expect(vp.y).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the focal point stable while zooming", () => {
|
||||
const vp = createViewport(screen, 3);
|
||||
const focal = { x: 400, y: 500 };
|
||||
const before = screenToWorld(vp, focal.x, focal.y);
|
||||
const zoomed = zoomAt(vp, focal, vp.scale * 2, screen, 3);
|
||||
const after = screenToWorld(zoomed, focal.x, focal.y);
|
||||
expect(after.x).toBeCloseTo(before.x);
|
||||
expect(after.y).toBeCloseTo(before.y);
|
||||
});
|
||||
|
||||
it("clamps zoom to the allowed range", () => {
|
||||
const fit = fitScale(screen.width);
|
||||
expect(clampScale(fit * 0.5, screen.width)).toBe(fit);
|
||||
expect(clampScale(fit * 99, screen.width)).toBe(fit * 20);
|
||||
});
|
||||
|
||||
it("clamps panning to the content bounds", () => {
|
||||
const vp = createViewport(screen, 3);
|
||||
expect(panBy(vp, 0, 100, screen, 3).y).toBe(0);
|
||||
const bottom = panBy(vp, 0, -100000, screen, 3);
|
||||
expect(bottom.y).toBeCloseTo(contentHeight(3) - screen.height / bottom.scale);
|
||||
});
|
||||
|
||||
it("reports the visible page range", () => {
|
||||
const vp: Viewport = { x: 0, y: 0, scale: fitScale(screen.width) };
|
||||
const range = visiblePageRange(vp, screen, 10);
|
||||
expect(range.first).toBe(0);
|
||||
expect(range.last).toBeGreaterThanOrEqual(0);
|
||||
expect(range.last).toBeLessThan(10);
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import { contentHeight, PAGE_GAP, PAGE_HEIGHT, PAGE_TOP_MARGIN, PAGE_WIDTH } from "../model/page";
|
||||
|
||||
export interface Viewport {
|
||||
x: number;
|
||||
y: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export interface ScreenSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const FIT_MARGIN = 12;
|
||||
const MAX_ZOOM = 20;
|
||||
|
||||
export function fitScale(screenWidth: number): number {
|
||||
return screenWidth / (PAGE_WIDTH + FIT_MARGIN * 2);
|
||||
}
|
||||
|
||||
export function createViewport(screen: ScreenSize, pageCount: number): Viewport {
|
||||
return clampViewport({ x: 0, y: 0, scale: fitScale(screen.width) }, screen, pageCount);
|
||||
}
|
||||
|
||||
export function clampScale(scale: number, screenWidth: number): number {
|
||||
const fit = fitScale(screenWidth);
|
||||
return Math.min(fit * MAX_ZOOM, Math.max(fit, scale));
|
||||
}
|
||||
|
||||
export function clampViewport(vp: Viewport, screen: ScreenSize, pageCount: number): Viewport {
|
||||
const worldScreenW = screen.width / vp.scale;
|
||||
const x =
|
||||
worldScreenW >= PAGE_WIDTH
|
||||
? (PAGE_WIDTH - worldScreenW) / 2
|
||||
: clamp(vp.x, 0, PAGE_WIDTH - worldScreenW);
|
||||
const height = contentHeight(pageCount);
|
||||
const worldScreenH = screen.height / vp.scale;
|
||||
const y =
|
||||
worldScreenH >= height ? (height - worldScreenH) / 2 : clamp(vp.y, 0, height - worldScreenH);
|
||||
return { ...vp, x, y };
|
||||
}
|
||||
|
||||
export function panBy(
|
||||
vp: Viewport,
|
||||
dxScreen: number,
|
||||
dyScreen: number,
|
||||
screen: ScreenSize,
|
||||
pageCount: number,
|
||||
): Viewport {
|
||||
return clampViewport(
|
||||
{ ...vp, x: vp.x - dxScreen / vp.scale, y: vp.y - dyScreen / vp.scale },
|
||||
screen,
|
||||
pageCount,
|
||||
);
|
||||
}
|
||||
|
||||
export function zoomAt(
|
||||
vp: Viewport,
|
||||
focal: Point,
|
||||
nextScale: number,
|
||||
screen: ScreenSize,
|
||||
pageCount: number,
|
||||
): Viewport {
|
||||
const scale = clampScale(nextScale, screen.width);
|
||||
const worldFx = vp.x + focal.x / vp.scale;
|
||||
const worldFy = vp.y + focal.y / vp.scale;
|
||||
return clampViewport(
|
||||
{ scale, x: worldFx - focal.x / scale, y: worldFy - focal.y / scale },
|
||||
screen,
|
||||
pageCount,
|
||||
);
|
||||
}
|
||||
|
||||
export function screenToWorld(vp: Viewport, screenX: number, screenY: number): Point {
|
||||
return { x: vp.x + screenX / vp.scale, y: vp.y + screenY / vp.scale };
|
||||
}
|
||||
|
||||
export function visiblePageRange(
|
||||
vp: Viewport,
|
||||
screen: ScreenSize,
|
||||
pageCount: number,
|
||||
): { first: number; last: number } {
|
||||
const span = PAGE_HEIGHT + PAGE_GAP;
|
||||
const first = Math.max(0, Math.floor((vp.y - PAGE_TOP_MARGIN) / span));
|
||||
const last = Math.min(
|
||||
pageCount - 1,
|
||||
Math.floor((vp.y + screen.height / vp.scale - PAGE_TOP_MARGIN) / span),
|
||||
);
|
||||
return { first, last: Math.max(first, last) };
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { WhiteboardView } from './WhiteboardView';
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hexToRgb, isDarkColor, normalizeHex } from "./color";
|
||||
|
||||
describe("normalizeHex", () => {
|
||||
it("accepts a six-digit hex with hash", () => {
|
||||
expect(normalizeHex("#2f6fdd")).toBe("#2f6fdd");
|
||||
});
|
||||
|
||||
it("accepts a six-digit hex without hash and lowercases it", () => {
|
||||
expect(normalizeHex("D64541")).toBe("#d64541");
|
||||
});
|
||||
|
||||
it("expands a three-digit hex", () => {
|
||||
expect(normalizeHex("#f2b")).toBe("#ff22bb");
|
||||
expect(normalizeHex("abc")).toBe("#aabbcc");
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace", () => {
|
||||
expect(normalizeHex(" #1a1a1a ")).toBe("#1a1a1a");
|
||||
});
|
||||
|
||||
it("rejects invalid input", () => {
|
||||
expect(normalizeHex("")).toBeNull();
|
||||
expect(normalizeHex("#12345")).toBeNull();
|
||||
expect(normalizeHex("#1234567")).toBeNull();
|
||||
expect(normalizeHex("red")).toBeNull();
|
||||
expect(normalizeHex("#gg0000")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hexToRgb", () => {
|
||||
it("parses channels in the 0-1 range", () => {
|
||||
expect(hexToRgb("#ffffff")).toEqual({ r: 1, g: 1, b: 1 });
|
||||
expect(hexToRgb("#000000")).toEqual({ r: 0, g: 0, b: 0 });
|
||||
const { r, g, b } = hexToRgb("#2f6fdd") ?? { r: 0, g: 0, b: 0 };
|
||||
expect(r).toBeCloseTo(47 / 255);
|
||||
expect(g).toBeCloseTo(111 / 255);
|
||||
expect(b).toBeCloseTo(221 / 255);
|
||||
});
|
||||
|
||||
it("returns null for invalid input", () => {
|
||||
expect(hexToRgb("nope")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDarkColor", () => {
|
||||
it("treats black and the blackboard green as dark", () => {
|
||||
expect(isDarkColor("#1a1a1a")).toBe(true);
|
||||
expect(isDarkColor("#003423")).toBe(true);
|
||||
expect(isDarkColor("#26262a")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats white and light tones as light", () => {
|
||||
expect(isDarkColor("#ffffff")).toBe(false);
|
||||
expect(isDarkColor("#fbf3db")).toBe(false);
|
||||
expect(isDarkColor("#eef1f4")).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults to light for invalid input", () => {
|
||||
expect(isDarkColor("invalid")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
export function normalizeHex(input: string): string | null {
|
||||
const match = input.trim().match(/^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/);
|
||||
if (!match) return null;
|
||||
const hex = match[1].toLowerCase();
|
||||
return `#${hex.length === 3 ? hex.replace(/./g, (c) => c + c) : hex}`;
|
||||
}
|
||||
|
||||
export function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
const normalized = normalizeHex(hex);
|
||||
if (!normalized) return null;
|
||||
return {
|
||||
r: parseInt(normalized.slice(1, 3), 16) / 255,
|
||||
g: parseInt(normalized.slice(3, 5), 16) / 255,
|
||||
b: parseInt(normalized.slice(5, 7), 16) / 255,
|
||||
};
|
||||
}
|
||||
|
||||
export function isDarkColor(hex: string): boolean {
|
||||
const rgb = hexToRgb(hex);
|
||||
if (!rgb) return false;
|
||||
return 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b < 0.5;
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hitTestStroke, pointToSegmentDistance, strokeBounds } from "./hitTest";
|
||||
import { arrowHead } from "./shapeGeometry";
|
||||
import type { Stroke } from "./stroke";
|
||||
|
||||
function horizontalStroke(): Stroke {
|
||||
return {
|
||||
id: "s1",
|
||||
pen: "pen",
|
||||
color: "#1a1a1a",
|
||||
size: 6,
|
||||
simulatePressure: false,
|
||||
points: [
|
||||
{ x: 10, y: 50, pressure: 0.5 },
|
||||
{ x: 60, y: 50, pressure: 0.5 },
|
||||
{ x: 110, y: 50, pressure: 0.5 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("pointToSegmentDistance", () => {
|
||||
it("measures the perpendicular distance", () => {
|
||||
expect(pointToSegmentDistance(5, 4, 0, 0, 10, 0)).toBe(4);
|
||||
});
|
||||
|
||||
it("clamps to the segment endpoints", () => {
|
||||
expect(pointToSegmentDistance(-3, 4, 0, 0, 10, 0)).toBe(5);
|
||||
expect(pointToSegmentDistance(14, 0, 0, 0, 10, 0)).toBe(4);
|
||||
});
|
||||
|
||||
it("handles a zero-length segment", () => {
|
||||
expect(pointToSegmentDistance(3, 4, 0, 0, 0, 0)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("strokeBounds", () => {
|
||||
it("expands the point bounds by the padding", () => {
|
||||
expect(strokeBounds(horizontalStroke(), 5)).toEqual({
|
||||
minX: 5,
|
||||
minY: 45,
|
||||
maxX: 115,
|
||||
maxY: 55,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("hitTestStroke", () => {
|
||||
it("hits a point on the stroke", () => {
|
||||
expect(hitTestStroke({ x: 60, y: 51 }, horizontalStroke(), 5)).toBe(true);
|
||||
});
|
||||
|
||||
it("hits within the stroke width plus tolerance", () => {
|
||||
expect(hitTestStroke({ x: 60, y: 57 }, horizontalStroke(), 5)).toBe(true);
|
||||
});
|
||||
|
||||
it("misses a point beyond the radius", () => {
|
||||
expect(hitTestStroke({ x: 60, y: 62 }, horizontalStroke(), 5)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects quickly via bounds", () => {
|
||||
expect(hitTestStroke({ x: 300, y: 300 }, horizontalStroke(), 5)).toBe(false);
|
||||
});
|
||||
|
||||
it("hits a single-point dot", () => {
|
||||
const dot: Stroke = { ...horizontalStroke(), points: [{ x: 20, y: 20, pressure: 0.5 }] };
|
||||
expect(hitTestStroke({ x: 22, y: 22 }, dot, 5)).toBe(true);
|
||||
expect(hitTestStroke({ x: 40, y: 40 }, dot, 5)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function shapeStroke(kind: "line" | "arrow" | "rect" | "ellipse"): Stroke {
|
||||
return {
|
||||
id: "shape-1",
|
||||
pen: "pen",
|
||||
color: "#1a1a1a",
|
||||
size: 4,
|
||||
simulatePressure: false,
|
||||
shape: kind,
|
||||
points: [
|
||||
{ x: 20, y: 20, pressure: 0.5 },
|
||||
{ x: 120, y: 100, pressure: 0.5 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("hitTestStroke with shapes", () => {
|
||||
it("hits a line on the segment and misses off it", () => {
|
||||
expect(hitTestStroke({ x: 70, y: 61 }, shapeStroke("line"), 5)).toBe(true);
|
||||
expect(hitTestStroke({ x: 70, y: 90 }, shapeStroke("line"), 5)).toBe(false);
|
||||
});
|
||||
|
||||
it("hits a rect only near its border", () => {
|
||||
const rect = shapeStroke("rect");
|
||||
expect(hitTestStroke({ x: 20, y: 60 }, rect, 5)).toBe(true);
|
||||
expect(hitTestStroke({ x: 70, y: 60 }, rect, 5)).toBe(false);
|
||||
expect(hitTestStroke({ x: 5, y: 60 }, rect, 5)).toBe(false);
|
||||
});
|
||||
|
||||
it("hits an ellipse only near its border", () => {
|
||||
const ellipse = shapeStroke("ellipse");
|
||||
expect(hitTestStroke({ x: 120, y: 60 }, ellipse, 5)).toBe(true);
|
||||
expect(hitTestStroke({ x: 70, y: 60 }, ellipse, 5)).toBe(false);
|
||||
expect(hitTestStroke({ x: 160, y: 60 }, ellipse, 5)).toBe(false);
|
||||
});
|
||||
|
||||
it("hits a tiny ellipse on its rim and interior", () => {
|
||||
const tiny: Stroke = {
|
||||
...shapeStroke("ellipse"),
|
||||
points: [
|
||||
{ x: 100, y: 100, pressure: 0.5 },
|
||||
{ x: 105, y: 105, pressure: 0.5 },
|
||||
],
|
||||
};
|
||||
expect(hitTestStroke({ x: 105, y: 102.5 }, tiny, 5)).toBe(true);
|
||||
expect(hitTestStroke({ x: 102.5, y: 102.5 }, tiny, 5)).toBe(true);
|
||||
expect(hitTestStroke({ x: 120, y: 120 }, tiny, 5)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not hit deep inside or far outside an eccentric ellipse", () => {
|
||||
const flat: Stroke = {
|
||||
...shapeStroke("ellipse"),
|
||||
points: [
|
||||
{ x: 100, y: 500, pressure: 0.5 },
|
||||
{ x: 700, y: 540, pressure: 0.5 },
|
||||
],
|
||||
};
|
||||
expect(hitTestStroke({ x: 600, y: 520 }, flat, 5)).toBe(false);
|
||||
expect(hitTestStroke({ x: 745, y: 520 }, flat, 5)).toBe(false);
|
||||
expect(hitTestStroke({ x: 700, y: 520 }, flat, 5)).toBe(true);
|
||||
expect(hitTestStroke({ x: 705, y: 520 }, flat, 5)).toBe(true);
|
||||
});
|
||||
|
||||
it("expands arrow bounds to include the head wings", () => {
|
||||
const arrow: Stroke = {
|
||||
...shapeStroke("arrow"),
|
||||
points: [
|
||||
{ x: 100, y: 20, pressure: 0.5 },
|
||||
{ x: 100, y: 100, pressure: 0.5 },
|
||||
],
|
||||
};
|
||||
const [left, right] = arrowHead(arrow.points[0], arrow.points[1], arrow.size);
|
||||
const bounds = strokeBounds(arrow, 0);
|
||||
expect(bounds.minX).toBeCloseTo(Math.min(100, left.x, right.x));
|
||||
expect(bounds.maxX).toBeCloseTo(Math.max(100, left.x, right.x));
|
||||
expect(bounds.maxX - bounds.minX).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
import { arrowHead, ellipseOutline } from "./shapeGeometry";
|
||||
import { effectiveStrokeSize, type Stroke } from "./stroke";
|
||||
|
||||
export const ERASER_TOLERANCE = 5;
|
||||
|
||||
export interface Bounds {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
}
|
||||
|
||||
export function pointToSegmentDistance(
|
||||
px: number,
|
||||
py: number,
|
||||
ax: number,
|
||||
ay: number,
|
||||
bx: number,
|
||||
by: number,
|
||||
): number {
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const lengthSq = dx * dx + dy * dy;
|
||||
if (lengthSq === 0) return Math.hypot(px - ax, py - ay);
|
||||
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSq));
|
||||
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
|
||||
}
|
||||
|
||||
export function strokeBounds(stroke: Stroke, padding: number): Bounds {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const point of stroke.points) {
|
||||
minX = Math.min(minX, point.x);
|
||||
minY = Math.min(minY, point.y);
|
||||
maxX = Math.max(maxX, point.x);
|
||||
maxY = Math.max(maxY, point.y);
|
||||
}
|
||||
if (stroke.shape === "arrow") {
|
||||
const [a, b] = stroke.points;
|
||||
if (a && b) {
|
||||
for (const wing of arrowHead(a, b, stroke.size)) {
|
||||
minX = Math.min(minX, wing.x);
|
||||
minY = Math.min(minY, wing.y);
|
||||
maxX = Math.max(maxX, wing.x);
|
||||
maxY = Math.max(maxY, wing.y);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
minX: minX - padding,
|
||||
minY: minY - padding,
|
||||
maxX: maxX + padding,
|
||||
maxY: maxY + padding,
|
||||
};
|
||||
}
|
||||
|
||||
export function hitTestStroke(
|
||||
point: { x: number; y: number },
|
||||
stroke: Stroke,
|
||||
tolerance: number,
|
||||
): boolean {
|
||||
if (stroke.shape) return hitTestShape(point, stroke, tolerance);
|
||||
const radius = effectiveStrokeSize(stroke) / 2 + tolerance;
|
||||
const bounds = strokeBounds(stroke, radius);
|
||||
if (
|
||||
point.x < bounds.minX ||
|
||||
point.x > bounds.maxX ||
|
||||
point.y < bounds.minY ||
|
||||
point.y > bounds.maxY
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const points = stroke.points;
|
||||
if (points.length === 1) {
|
||||
return Math.hypot(point.x - points[0].x, point.y - points[0].y) <= radius;
|
||||
}
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const a = points[i];
|
||||
const b = points[i + 1];
|
||||
if (pointToSegmentDistance(point.x, point.y, a.x, a.y, b.x, b.y) <= radius) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hitTestShape(point: { x: number; y: number }, stroke: Stroke, tolerance: number): boolean {
|
||||
const radius = stroke.size / 2 + tolerance;
|
||||
const [a, b] = stroke.points;
|
||||
if (!a || !b) return false;
|
||||
switch (stroke.shape) {
|
||||
case "line":
|
||||
return pointToSegmentDistance(point.x, point.y, a.x, a.y, b.x, b.y) <= radius;
|
||||
case "arrow": {
|
||||
if (pointToSegmentDistance(point.x, point.y, a.x, a.y, b.x, b.y) <= radius) return true;
|
||||
const [left, right] = arrowHead(a, b, stroke.size);
|
||||
return (
|
||||
pointToSegmentDistance(point.x, point.y, b.x, b.y, left.x, left.y) <= radius ||
|
||||
pointToSegmentDistance(point.x, point.y, b.x, b.y, right.x, right.y) <= radius
|
||||
);
|
||||
}
|
||||
case "rect": {
|
||||
const x1 = Math.min(a.x, b.x);
|
||||
const x2 = Math.max(a.x, b.x);
|
||||
const y1 = Math.min(a.y, b.y);
|
||||
const y2 = Math.max(a.y, b.y);
|
||||
const insideExpanded =
|
||||
point.x >= x1 - radius &&
|
||||
point.x <= x2 + radius &&
|
||||
point.y >= y1 - radius &&
|
||||
point.y <= y2 + radius;
|
||||
const insideShrunk =
|
||||
point.x > x1 + radius &&
|
||||
point.x < x2 - radius &&
|
||||
point.y > y1 + radius &&
|
||||
point.y < y2 - radius;
|
||||
return insideExpanded && !insideShrunk;
|
||||
}
|
||||
case "ellipse": {
|
||||
const rx = Math.abs(b.x - a.x) / 2;
|
||||
const ry = Math.abs(b.y - a.y) / 2;
|
||||
if (rx < 1 || ry < 1) {
|
||||
return pointToSegmentDistance(point.x, point.y, a.x, a.y, b.x, b.y) <= radius;
|
||||
}
|
||||
const outline = ellipseOutline(a, b);
|
||||
for (let i = 0; i < outline.length; i++) {
|
||||
const p1 = outline[i];
|
||||
const p2 = outline[(i + 1) % outline.length];
|
||||
if (pointToSegmentDistance(point.x, point.y, p1.x, p1.y, p2.x, p2.y) <= radius) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createImageItem, imageExtension, placeImageCentered, placeImageSize } from "./image";
|
||||
import { PAGE_HEIGHT, PAGE_WIDTH, PLACEMENT_MARGIN } from "./page";
|
||||
|
||||
describe("placeImageSize", () => {
|
||||
it("keeps small images at their natural size", () => {
|
||||
expect(placeImageSize(200, 100)).toEqual({ width: 200, height: 100 });
|
||||
});
|
||||
|
||||
it("shrinks oversized images to fit the page keeping aspect ratio", () => {
|
||||
const maxWidth = PAGE_WIDTH - PLACEMENT_MARGIN * 2;
|
||||
const { width, height } = placeImageSize(maxWidth * 2, 100);
|
||||
expect(width).toBeCloseTo(maxWidth);
|
||||
expect(height).toBeCloseTo(100 / 2);
|
||||
});
|
||||
|
||||
it("fits very tall images by height", () => {
|
||||
const maxHeight = PAGE_HEIGHT - PLACEMENT_MARGIN * 2;
|
||||
const { width, height } = placeImageSize(100, maxHeight * 4);
|
||||
expect(height).toBeCloseTo(maxHeight);
|
||||
expect(width).toBeCloseTo(100 / 4);
|
||||
});
|
||||
|
||||
it("falls back to a default size for degenerate input", () => {
|
||||
expect(placeImageSize(0, 0)).toEqual({ width: 300, height: 150 });
|
||||
expect(placeImageSize(Number.NaN, Number.POSITIVE_INFINITY)).toEqual({
|
||||
width: 300,
|
||||
height: 150,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createImageItem", () => {
|
||||
it("places the image at the top-left placement margin", () => {
|
||||
const item = createImageItem("img-1", 200, 100);
|
||||
expect(item.imageId).toBe("img-1");
|
||||
expect(item.id).not.toBe("img-1");
|
||||
expect(item.x).toBe(PLACEMENT_MARGIN);
|
||||
expect(item.y).toBe(PLACEMENT_MARGIN);
|
||||
expect(item.width).toBe(200);
|
||||
expect(item.height).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeImageCentered", () => {
|
||||
it("fills the page width with a wide image and centers it vertically", () => {
|
||||
const placed = placeImageCentered(200, 100);
|
||||
expect(placed.width).toBeCloseTo(PAGE_WIDTH);
|
||||
expect(placed.height).toBeCloseTo((100 * PAGE_WIDTH) / 200);
|
||||
expect(placed.x).toBeCloseTo(0);
|
||||
expect(placed.y).toBeCloseTo((PAGE_HEIGHT - placed.height) / 2);
|
||||
});
|
||||
|
||||
it("fills the page height with a tall image and centers it horizontally", () => {
|
||||
const placed = placeImageCentered(100, 200);
|
||||
expect(placed.height).toBeCloseTo(PAGE_HEIGHT);
|
||||
expect(placed.width).toBeCloseTo((100 * PAGE_HEIGHT) / 200);
|
||||
expect(placed.y).toBeCloseTo(0);
|
||||
expect(placed.x).toBeCloseTo((PAGE_WIDTH - placed.width) / 2);
|
||||
});
|
||||
|
||||
it("upscales small images to fill the page", () => {
|
||||
const placed = placeImageCentered(50, 50);
|
||||
expect(placed.width).toBeGreaterThan(50);
|
||||
expect(placed.height).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it("matches the page aspect with at most one blank side", () => {
|
||||
for (const [w, h] of [
|
||||
[400, 300],
|
||||
[300, 400],
|
||||
[1000, 100],
|
||||
]) {
|
||||
const placed = placeImageCentered(w, h);
|
||||
const marginX = Math.min(placed.x, PAGE_WIDTH - placed.x - placed.width);
|
||||
const marginY = Math.min(placed.y, PAGE_HEIGHT - placed.y - placed.height);
|
||||
expect(Math.min(marginX, marginY)).toBeCloseTo(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("imageExtension", () => {
|
||||
it("maps known mime types to file extensions", () => {
|
||||
expect(imageExtension("image/jpeg")).toBe("jpg");
|
||||
expect(imageExtension("image/png")).toBe("png");
|
||||
expect(imageExtension("image/svg+xml")).toBe("svg");
|
||||
expect(imageExtension("image/webp")).toBe("webp");
|
||||
expect(imageExtension("image/gif")).toBe("gif");
|
||||
});
|
||||
|
||||
it("falls back to bin for unknown types", () => {
|
||||
expect(imageExtension("image/x-unknown")).toBe("bin");
|
||||
expect(imageExtension("")).toBe("bin");
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
import { PAGE_HEIGHT, PAGE_WIDTH, PLACEMENT_MARGIN } from "./page";
|
||||
import { newId } from "./stroke";
|
||||
|
||||
export interface ImageItem {
|
||||
id: string;
|
||||
imageId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export function placeImageSize(
|
||||
naturalWidth: number,
|
||||
naturalHeight: number,
|
||||
): { width: number; height: number } {
|
||||
const safeWidth = naturalWidth > 0 && Number.isFinite(naturalWidth) ? naturalWidth : 300;
|
||||
const safeHeight = naturalHeight > 0 && Number.isFinite(naturalHeight) ? naturalHeight : 150;
|
||||
const maxWidth = PAGE_WIDTH - PLACEMENT_MARGIN * 2;
|
||||
const maxHeight = PAGE_HEIGHT - PLACEMENT_MARGIN * 2;
|
||||
const scale = Math.min(1, maxWidth / safeWidth, maxHeight / safeHeight);
|
||||
return { width: safeWidth * scale, height: safeHeight * scale };
|
||||
}
|
||||
|
||||
export function placeImageCentered(
|
||||
naturalWidth: number,
|
||||
naturalHeight: number,
|
||||
): { x: number; y: number; width: number; height: number } {
|
||||
const safeWidth = naturalWidth > 0 && Number.isFinite(naturalWidth) ? naturalWidth : 300;
|
||||
const safeHeight = naturalHeight > 0 && Number.isFinite(naturalHeight) ? naturalHeight : 150;
|
||||
const scale = Math.min(PAGE_WIDTH / safeWidth, PAGE_HEIGHT / safeHeight);
|
||||
const width = safeWidth * scale;
|
||||
const height = safeHeight * scale;
|
||||
return {
|
||||
x: (PAGE_WIDTH - width) / 2,
|
||||
y: (PAGE_HEIGHT - height) / 2,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
export function createImageItem(
|
||||
imageId: string,
|
||||
naturalWidth: number,
|
||||
naturalHeight: number,
|
||||
): ImageItem {
|
||||
const { width, height } = placeImageSize(naturalWidth, naturalHeight);
|
||||
return { id: newId(), imageId, x: PLACEMENT_MARGIN, y: PLACEMENT_MARGIN, width, height };
|
||||
}
|
||||
|
||||
export function imageExtension(mimeType: string): string {
|
||||
switch (mimeType) {
|
||||
case "image/jpeg":
|
||||
return "jpg";
|
||||
case "image/png":
|
||||
return "png";
|
||||
case "image/svg+xml":
|
||||
return "svg";
|
||||
case "image/webp":
|
||||
return "webp";
|
||||
case "image/gif":
|
||||
return "gif";
|
||||
case "image/avif":
|
||||
return "avif";
|
||||
default:
|
||||
return "bin";
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
clampToPage,
|
||||
clonePageWithNewIds,
|
||||
contentHeight,
|
||||
PAGE_GAP,
|
||||
PAGE_HEIGHT,
|
||||
PAGE_TOP_MARGIN,
|
||||
PAGE_WIDTH,
|
||||
pageAt,
|
||||
pageIndexAtY,
|
||||
pageTopY,
|
||||
trimTrailingBlankPages,
|
||||
} from "./page";
|
||||
|
||||
describe("page geometry", () => {
|
||||
it("computes page top offsets", () => {
|
||||
expect(pageTopY(0)).toBe(PAGE_TOP_MARGIN);
|
||||
expect(pageTopY(1)).toBe(PAGE_TOP_MARGIN + PAGE_HEIGHT + PAGE_GAP);
|
||||
});
|
||||
|
||||
it("computes content height with margins and gaps", () => {
|
||||
expect(contentHeight(1)).toBe(PAGE_TOP_MARGIN + PAGE_HEIGHT + PAGE_GAP);
|
||||
expect(contentHeight(2)).toBe(PAGE_TOP_MARGIN + 2 * PAGE_HEIGHT + 2 * PAGE_GAP);
|
||||
});
|
||||
|
||||
it("hits a point inside the first page", () => {
|
||||
const hit = pageAt(100, PAGE_TOP_MARGIN + 50, 3);
|
||||
expect(hit).toEqual({ index: 0, x: 100, y: 50 });
|
||||
});
|
||||
|
||||
it("hits a point inside a later page", () => {
|
||||
const hit = pageAt(10, pageTopY(2) + 20, 3);
|
||||
expect(hit).toEqual({ index: 2, x: 10, y: 20 });
|
||||
});
|
||||
|
||||
it("misses the gap between pages", () => {
|
||||
expect(pageAt(100, PAGE_TOP_MARGIN + PAGE_HEIGHT + 5, 3)).toBeNull();
|
||||
});
|
||||
|
||||
it("misses outside the page horizontally", () => {
|
||||
expect(pageAt(-1, 100, 3)).toBeNull();
|
||||
expect(pageAt(PAGE_WIDTH + 1, 100, 3)).toBeNull();
|
||||
});
|
||||
|
||||
it("misses beyond the last page", () => {
|
||||
expect(pageAt(100, pageTopY(5), 3)).toBeNull();
|
||||
});
|
||||
|
||||
it("maps a world y to the nearest page index", () => {
|
||||
expect(pageIndexAtY(pageTopY(1) + 5, 3)).toBe(1);
|
||||
expect(pageIndexAtY(-100, 3)).toBe(0);
|
||||
expect(pageIndexAtY(99999, 3)).toBe(2);
|
||||
});
|
||||
|
||||
it("clamps points to the page bounds", () => {
|
||||
expect(clampToPage(-5, 2000)).toEqual({ x: 0, y: PAGE_HEIGHT });
|
||||
expect(clampToPage(100, 200)).toEqual({ x: 100, y: 200 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("trimTrailingBlankPages", () => {
|
||||
const blank = (id: string) => ({
|
||||
id,
|
||||
strokes: [],
|
||||
images: [],
|
||||
paperColor: "#ffffff",
|
||||
pattern: "blank" as const,
|
||||
});
|
||||
const written = (id: string) => ({
|
||||
...blank(id),
|
||||
strokes: [
|
||||
{
|
||||
id: `s-${id}`,
|
||||
pen: "pen" as const,
|
||||
color: "#1a1a1a",
|
||||
size: 2,
|
||||
simulatePressure: false,
|
||||
points: [{ x: 1, y: 1, pressure: 0.5 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const imaged = (id: string) => ({
|
||||
...blank(id),
|
||||
images: [{ id: `i-${id}`, imageId: "blob", x: 0, y: 0, width: 10, height: 10 }],
|
||||
});
|
||||
|
||||
it("drops trailing blank pages", () => {
|
||||
const pages = [written("a"), blank("b"), blank("c")];
|
||||
expect(trimTrailingBlankPages(pages).map((p) => p.id)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("keeps blank pages in the middle", () => {
|
||||
const pages = [written("a"), blank("b"), written("c"), blank("d")];
|
||||
expect(trimTrailingBlankPages(pages).map((p) => p.id)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("keeps at least one page", () => {
|
||||
const pages = [blank("a"), blank("b")];
|
||||
expect(trimTrailingBlankPages(pages).map((p) => p.id)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("keeps everything when the last page has content", () => {
|
||||
const pages = [written("a"), written("b")];
|
||||
expect(trimTrailingBlankPages(pages)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("treats a page with only images as content", () => {
|
||||
const pages = [written("a"), imaged("b")];
|
||||
expect(trimTrailingBlankPages(pages)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clonePageWithNewIds", () => {
|
||||
it("clones a page with fresh ids while preserving content and image references", () => {
|
||||
const source = {
|
||||
id: "page-1",
|
||||
paperColor: "#003423",
|
||||
pattern: "grid" as const,
|
||||
strokes: [
|
||||
{
|
||||
id: "s1",
|
||||
pen: "pen" as const,
|
||||
color: "#d64541",
|
||||
size: 3,
|
||||
simulatePressure: true,
|
||||
shape: "arrow" as const,
|
||||
points: [
|
||||
{ x: 1, y: 2, pressure: 0.4 },
|
||||
{ x: 30, y: 40, pressure: 0.9 },
|
||||
],
|
||||
},
|
||||
],
|
||||
images: [{ id: "i1", imageId: "blob-1", x: 40, y: 40, width: 100, height: 50, locked: true }],
|
||||
};
|
||||
const clone = clonePageWithNewIds(source);
|
||||
expect(clone.id).not.toBe(source.id);
|
||||
expect(clone.strokes[0].id).not.toBe("s1");
|
||||
expect(clone.images[0].id).not.toBe("i1");
|
||||
expect(clone.images[0].imageId).toBe("blob-1");
|
||||
expect(clone.paperColor).toBe("#003423");
|
||||
expect(clone.pattern).toBe("grid");
|
||||
expect(clone.strokes[0]).toMatchObject({
|
||||
pen: "pen",
|
||||
color: "#d64541",
|
||||
size: 3,
|
||||
simulatePressure: true,
|
||||
shape: "arrow",
|
||||
});
|
||||
expect(clone.strokes[0].points).toEqual(source.strokes[0].points);
|
||||
expect(clone.strokes[0].points).not.toBe(source.strokes[0].points);
|
||||
expect(clone.strokes[0].points[0]).not.toBe(source.strokes[0].points[0]);
|
||||
expect(clone.images[0].locked).toBe(true);
|
||||
expect(source.strokes[0].id).toBe("s1");
|
||||
expect(source.images[0].id).toBe("i1");
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import type { ImageItem } from "./image";
|
||||
import type { Stroke } from "./stroke";
|
||||
import { newId } from "./stroke";
|
||||
|
||||
export const PAGE_WIDTH = 794;
|
||||
export const PAGE_HEIGHT = 1123;
|
||||
export const PAGE_GAP = 24;
|
||||
export const PAGE_TOP_MARGIN = 24;
|
||||
export const PLACEMENT_MARGIN = 40;
|
||||
|
||||
export const PAGE_PATTERNS = ["blank", "lined", "grid", "dots", "rice"] as const;
|
||||
|
||||
export type PagePattern = (typeof PAGE_PATTERNS)[number];
|
||||
|
||||
export interface Page {
|
||||
id: string;
|
||||
strokes: Stroke[];
|
||||
images: ImageItem[];
|
||||
paperColor: string;
|
||||
pattern: PagePattern;
|
||||
}
|
||||
|
||||
export interface PageHit {
|
||||
index: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export function createPage(paperColor: string, pattern: PagePattern = "blank"): Page {
|
||||
return { id: newId(), strokes: [], images: [], paperColor, pattern };
|
||||
}
|
||||
|
||||
export function pageTopY(index: number): number {
|
||||
return PAGE_TOP_MARGIN + index * (PAGE_HEIGHT + PAGE_GAP);
|
||||
}
|
||||
|
||||
export function contentHeight(pageCount: number): number {
|
||||
if (pageCount <= 0) return PAGE_TOP_MARGIN;
|
||||
return PAGE_TOP_MARGIN + pageCount * PAGE_HEIGHT + (pageCount - 1) * PAGE_GAP + PAGE_GAP;
|
||||
}
|
||||
|
||||
export function pageIndexAtY(worldY: number, pageCount: number): number {
|
||||
const raw = Math.floor((worldY - PAGE_TOP_MARGIN) / (PAGE_HEIGHT + PAGE_GAP));
|
||||
return Math.min(pageCount - 1, Math.max(0, raw));
|
||||
}
|
||||
|
||||
export function pageAt(worldX: number, worldY: number, pageCount: number): PageHit | null {
|
||||
if (worldX < 0 || worldX > PAGE_WIDTH) return null;
|
||||
const rel = worldY - PAGE_TOP_MARGIN;
|
||||
if (rel < 0) return null;
|
||||
const index = Math.floor(rel / (PAGE_HEIGHT + PAGE_GAP));
|
||||
if (index >= pageCount) return null;
|
||||
const y = rel - index * (PAGE_HEIGHT + PAGE_GAP);
|
||||
if (y > PAGE_HEIGHT) return null;
|
||||
return { index, x: worldX, y };
|
||||
}
|
||||
|
||||
export function clampToPage(x: number, y: number): { x: number; y: number } {
|
||||
return {
|
||||
x: Math.min(PAGE_WIDTH, Math.max(0, x)),
|
||||
y: Math.min(PAGE_HEIGHT, Math.max(0, y)),
|
||||
};
|
||||
}
|
||||
|
||||
export function trimTrailingBlankPages(pages: Page[]): Page[] {
|
||||
let end = pages.length;
|
||||
while (end > 1 && pages[end - 1].strokes.length === 0 && pages[end - 1].images.length === 0) {
|
||||
end--;
|
||||
}
|
||||
return pages.slice(0, end);
|
||||
}
|
||||
|
||||
export function clonePageWithNewIds(page: Page): Page {
|
||||
return {
|
||||
id: newId(),
|
||||
paperColor: page.paperColor,
|
||||
pattern: page.pattern,
|
||||
strokes: page.strokes.map((stroke) => ({
|
||||
...stroke,
|
||||
id: newId(),
|
||||
points: stroke.points.map((point) => ({ ...point })),
|
||||
})),
|
||||
images: page.images.map((image) => ({ ...image, id: newId() })),
|
||||
};
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PAGE_HEIGHT, PAGE_WIDTH } from "./page";
|
||||
import { PATTERN_MARGIN, PATTERN_SPACING, patternLayout, RICE_CELL } from "./patternLayout";
|
||||
|
||||
describe("patternLayout", () => {
|
||||
it("blank has no geometry", () => {
|
||||
expect(patternLayout("blank")).toEqual({ lines: [], dots: [] });
|
||||
});
|
||||
|
||||
it("lined lines stay within margins and keep a larger top gap", () => {
|
||||
const { lines, dots } = patternLayout("lined");
|
||||
expect(dots).toHaveLength(0);
|
||||
expect(lines.length).toBeGreaterThan(10);
|
||||
for (const line of lines) {
|
||||
expect(line.y1).toBe(line.y2);
|
||||
expect(line.dashed).toBe(false);
|
||||
expect(line.x1).toBe(PATTERN_MARGIN);
|
||||
expect(line.x2).toBe(PAGE_WIDTH - PATTERN_MARGIN);
|
||||
}
|
||||
expect(lines[1].y1 - lines[0].y1).toBe(PATTERN_SPACING);
|
||||
const topGap = lines[0].y1;
|
||||
const bottomGap = PAGE_HEIGHT - lines[lines.length - 1].y1;
|
||||
expect(topGap).toBeGreaterThan(bottomGap);
|
||||
expect(topGap).toBe(PATTERN_MARGIN + PATTERN_SPACING);
|
||||
});
|
||||
|
||||
it("grid draws only complete cells, centered on the page", () => {
|
||||
const { lines } = patternLayout("grid");
|
||||
const vertical = lines.filter((l) => l.x1 === l.x2);
|
||||
const horizontal = lines.filter((l) => l.y1 === l.y2);
|
||||
const cols = vertical.length - 1;
|
||||
const rows = horizontal.length - 1;
|
||||
const minX = Math.min(...vertical.map((l) => l.x1));
|
||||
const maxX = Math.max(...vertical.map((l) => l.x1));
|
||||
const minY = Math.min(...horizontal.map((l) => l.y1));
|
||||
const maxY = Math.max(...horizontal.map((l) => l.y1));
|
||||
expect((maxX - minX) % PATTERN_SPACING).toBe(0);
|
||||
expect((maxY - minY) % PATTERN_SPACING).toBe(0);
|
||||
expect(maxX - minX).toBe(cols * PATTERN_SPACING);
|
||||
expect(maxY - minY).toBe(rows * PATTERN_SPACING);
|
||||
expect(minX).toBeCloseTo(PAGE_WIDTH - maxX, 5);
|
||||
expect(minY).toBeCloseTo(PAGE_HEIGHT - maxY, 5);
|
||||
expect(minX).toBeGreaterThanOrEqual(PATTERN_MARGIN);
|
||||
expect(minY).toBeGreaterThanOrEqual(PATTERN_MARGIN);
|
||||
});
|
||||
|
||||
it("dots form a centered lattice within margins", () => {
|
||||
const { lines, dots } = patternLayout("dots");
|
||||
expect(lines).toHaveLength(0);
|
||||
expect(dots.length).toBeGreaterThan(50);
|
||||
const minX = Math.min(...dots.map((d) => d.x));
|
||||
const maxX = Math.max(...dots.map((d) => d.x));
|
||||
const minY = Math.min(...dots.map((d) => d.y));
|
||||
const maxY = Math.max(...dots.map((d) => d.y));
|
||||
expect(minX).toBeCloseTo(PAGE_WIDTH - maxX, 5);
|
||||
expect(minY).toBeCloseTo(PAGE_HEIGHT - maxY, 5);
|
||||
expect(minX).toBeGreaterThanOrEqual(PATTERN_MARGIN);
|
||||
expect(minY).toBeGreaterThanOrEqual(PATTERN_MARGIN);
|
||||
expect(maxX).toBeLessThanOrEqual(PAGE_WIDTH - PATTERN_MARGIN);
|
||||
expect(maxY).toBeLessThanOrEqual(PAGE_HEIGHT - PATTERN_MARGIN);
|
||||
});
|
||||
|
||||
it("rice produces a solid grid with dashed cell guides", () => {
|
||||
const { lines } = patternLayout("rice");
|
||||
const cols = Math.floor((PAGE_WIDTH - 2 * PATTERN_MARGIN) / RICE_CELL);
|
||||
const rows = Math.floor((PAGE_HEIGHT - 2 * PATTERN_MARGIN) / RICE_CELL);
|
||||
const solid = lines.filter((l) => !l.dashed);
|
||||
const dashed = lines.filter((l) => l.dashed);
|
||||
expect(solid).toHaveLength(cols + 1 + rows + 1);
|
||||
expect(dashed).toHaveLength(cols * rows * 4);
|
||||
const xs = solid.map((l) => l.x1);
|
||||
const ys = solid.map((l) => l.y1);
|
||||
expect(Math.min(...xs)).toBeGreaterThanOrEqual(PATTERN_MARGIN);
|
||||
expect(Math.min(...ys)).toBeGreaterThanOrEqual(PATTERN_MARGIN);
|
||||
});
|
||||
|
||||
it("centers the rice grid on the page", () => {
|
||||
const { lines } = patternLayout("rice");
|
||||
const solid = lines.filter((l) => !l.dashed);
|
||||
const minX = Math.min(...solid.map((l) => l.x1));
|
||||
const maxX = Math.max(...solid.map((l) => l.x2));
|
||||
expect(minX - 0).toBeCloseTo(PAGE_WIDTH - maxX, 5);
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
import { PAGE_HEIGHT, PAGE_WIDTH, type PagePattern } from "./page";
|
||||
|
||||
export const PATTERN_MARGIN = 48;
|
||||
export const PATTERN_SPACING = 56;
|
||||
export const RICE_CELL = 96;
|
||||
export const PATTERN_DASH = [6, 4] as const;
|
||||
|
||||
export interface PatternLine {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
dashed: boolean;
|
||||
}
|
||||
|
||||
export interface PatternDot {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface PatternLayout {
|
||||
lines: PatternLine[];
|
||||
dots: PatternDot[];
|
||||
}
|
||||
|
||||
export function patternLayout(pattern: PagePattern): PatternLayout {
|
||||
switch (pattern) {
|
||||
case "blank":
|
||||
return { lines: [], dots: [] };
|
||||
case "lined":
|
||||
return { lines: linedLines(), dots: [] };
|
||||
case "grid":
|
||||
return { lines: gridLines(), dots: [] };
|
||||
case "dots":
|
||||
return { lines: [], dots: dotLattice() };
|
||||
case "rice":
|
||||
return { lines: riceLines(), dots: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function centeredSpan(total: number, spacing: number): { start: number; cells: number } {
|
||||
const usable = total - 2 * PATTERN_MARGIN;
|
||||
const cells = Math.max(1, Math.floor(usable / spacing));
|
||||
return { start: (total - cells * spacing) / 2, cells };
|
||||
}
|
||||
|
||||
function linedLines(): PatternLine[] {
|
||||
const lines: PatternLine[] = [];
|
||||
for (
|
||||
let y = PATTERN_MARGIN + PATTERN_SPACING;
|
||||
y <= PAGE_HEIGHT - PATTERN_MARGIN;
|
||||
y += PATTERN_SPACING
|
||||
) {
|
||||
lines.push({
|
||||
x1: PATTERN_MARGIN,
|
||||
y1: y,
|
||||
x2: PAGE_WIDTH - PATTERN_MARGIN,
|
||||
y2: y,
|
||||
dashed: false,
|
||||
});
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function gridLines(): PatternLine[] {
|
||||
const { start: startX, cells: cols } = centeredSpan(PAGE_WIDTH, PATTERN_SPACING);
|
||||
const { start: startY, cells: rows } = centeredSpan(PAGE_HEIGHT, PATTERN_SPACING);
|
||||
const endX = startX + cols * PATTERN_SPACING;
|
||||
const endY = startY + rows * PATTERN_SPACING;
|
||||
const lines: PatternLine[] = [];
|
||||
for (let k = 0; k <= cols; k++) {
|
||||
const x = startX + k * PATTERN_SPACING;
|
||||
lines.push({ x1: x, y1: startY, x2: x, y2: endY, dashed: false });
|
||||
}
|
||||
for (let k = 0; k <= rows; k++) {
|
||||
const y = startY + k * PATTERN_SPACING;
|
||||
lines.push({ x1: startX, y1: y, x2: endX, y2: y, dashed: false });
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function dotLattice(): PatternDot[] {
|
||||
const { start: startX, cells: cols } = centeredSpan(PAGE_WIDTH, PATTERN_SPACING);
|
||||
const { start: startY, cells: rows } = centeredSpan(PAGE_HEIGHT, PATTERN_SPACING);
|
||||
const dots: PatternDot[] = [];
|
||||
for (let row = 0; row <= rows; row++) {
|
||||
for (let col = 0; col <= cols; col++) {
|
||||
dots.push({
|
||||
x: startX + col * PATTERN_SPACING,
|
||||
y: startY + row * PATTERN_SPACING,
|
||||
});
|
||||
}
|
||||
}
|
||||
return dots;
|
||||
}
|
||||
|
||||
function riceLines(): PatternLine[] {
|
||||
const { start: startX, cells: cols } = centeredSpan(PAGE_WIDTH, RICE_CELL);
|
||||
const { start: startY, cells: rows } = centeredSpan(PAGE_HEIGHT, RICE_CELL);
|
||||
const endX = startX + cols * RICE_CELL;
|
||||
const endY = startY + rows * RICE_CELL;
|
||||
const lines: PatternLine[] = [];
|
||||
|
||||
for (let k = 0; k <= cols; k++) {
|
||||
const x = startX + k * RICE_CELL;
|
||||
lines.push({ x1: x, y1: startY, x2: x, y2: endY, dashed: false });
|
||||
}
|
||||
for (let k = 0; k <= rows; k++) {
|
||||
const y = startY + k * RICE_CELL;
|
||||
lines.push({ x1: startX, y1: y, x2: endX, y2: y, dashed: false });
|
||||
}
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
const x = startX + col * RICE_CELL;
|
||||
const y = startY + row * RICE_CELL;
|
||||
const midX = x + RICE_CELL / 2;
|
||||
const midY = y + RICE_CELL / 2;
|
||||
lines.push({ x1: x, y1: midY, x2: x + RICE_CELL, y2: midY, dashed: true });
|
||||
lines.push({ x1: midX, y1: y, x2: midX, y2: y + RICE_CELL, dashed: true });
|
||||
lines.push({ x1: x, y1: y, x2: x + RICE_CELL, y2: y + RICE_CELL, dashed: true });
|
||||
lines.push({ x1: x, y1: y + RICE_CELL, x2: x + RICE_CELL, y2: y, dashed: true });
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
imageInLasso,
|
||||
imagesInLasso,
|
||||
pointInPolygon,
|
||||
segmentsIntersect,
|
||||
strokeInLasso,
|
||||
strokesInLasso,
|
||||
} from "./selection";
|
||||
import type { Stroke } from "./stroke";
|
||||
|
||||
function penStroke(id: string, points: { x: number; y: number }[], size = 4): Stroke {
|
||||
return {
|
||||
id,
|
||||
pen: "pen",
|
||||
color: "#1a1a1a",
|
||||
size,
|
||||
simulatePressure: false,
|
||||
points: points.map((p) => ({ ...p, pressure: 0.5 })),
|
||||
};
|
||||
}
|
||||
|
||||
function shapeStroke(
|
||||
id: string,
|
||||
shape: Stroke["shape"],
|
||||
a: { x: number; y: number },
|
||||
b: { x: number; y: number },
|
||||
): Stroke {
|
||||
return { ...penStroke(id, [a, b]), shape };
|
||||
}
|
||||
|
||||
const square = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 100, y: 0 },
|
||||
{ x: 100, y: 100 },
|
||||
{ x: 0, y: 100 },
|
||||
];
|
||||
|
||||
describe("pointInPolygon", () => {
|
||||
it("detects points inside and outside a square", () => {
|
||||
expect(pointInPolygon({ x: 50, y: 50 }, square)).toBe(true);
|
||||
expect(pointInPolygon({ x: 150, y: 50 }, square)).toBe(false);
|
||||
expect(pointInPolygon({ x: -10, y: 50 }, square)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for degenerate polygons", () => {
|
||||
expect(pointInPolygon({ x: 0, y: 0 }, [])).toBe(false);
|
||||
expect(pointInPolygon({ x: 0, y: 0 }, [{ x: 0, y: 0 }])).toBe(false);
|
||||
expect(pointInPolygon({ x: 5, y: 5 }, square.slice(0, 2))).toBe(false);
|
||||
});
|
||||
|
||||
it("handles concave polygons", () => {
|
||||
const l = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 100, y: 0 },
|
||||
{ x: 100, y: 40 },
|
||||
{ x: 40, y: 40 },
|
||||
{ x: 40, y: 100 },
|
||||
{ x: 0, y: 100 },
|
||||
];
|
||||
expect(pointInPolygon({ x: 20, y: 20 }, l)).toBe(true);
|
||||
expect(pointInPolygon({ x: 80, y: 80 }, l)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("segmentsIntersect", () => {
|
||||
it("detects crossing segments", () => {
|
||||
expect(
|
||||
segmentsIntersect({ x: 0, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }, { x: 10, y: 0 }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects parallel segments", () => {
|
||||
expect(
|
||||
segmentsIntersect({ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 0, y: 5 }, { x: 10, y: 5 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects collinear points off the segment", () => {
|
||||
expect(
|
||||
segmentsIntersect({ x: 20, y: 0 }, { x: 25, y: 5 }, { x: 0, y: 0 }, { x: 10, y: 0 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("detects touching endpoints", () => {
|
||||
expect(
|
||||
segmentsIntersect({ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("strokeInLasso", () => {
|
||||
it("selects a stroke fully inside the lasso without touching its boundary", () => {
|
||||
const stroke = penStroke("s1", [
|
||||
{ x: 40, y: 40 },
|
||||
{ x: 60, y: 60 },
|
||||
]);
|
||||
expect(strokeInLasso(stroke, square)).toBe(true);
|
||||
});
|
||||
|
||||
it("selects a stroke that merely crosses the lasso boundary", () => {
|
||||
const stroke = penStroke("s1", [
|
||||
{ x: -50, y: 50 },
|
||||
{ x: 50, y: 50 },
|
||||
]);
|
||||
expect(strokeInLasso(stroke, square)).toBe(true);
|
||||
});
|
||||
|
||||
it("treats an open lasso as closed by linking the last point to the first", () => {
|
||||
const openSquare = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 100, y: 0 },
|
||||
{ x: 100, y: 100 },
|
||||
];
|
||||
const stroke = penStroke("s1", [
|
||||
{ x: -10, y: -10 },
|
||||
{ x: 20, y: 5 },
|
||||
]);
|
||||
expect(strokeInLasso(stroke, openSquare)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects strokes outside the lasso", () => {
|
||||
const stroke = penStroke("s1", [
|
||||
{ x: 200, y: 200 },
|
||||
{ x: 300, y: 300 },
|
||||
]);
|
||||
expect(strokeInLasso(stroke, square)).toBe(false);
|
||||
});
|
||||
|
||||
it("selects a thick stroke when the lasso lies entirely within its ink", () => {
|
||||
const stroke = penStroke(
|
||||
"s1",
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 300, y: 0 },
|
||||
],
|
||||
30,
|
||||
);
|
||||
const tiny = [
|
||||
{ x: 140, y: -5 },
|
||||
{ x: 160, y: -5 },
|
||||
{ x: 160, y: 5 },
|
||||
{ x: 140, y: 5 },
|
||||
];
|
||||
expect(strokeInLasso(stroke, tiny)).toBe(true);
|
||||
});
|
||||
|
||||
it("selects a single-point dot stroke inside the lasso", () => {
|
||||
const stroke = penStroke("s1", [{ x: 50, y: 50 }]);
|
||||
expect(strokeInLasso(stroke, square)).toBe(true);
|
||||
});
|
||||
|
||||
it("selects shape strokes by their outline geometry", () => {
|
||||
const rect = shapeStroke("r", "rect", { x: 20, y: 20 }, { x: 80, y: 80 });
|
||||
expect(strokeInLasso(rect, square)).toBe(true);
|
||||
|
||||
const line = shapeStroke("l", "line", { x: -50, y: 50 }, { x: 50, y: 50 });
|
||||
expect(strokeInLasso(line, square)).toBe(true);
|
||||
|
||||
const ellipse = shapeStroke("e", "ellipse", { x: 200, y: 200 }, { x: 260, y: 260 });
|
||||
expect(strokeInLasso(ellipse, square)).toBe(false);
|
||||
|
||||
const ellipseOverlap = shapeStroke("e2", "ellipse", { x: 80, y: 80 }, { x: 160, y: 160 });
|
||||
expect(strokeInLasso(ellipseOverlap, square)).toBe(true);
|
||||
});
|
||||
|
||||
it("selects an arrow whose head wing crosses the lasso", () => {
|
||||
const arrow = shapeStroke("a", "arrow", { x: 104, y: 10 }, { x: 104, y: 80 });
|
||||
expect(strokeInLasso(arrow, square)).toBe(true);
|
||||
const outside = shapeStroke("a2", "arrow", { x: 140, y: 10 }, { x: 140, y: 80 });
|
||||
expect(strokeInLasso(outside, square)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for degenerate lassos", () => {
|
||||
const stroke = penStroke("s1", [
|
||||
{ x: 50, y: 50 },
|
||||
{ x: 60, y: 60 },
|
||||
]);
|
||||
expect(strokeInLasso(stroke, [])).toBe(false);
|
||||
expect(strokeInLasso(stroke, [{ x: 50, y: 50 }])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("strokesInLasso", () => {
|
||||
it("filters strokes preserving order", () => {
|
||||
const inside = penStroke("in", [
|
||||
{ x: 40, y: 40 },
|
||||
{ x: 60, y: 60 },
|
||||
]);
|
||||
const outside = penStroke("out", [
|
||||
{ x: 300, y: 300 },
|
||||
{ x: 320, y: 320 },
|
||||
]);
|
||||
const crossing = penStroke("cross", [
|
||||
{ x: -10, y: 10 },
|
||||
{ x: 10, y: 10 },
|
||||
]);
|
||||
expect(strokesInLasso([inside, outside, crossing], square).map((s) => s.id)).toEqual([
|
||||
"in",
|
||||
"cross",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty list for a degenerate lasso", () => {
|
||||
const stroke = penStroke("s1", [{ x: 50, y: 50 }]);
|
||||
expect(strokesInLasso([stroke], [{ x: 50, y: 50 }])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("imageInLasso", () => {
|
||||
const image = { id: "i1", imageId: "blob-1", x: 20, y: 20, width: 60, height: 40 };
|
||||
|
||||
it("selects an image fully inside the lasso", () => {
|
||||
expect(imageInLasso(image, square)).toBe(true);
|
||||
});
|
||||
|
||||
it("selects an image the lasso merely touches", () => {
|
||||
const overlapping = { ...image, x: 90, y: 90 };
|
||||
expect(imageInLasso(overlapping, square)).toBe(true);
|
||||
});
|
||||
|
||||
it("selects an image that fully contains the lasso", () => {
|
||||
const huge = { ...image, x: -50, y: -50, width: 500, height: 500 };
|
||||
const tiny = [
|
||||
{ x: 200, y: 200 },
|
||||
{ x: 220, y: 200 },
|
||||
{ x: 220, y: 220 },
|
||||
{ x: 200, y: 220 },
|
||||
];
|
||||
expect(imageInLasso(huge, tiny)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects images outside the lasso", () => {
|
||||
const outside = { ...image, id: "i2", x: 200, y: 200 };
|
||||
expect(imageInLasso(outside, square)).toBe(false);
|
||||
expect(imagesInLasso([image, outside], square).map((i) => i.id)).toEqual(["i1"]);
|
||||
});
|
||||
|
||||
it("returns false for a degenerate lasso", () => {
|
||||
expect(imageInLasso(image, [])).toBe(false);
|
||||
expect(imageInLasso(image, [{ x: 50, y: 50 }])).toBe(false);
|
||||
});
|
||||
|
||||
it("skips locked images", () => {
|
||||
const locked = { ...image, id: "i3", locked: true };
|
||||
expect(imageInLasso(locked, square)).toBe(true);
|
||||
expect(imagesInLasso([image, locked], square).map((i) => i.id)).toEqual(["i1"]);
|
||||
});
|
||||
});
|
||||
@@ -1,200 +0,0 @@
|
||||
import { ERASER_TOLERANCE, hitTestStroke } from "./hitTest";
|
||||
import type { ImageItem } from "./image";
|
||||
import { arrowHead, ellipseOutline, type Point } from "./shapeGeometry";
|
||||
import { effectiveStrokeSize, type Stroke } from "./stroke";
|
||||
|
||||
export function pointInPolygon(point: Point, polygon: Point[]): boolean {
|
||||
if (polygon.length < 3) return false;
|
||||
let inside = false;
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const a = polygon[i];
|
||||
const b = polygon[j];
|
||||
if (a.y > point.y !== b.y > point.y) {
|
||||
const x = ((b.x - a.x) * (point.y - a.y)) / (b.y - a.y) + a.x;
|
||||
if (point.x < x) inside = !inside;
|
||||
}
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
export function segmentsIntersect(a1: Point, a2: Point, b1: Point, b2: Point): boolean {
|
||||
const d1 = cross(b1, b2, a1);
|
||||
const d2 = cross(b1, b2, a2);
|
||||
const d3 = cross(a1, a2, b1);
|
||||
const d4 = cross(a1, a2, b2);
|
||||
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
|
||||
return true;
|
||||
}
|
||||
if (d1 === 0 && onSegment(b1, b2, a1)) return true;
|
||||
if (d2 === 0 && onSegment(b1, b2, a2)) return true;
|
||||
if (d3 === 0 && onSegment(a1, a2, b1)) return true;
|
||||
if (d4 === 0 && onSegment(a1, a2, b2)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function strokeInLasso(stroke: Stroke, lasso: Point[]): boolean {
|
||||
if (lasso.length < 2) return false;
|
||||
const segments = strokeSegments(stroke);
|
||||
const vertices = stroke.shape ? segments.flat() : (stroke.points as Point[]);
|
||||
const radius = effectiveStrokeSize(stroke) / 2 + ERASER_TOLERANCE;
|
||||
const region = lassoBounds(lasso);
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const v of vertices) {
|
||||
minX = Math.min(minX, v.x);
|
||||
minY = Math.min(minY, v.y);
|
||||
maxX = Math.max(maxX, v.x);
|
||||
maxY = Math.max(maxY, v.y);
|
||||
}
|
||||
if (
|
||||
maxX + radius < region.minX ||
|
||||
minX - radius > region.maxX ||
|
||||
maxY + radius < region.minY ||
|
||||
minY - radius > region.maxY
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
for (const vertex of vertices) {
|
||||
if (pointInPolygon(vertex, lasso)) return true;
|
||||
}
|
||||
const edges = polygonEdges(lasso);
|
||||
for (const [p1, p2] of segments) {
|
||||
for (const [q1, q2] of edges) {
|
||||
if (segmentsIntersect(p1, p2, q1, q2)) return true;
|
||||
}
|
||||
}
|
||||
for (const vertex of lasso) {
|
||||
if (hitTestStroke(vertex, stroke, ERASER_TOLERANCE)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function strokesInLasso(strokes: Stroke[], lasso: Point[]): Stroke[] {
|
||||
if (lasso.length < 2) return [];
|
||||
return strokes.filter((stroke) => strokeInLasso(stroke, lasso));
|
||||
}
|
||||
|
||||
export function imageInLasso(image: ImageItem, lasso: Point[]): boolean {
|
||||
if (lasso.length < 2) return false;
|
||||
const region = lassoBounds(lasso);
|
||||
if (
|
||||
image.x > region.maxX ||
|
||||
image.x + image.width < region.minX ||
|
||||
image.y > region.maxY ||
|
||||
image.y + image.height < region.minY
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const corners = [
|
||||
{ x: image.x, y: image.y },
|
||||
{ x: image.x + image.width, y: image.y },
|
||||
{ x: image.x + image.width, y: image.y + image.height },
|
||||
{ x: image.x, y: image.y + image.height },
|
||||
];
|
||||
for (const corner of corners) {
|
||||
if (pointInPolygon(corner, lasso)) return true;
|
||||
}
|
||||
const lassoEdges = polygonEdges(lasso);
|
||||
for (let i = 0; i < corners.length; i++) {
|
||||
const p1 = corners[i];
|
||||
const p2 = corners[(i + 1) % corners.length];
|
||||
for (const [q1, q2] of lassoEdges) {
|
||||
if (segmentsIntersect(p1, p2, q1, q2)) return true;
|
||||
}
|
||||
}
|
||||
for (const vertex of lasso) {
|
||||
if (
|
||||
vertex.x >= image.x &&
|
||||
vertex.x <= image.x + image.width &&
|
||||
vertex.y >= image.y &&
|
||||
vertex.y <= image.y + image.height
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function imagesInLasso(images: ImageItem[], lasso: Point[]): ImageItem[] {
|
||||
if (lasso.length < 2) return [];
|
||||
return images.filter((image) => !image.locked && imageInLasso(image, lasso));
|
||||
}
|
||||
|
||||
function polygonEdges(polygon: Point[]): [Point, Point][] {
|
||||
const edges: [Point, Point][] = [];
|
||||
for (let i = 0; i < polygon.length; i++) {
|
||||
edges.push([polygon[i], polygon[(i + 1) % polygon.length]]);
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
function strokeSegments(stroke: Stroke): [Point, Point][] {
|
||||
if (!stroke.shape) {
|
||||
const segments: [Point, Point][] = [];
|
||||
const points = stroke.points;
|
||||
for (let i = 0; i < points.length - 1; i++) segments.push([points[i], points[i + 1]]);
|
||||
return segments;
|
||||
}
|
||||
const [a, b] = stroke.points;
|
||||
if (!a || !b) return [];
|
||||
switch (stroke.shape) {
|
||||
case "line":
|
||||
return [[a, b]];
|
||||
case "arrow": {
|
||||
const [left, right] = arrowHead(a, b, stroke.size);
|
||||
return [
|
||||
[a, b],
|
||||
[b, left],
|
||||
[b, right],
|
||||
];
|
||||
}
|
||||
case "rect": {
|
||||
const tl = { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y) };
|
||||
const br = { x: Math.max(a.x, b.x), y: Math.max(a.y, b.y) };
|
||||
const tr = { x: br.x, y: tl.y };
|
||||
const bl = { x: tl.x, y: br.y };
|
||||
return [
|
||||
[tl, tr],
|
||||
[tr, br],
|
||||
[br, bl],
|
||||
[bl, tl],
|
||||
];
|
||||
}
|
||||
case "ellipse": {
|
||||
const rx = Math.abs(b.x - a.x) / 2;
|
||||
const ry = Math.abs(b.y - a.y) / 2;
|
||||
if (rx < 1 || ry < 1) return [[a, b]];
|
||||
const outline = ellipseOutline(a, b);
|
||||
return outline.map((point, i) => [point, outline[(i + 1) % outline.length]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function lassoBounds(lasso: Point[]): { minX: number; minY: number; maxX: number; maxY: number } {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const p of lasso) {
|
||||
minX = Math.min(minX, p.x);
|
||||
minY = Math.min(minY, p.y);
|
||||
maxX = Math.max(maxX, p.x);
|
||||
maxY = Math.max(maxY, p.y);
|
||||
}
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
function cross(o: Point, a: Point, b: Point): number {
|
||||
return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
|
||||
}
|
||||
|
||||
function onSegment(a: Point, b: Point, p: Point): boolean {
|
||||
return (
|
||||
Math.min(a.x, b.x) <= p.x &&
|
||||
p.x <= Math.max(a.x, b.x) &&
|
||||
Math.min(a.y, b.y) <= p.y &&
|
||||
p.y <= Math.max(a.y, b.y)
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export const ELLIPSE_OUTLINE_SEGMENTS = 32;
|
||||
|
||||
export function arrowHead(a: Point, b: Point, size: number): [Point, Point] {
|
||||
const angle = Math.atan2(b.y - a.y, b.x - a.x);
|
||||
const length = Math.max(10, size * 4);
|
||||
const spread = Math.PI / 7;
|
||||
return [
|
||||
{ x: b.x - length * Math.cos(angle - spread), y: b.y - length * Math.sin(angle - spread) },
|
||||
{ x: b.x - length * Math.cos(angle + spread), y: b.y - length * Math.sin(angle + spread) },
|
||||
];
|
||||
}
|
||||
|
||||
export function ellipseOutline(a: Point, b: Point, segments = ELLIPSE_OUTLINE_SEGMENTS): Point[] {
|
||||
const cx = (a.x + b.x) / 2;
|
||||
const cy = (a.y + b.y) / 2;
|
||||
const rx = Math.abs(b.x - a.x) / 2;
|
||||
const ry = Math.abs(b.y - a.y) / 2;
|
||||
const points: Point[] = [];
|
||||
for (let i = 0; i < segments; i++) {
|
||||
const angle = (i / segments) * Math.PI * 2;
|
||||
points.push({ x: cx + rx * Math.cos(angle), y: cy + ry * Math.sin(angle) });
|
||||
}
|
||||
return points;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createStroke } from "./stroke";
|
||||
|
||||
const baseInput = {
|
||||
pen: "pen" as const,
|
||||
color: "#1a1a1a",
|
||||
size: 5,
|
||||
simulatePressure: false,
|
||||
points: [{ x: 0, y: 0, pressure: 0.5 }],
|
||||
};
|
||||
|
||||
describe("createStroke", () => {
|
||||
it("assigns a unique non-empty id to each stroke", () => {
|
||||
const a = createStroke(baseInput);
|
||||
const b = createStroke(baseInput);
|
||||
expect(a.id).toBeTruthy();
|
||||
expect(b.id).toBeTruthy();
|
||||
expect(a.id).not.toBe(b.id);
|
||||
});
|
||||
|
||||
it("keeps the input fields untouched", () => {
|
||||
const stroke = createStroke(baseInput);
|
||||
expect(stroke.pen).toBe("pen");
|
||||
expect(stroke.color).toBe("#1a1a1a");
|
||||
expect(stroke.size).toBe(5);
|
||||
expect(stroke.points).toEqual(baseInput.points);
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
export type PenKind = "pen" | "highlighter";
|
||||
|
||||
export const HIGHLIGHTER_SIZE_FACTOR = 2.2;
|
||||
|
||||
export const SHAPE_KINDS = ["line", "arrow", "rect", "ellipse"] as const;
|
||||
|
||||
export type ShapeKind = (typeof SHAPE_KINDS)[number];
|
||||
|
||||
export const TOOL_KINDS = [
|
||||
"pen",
|
||||
"highlighter",
|
||||
"eraser",
|
||||
"laser",
|
||||
"select",
|
||||
...SHAPE_KINDS,
|
||||
] as const;
|
||||
|
||||
export type ToolKind = (typeof TOOL_KINDS)[number];
|
||||
|
||||
export interface StrokePoint {
|
||||
x: number;
|
||||
y: number;
|
||||
pressure: number;
|
||||
}
|
||||
|
||||
export interface Stroke {
|
||||
id: string;
|
||||
pen: PenKind;
|
||||
color: string;
|
||||
size: number;
|
||||
simulatePressure: boolean;
|
||||
points: StrokePoint[];
|
||||
shape?: ShapeKind;
|
||||
}
|
||||
|
||||
export function createStroke(input: Omit<Stroke, "id">): Stroke {
|
||||
return { ...input, id: newId() };
|
||||
}
|
||||
|
||||
export function effectiveStrokeSize(stroke: Stroke): number {
|
||||
return stroke.pen === "highlighter" ? stroke.size * HIGHLIGHTER_SIZE_FACTOR : stroke.size;
|
||||
}
|
||||
|
||||
let idCounter = 0;
|
||||
|
||||
export function newId(): string {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now().toString(36)}-${(idCounter++).toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Stroke } from "./stroke";
|
||||
import {
|
||||
clampMoveDelta,
|
||||
clampScaleToPage,
|
||||
imagesBounds,
|
||||
scaleBounds,
|
||||
scaleImage,
|
||||
scaleStroke,
|
||||
strokesBounds,
|
||||
translateBounds,
|
||||
translateImage,
|
||||
translateStroke,
|
||||
unionBounds,
|
||||
} from "./transform";
|
||||
|
||||
function penStroke(id: string, points: { x: number; y: number }[], size = 4): Stroke {
|
||||
return {
|
||||
id,
|
||||
pen: "pen",
|
||||
color: "#1a1a1a",
|
||||
size,
|
||||
simulatePressure: false,
|
||||
points: points.map((p) => ({ ...p, pressure: 0.5 })),
|
||||
};
|
||||
}
|
||||
|
||||
describe("strokesBounds", () => {
|
||||
it("unions stroke bounds including the ink margin", () => {
|
||||
const a = penStroke("a", [
|
||||
{ x: 10, y: 10 },
|
||||
{ x: 20, y: 20 },
|
||||
]);
|
||||
const b = penStroke(
|
||||
"b",
|
||||
[
|
||||
{ x: 100, y: 50 },
|
||||
{ x: 120, y: 80 },
|
||||
],
|
||||
8,
|
||||
);
|
||||
expect(strokesBounds([a, b])).toEqual({ minX: 8, minY: 8, maxX: 124, maxY: 84 });
|
||||
});
|
||||
|
||||
it("returns null for an empty selection", () => {
|
||||
expect(strokesBounds([])).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the widened highlighter size", () => {
|
||||
const stroke: Stroke = { ...penStroke("h", [{ x: 10, y: 10 }], 4), pen: "highlighter" };
|
||||
const bounds = strokesBounds([stroke]);
|
||||
expect(bounds?.minX).toBeCloseTo(10 - (4 * 2.2) / 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("translateStroke", () => {
|
||||
it("moves every point and preserves the rest of the stroke", () => {
|
||||
const stroke = penStroke("s", [
|
||||
{ x: 1, y: 2 },
|
||||
{ x: 3, y: 4 },
|
||||
]);
|
||||
const moved = translateStroke(stroke, 10, -5);
|
||||
expect(moved.points.map((p) => [p.x, p.y])).toEqual([
|
||||
[11, -3],
|
||||
[13, -1],
|
||||
]);
|
||||
expect(moved.id).toBe("s");
|
||||
expect(moved.size).toBe(stroke.size);
|
||||
expect(moved.color).toBe(stroke.color);
|
||||
expect(stroke.points[0].x).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scaleStroke", () => {
|
||||
it("scales points about the anchor", () => {
|
||||
const stroke = penStroke("s", [
|
||||
{ x: 10, y: 10 },
|
||||
{ x: 20, y: 30 },
|
||||
]);
|
||||
const scaled = scaleStroke(stroke, { x: 10, y: 10 }, 2, 3);
|
||||
expect(scaled.points.map((p) => [p.x, p.y])).toEqual([
|
||||
[10, 10],
|
||||
[30, 70],
|
||||
]);
|
||||
});
|
||||
|
||||
it("scales the ink size by the geometric mean of the factors", () => {
|
||||
const stroke = penStroke("s", [{ x: 0, y: 0 }], 6);
|
||||
expect(scaleStroke(stroke, { x: 0, y: 0 }, 2, 2).size).toBeCloseTo(12);
|
||||
expect(scaleStroke(stroke, { x: 0, y: 0 }, 2, 8).size).toBeCloseTo(24);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bounds helpers", () => {
|
||||
const bounds = { minX: 10, minY: 20, maxX: 30, maxY: 40 };
|
||||
|
||||
it("translates bounds", () => {
|
||||
expect(translateBounds(bounds, 5, -10)).toEqual({ minX: 15, minY: 10, maxX: 35, maxY: 30 });
|
||||
});
|
||||
|
||||
it("scales bounds about the anchor", () => {
|
||||
expect(scaleBounds(bounds, { x: 10, y: 20 }, 2, 2)).toEqual({
|
||||
minX: 10,
|
||||
minY: 20,
|
||||
maxX: 50,
|
||||
maxY: 60,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampMoveDelta", () => {
|
||||
const bounds = { minX: 10, minY: 10, maxX: 110, maxY: 110 };
|
||||
|
||||
it("keeps deltas that fit on the page", () => {
|
||||
expect(clampMoveDelta(bounds, 50, -5)).toEqual({ dx: 50, dy: -5 });
|
||||
});
|
||||
|
||||
it("clamps movement beyond the page edges", () => {
|
||||
expect(clampMoveDelta(bounds, -500, 0).dx).toBe(-10);
|
||||
expect(clampMoveDelta(bounds, 0, 99999).dy).toBe(1123 - 110);
|
||||
expect(clampMoveDelta(bounds, 99999, 0).dx).toBe(794 - 110);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampScaleToPage", () => {
|
||||
it("allows scaling that stays on the page", () => {
|
||||
const bounds = { minX: 100, minY: 100, maxX: 200, maxY: 200 };
|
||||
expect(clampScaleToPage(bounds, { x: 100, y: 100 }, 2, 2)).toEqual({ sx: 2, sy: 2 });
|
||||
});
|
||||
|
||||
it("clamps scaling that would overflow the page", () => {
|
||||
const bounds = { minX: 100, minY: 100, maxX: 200, maxY: 200 };
|
||||
const clamped = clampScaleToPage(bounds, { x: 100, y: 100 }, 100, 100);
|
||||
expect(clamped.sx).toBeCloseTo((794 - 100) / 100);
|
||||
expect(clamped.sy).toBeCloseTo((1123 - 100) / 100);
|
||||
});
|
||||
|
||||
it("supports shrinking without limits", () => {
|
||||
const bounds = { minX: 100, minY: 100, maxX: 200, maxY: 200 };
|
||||
expect(clampScaleToPage(bounds, { x: 100, y: 100 }, 0.5, 0.25)).toEqual({
|
||||
sx: 0.5,
|
||||
sy: 0.25,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("image transforms", () => {
|
||||
const image = { id: "i1", imageId: "blob-1", x: 10, y: 20, width: 100, height: 50 };
|
||||
|
||||
it("translates an image", () => {
|
||||
expect(translateImage(image, 5, -10)).toMatchObject({ x: 15, y: 10, width: 100 });
|
||||
expect(image.x).toBe(10);
|
||||
});
|
||||
|
||||
it("scales an image about the anchor including its size", () => {
|
||||
const scaled = scaleImage(image, { x: 10, y: 20 }, 2, 3);
|
||||
expect(scaled).toMatchObject({ x: 10, y: 20, width: 200, height: 150 });
|
||||
});
|
||||
|
||||
it("computes the union bounds of images", () => {
|
||||
const other = { ...image, id: "i2", x: 50, y: 0, width: 20, height: 20 };
|
||||
expect(imagesBounds([image, other])).toEqual({ minX: 10, minY: 0, maxX: 110, maxY: 70 });
|
||||
expect(imagesBounds([])).toBeNull();
|
||||
});
|
||||
|
||||
it("unions stroke and image bounds", () => {
|
||||
const stroke = penStroke("s", [{ x: 0, y: 0 }], 4);
|
||||
const bounds = unionBounds(strokesBounds([stroke]), imagesBounds([image]));
|
||||
expect(bounds).toEqual({ minX: -2, minY: -2, maxX: 110, maxY: 70 });
|
||||
expect(unionBounds(null, null)).toBeNull();
|
||||
expect(unionBounds(imagesBounds([image]), null)).toEqual({
|
||||
minX: 10,
|
||||
minY: 20,
|
||||
maxX: 110,
|
||||
maxY: 70,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
import { type Bounds, strokeBounds } from "./hitTest";
|
||||
import type { ImageItem } from "./image";
|
||||
import { PAGE_HEIGHT, PAGE_WIDTH } from "./page";
|
||||
import type { Point } from "./shapeGeometry";
|
||||
import { effectiveStrokeSize, type Stroke } from "./stroke";
|
||||
|
||||
export function strokesBounds(strokes: Stroke[]): Bounds | null {
|
||||
if (strokes.length === 0) return null;
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const stroke of strokes) {
|
||||
const bounds = strokeBounds(stroke, inkMargin(stroke));
|
||||
minX = Math.min(minX, bounds.minX);
|
||||
minY = Math.min(minY, bounds.minY);
|
||||
maxX = Math.max(maxX, bounds.maxX);
|
||||
maxY = Math.max(maxY, bounds.maxY);
|
||||
}
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
export function translateStroke(stroke: Stroke, dx: number, dy: number): Stroke {
|
||||
return {
|
||||
...stroke,
|
||||
points: stroke.points.map((p) => ({ ...p, x: p.x + dx, y: p.y + dy })),
|
||||
};
|
||||
}
|
||||
|
||||
export function translateImage(image: ImageItem, dx: number, dy: number): ImageItem {
|
||||
return { ...image, x: image.x + dx, y: image.y + dy };
|
||||
}
|
||||
|
||||
export function scaleImage(image: ImageItem, anchor: Point, sx: number, sy: number): ImageItem {
|
||||
return {
|
||||
...image,
|
||||
x: anchor.x + (image.x - anchor.x) * sx,
|
||||
y: anchor.y + (image.y - anchor.y) * sy,
|
||||
width: image.width * sx,
|
||||
height: image.height * sy,
|
||||
};
|
||||
}
|
||||
|
||||
export function imagesBounds(images: ImageItem[]): Bounds | null {
|
||||
if (images.length === 0) return null;
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const image of images) {
|
||||
minX = Math.min(minX, image.x);
|
||||
minY = Math.min(minY, image.y);
|
||||
maxX = Math.max(maxX, image.x + image.width);
|
||||
maxY = Math.max(maxY, image.y + image.height);
|
||||
}
|
||||
return { minX, minY, maxX, maxY };
|
||||
}
|
||||
|
||||
export function unionBounds(a: Bounds | null, b: Bounds | null): Bounds | null {
|
||||
if (!a) return b;
|
||||
if (!b) return a;
|
||||
return {
|
||||
minX: Math.min(a.minX, b.minX),
|
||||
minY: Math.min(a.minY, b.minY),
|
||||
maxX: Math.max(a.maxX, b.maxX),
|
||||
maxY: Math.max(a.maxY, b.maxY),
|
||||
};
|
||||
}
|
||||
|
||||
export function scaleStroke(stroke: Stroke, anchor: Point, sx: number, sy: number): Stroke {
|
||||
return {
|
||||
...stroke,
|
||||
size: stroke.size * Math.sqrt(Math.abs(sx * sy)),
|
||||
points: stroke.points.map((p) => ({
|
||||
...p,
|
||||
x: anchor.x + (p.x - anchor.x) * sx,
|
||||
y: anchor.y + (p.y - anchor.y) * sy,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function translateBounds(bounds: Bounds, dx: number, dy: number): Bounds {
|
||||
return {
|
||||
minX: bounds.minX + dx,
|
||||
minY: bounds.minY + dy,
|
||||
maxX: bounds.maxX + dx,
|
||||
maxY: bounds.maxY + dy,
|
||||
};
|
||||
}
|
||||
|
||||
export function scaleBounds(bounds: Bounds, anchor: Point, sx: number, sy: number): Bounds {
|
||||
const xs = [bounds.minX, bounds.maxX].map((x) => anchor.x + (x - anchor.x) * sx);
|
||||
const ys = [bounds.minY, bounds.maxY].map((y) => anchor.y + (y - anchor.y) * sy);
|
||||
return {
|
||||
minX: Math.min(...xs),
|
||||
minY: Math.min(...ys),
|
||||
maxX: Math.max(...xs),
|
||||
maxY: Math.max(...ys),
|
||||
};
|
||||
}
|
||||
|
||||
export function clampMoveDelta(bounds: Bounds, dx: number, dy: number): { dx: number; dy: number } {
|
||||
return {
|
||||
dx: clamp(dx, -bounds.minX, PAGE_WIDTH - bounds.maxX),
|
||||
dy: clamp(dy, -bounds.minY, PAGE_HEIGHT - bounds.maxY),
|
||||
};
|
||||
}
|
||||
|
||||
export function clampScaleToPage(
|
||||
bounds: Bounds,
|
||||
anchor: Point,
|
||||
sx: number,
|
||||
sy: number,
|
||||
): { sx: number; sy: number } {
|
||||
return {
|
||||
sx: Math.min(sx, maxScale(bounds.minX, bounds.maxX, anchor.x, PAGE_WIDTH)),
|
||||
sy: Math.min(sy, maxScale(bounds.minY, bounds.maxY, anchor.y, PAGE_HEIGHT)),
|
||||
};
|
||||
}
|
||||
|
||||
function maxScale(min: number, max: number, anchor: number, extent: number): number {
|
||||
let limit = Infinity;
|
||||
if (min < anchor) limit = Math.min(limit, anchor / (anchor - min));
|
||||
if (max > anchor) limit = Math.min(limit, (extent - anchor) / (max - anchor));
|
||||
return limit;
|
||||
}
|
||||
|
||||
function inkMargin(stroke: Stroke): number {
|
||||
return effectiveStrokeSize(stroke) / 2;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), Math.max(min, max));
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export interface ViewState {
|
||||
x: number;
|
||||
y: number;
|
||||
zoom: number;
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { Page } from "../model/page";
|
||||
import { useBoardStore } from "../store/useBoardStore";
|
||||
import { replacePages, savePage } from "./notebooks";
|
||||
|
||||
let saveErrorReported = false;
|
||||
|
||||
export function startAutosave(): () => void {
|
||||
return useBoardStore.subscribe((state, prev) => {
|
||||
const notebookId = state.notebookId;
|
||||
if (!notebookId || state.pages === prev.pages) return;
|
||||
if (state.pages.length < prev.pages.length) {
|
||||
void replacePages(notebookId, state.pages).catch(reportSaveError);
|
||||
return;
|
||||
}
|
||||
for (const { index, page } of changedPages(prev.pages, state.pages)) {
|
||||
void savePage(notebookId, index, page).catch(reportSaveError);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function reportSaveError(error: unknown): void {
|
||||
console.error("Failed to save page", error);
|
||||
if (saveErrorReported) return;
|
||||
saveErrorReported = true;
|
||||
window.alert("Saving failed. Your latest changes may not be stored.");
|
||||
}
|
||||
|
||||
function changedPages(prev: Page[], next: Page[]): { index: number; page: Page }[] {
|
||||
const changed: { index: number; page: Page }[] = [];
|
||||
for (const [index, page] of next.entries()) {
|
||||
if (prev[index] !== page) changed.push({ index, page });
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { type DBSchema, type IDBPDatabase, openDB } from "idb";
|
||||
import type { ImageItem } from "../model/image";
|
||||
import type { PagePattern } from "../model/page";
|
||||
import type { Stroke } from "../model/stroke";
|
||||
import type { ViewState } from "../model/viewState";
|
||||
|
||||
export interface NotebookRecord {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
pageCount: number;
|
||||
viewState?: ViewState;
|
||||
}
|
||||
|
||||
export interface PageRecord {
|
||||
id: string;
|
||||
notebookId: string;
|
||||
index: number;
|
||||
paperColor: string;
|
||||
pattern: PagePattern;
|
||||
strokes: Stroke[];
|
||||
images?: ImageItem[];
|
||||
}
|
||||
|
||||
export interface ImageRecord {
|
||||
id: string;
|
||||
mimeType: string;
|
||||
blob: Blob;
|
||||
}
|
||||
|
||||
interface VasDB extends DBSchema {
|
||||
notebooks: { key: string; value: NotebookRecord };
|
||||
pages: { key: string; value: PageRecord; indexes: { "by-notebook": string } };
|
||||
images: { key: string; value: ImageRecord };
|
||||
}
|
||||
|
||||
let dbPromise: Promise<IDBPDatabase<VasDB>> | null = null;
|
||||
|
||||
export function db(): Promise<IDBPDatabase<VasDB>> {
|
||||
dbPromise ??= openDB<VasDB>("vas", 2, {
|
||||
upgrade(database, oldVersion) {
|
||||
if (oldVersion < 1) {
|
||||
database.createObjectStore("notebooks", { keyPath: "id" });
|
||||
const pages = database.createObjectStore("pages", { keyPath: "id" });
|
||||
pages.createIndex("by-notebook", "notebookId");
|
||||
}
|
||||
if (oldVersion < 2) {
|
||||
database.createObjectStore("images", { keyPath: "id" });
|
||||
}
|
||||
},
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { ensureImageLoaded } from "../engine/imageCache";
|
||||
import { paintPage } from "../engine/renderPage";
|
||||
import type { Page } from "../model/page";
|
||||
import { downloadBlob } from "./transfer";
|
||||
|
||||
const PNG_SCALE = 2;
|
||||
|
||||
export async function exportPagePng(title: string, pageIndex: number, page: Page): Promise<void> {
|
||||
await Promise.all(page.images.map((image) => ensureImageLoaded(image.imageId)));
|
||||
const canvas = document.createElement("canvas");
|
||||
paintPage(canvas, page, PNG_SCALE);
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
downloadBlob(blob, `${title}-page-${pageIndex + 1}.png`);
|
||||
} else {
|
||||
window.alert("PNG export failed.");
|
||||
}
|
||||
}, "image/png");
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
import { PDFDocument, type PDFImage, type PDFPage, type RGB, rgb } from "pdf-lib";
|
||||
import { getOutlinePoints, HIGHLIGHTER_ALPHA } from "../engine/renderStroke";
|
||||
import { hexToRgb, isDarkColor } from "../model/color";
|
||||
import type { ImageItem } from "../model/image";
|
||||
import type { PagePattern } from "../model/page";
|
||||
import { PAGE_HEIGHT, PAGE_WIDTH, type Page, trimTrailingBlankPages } from "../model/page";
|
||||
import { PATTERN_DASH, patternLayout } from "../model/patternLayout";
|
||||
import { arrowHead } from "../model/shapeGeometry";
|
||||
import type { Stroke } from "../model/stroke";
|
||||
import { getImage } from "./images";
|
||||
import { rasterizeToPng } from "./rasterize";
|
||||
import { downloadBlob } from "./transfer";
|
||||
|
||||
const PT_PER_UNIT = 72 / 96;
|
||||
|
||||
export async function exportNotebookPdf(title: string, pages: Page[]): Promise<void> {
|
||||
const doc = await PDFDocument.create();
|
||||
doc.setTitle(title);
|
||||
const width = PAGE_WIDTH * PT_PER_UNIT;
|
||||
const height = PAGE_HEIGHT * PT_PER_UNIT;
|
||||
const embedded = new Map<string, PDFImage | null>();
|
||||
|
||||
for (const page of trimTrailingBlankPages(pages)) {
|
||||
const pdfPage = doc.addPage([width, height]);
|
||||
pdfPage.drawRectangle({ x: 0, y: 0, width, height, color: toPdfRgb(page.paperColor, rgb) });
|
||||
drawPattern(pdfPage, page.pattern, page.paperColor, rgb);
|
||||
for (const image of page.images) {
|
||||
await drawPdfImage(doc, pdfPage, image, embedded);
|
||||
}
|
||||
for (const stroke of page.strokes) {
|
||||
if (stroke.shape) {
|
||||
drawPdfShape(pdfPage, stroke, rgb);
|
||||
continue;
|
||||
}
|
||||
const outline = getOutlinePoints(stroke, true);
|
||||
if (outline.length < 3) continue;
|
||||
pdfPage.drawSvgPath(outlineToSvgPath(outline), {
|
||||
y: height,
|
||||
color: toPdfRgb(stroke.color, rgb),
|
||||
opacity: stroke.pen === "highlighter" ? HIGHLIGHTER_ALPHA : 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = await doc.save();
|
||||
downloadBlob(
|
||||
new Blob([bytes.buffer as ArrayBuffer], { type: "application/pdf" }),
|
||||
`${title}.pdf`,
|
||||
);
|
||||
}
|
||||
|
||||
async function drawPdfImage(
|
||||
doc: PDFDocument,
|
||||
pdfPage: PDFPage,
|
||||
image: ImageItem,
|
||||
embedded: Map<string, PDFImage | null>,
|
||||
): Promise<void> {
|
||||
let pdfImage = embedded.get(image.imageId);
|
||||
if (pdfImage === undefined) {
|
||||
pdfImage = await embedImage(doc, image.imageId);
|
||||
embedded.set(image.imageId, pdfImage);
|
||||
}
|
||||
if (!pdfImage) return;
|
||||
pdfPage.drawImage(pdfImage, {
|
||||
x: image.x * PT_PER_UNIT,
|
||||
y: toY(image.y + image.height),
|
||||
width: image.width * PT_PER_UNIT,
|
||||
height: image.height * PT_PER_UNIT,
|
||||
});
|
||||
}
|
||||
|
||||
async function embedImage(doc: PDFDocument, imageId: string): Promise<PDFImage | null> {
|
||||
try {
|
||||
const record = await getImage(imageId);
|
||||
if (!record) return null;
|
||||
const bytes = await record.blob.arrayBuffer();
|
||||
if (record.mimeType === "image/jpeg") return await doc.embedJpg(bytes);
|
||||
if (record.mimeType === "image/png") return await doc.embedPng(bytes);
|
||||
const png = await rasterizeToPng(record.blob);
|
||||
return png ? await doc.embedPng(png) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function toPdfRgb(hex: string, rgb: (r: number, g: number, b: number) => RGB): RGB {
|
||||
const parsed = hexToRgb(hex);
|
||||
return parsed ? rgb(parsed.r, parsed.g, parsed.b) : rgb(0, 0, 0);
|
||||
}
|
||||
|
||||
function outlineToSvgPath(outline: number[][]): string {
|
||||
const segments = outline.map(([x, y], index) => {
|
||||
const command = index === 0 ? "M" : "L";
|
||||
return `${command}${(x * PT_PER_UNIT).toFixed(2)} ${(y * PT_PER_UNIT).toFixed(2)}`;
|
||||
});
|
||||
return `${segments.join(" ")} Z`;
|
||||
}
|
||||
|
||||
function toY(y: number): number {
|
||||
return (PAGE_HEIGHT - y) * PT_PER_UNIT;
|
||||
}
|
||||
|
||||
function drawPdfShape(
|
||||
pdfPage: PDFPage,
|
||||
stroke: Stroke,
|
||||
rgb: (r: number, g: number, b: number) => RGB,
|
||||
): void {
|
||||
const [a, b] = stroke.points;
|
||||
if (!stroke.shape || !a || !b) return;
|
||||
const color = toPdfRgb(stroke.color, rgb);
|
||||
const thickness = stroke.size * PT_PER_UNIT;
|
||||
switch (stroke.shape) {
|
||||
case "line":
|
||||
pdfPage.drawLine({
|
||||
start: { x: a.x * PT_PER_UNIT, y: toY(a.y) },
|
||||
end: { x: b.x * PT_PER_UNIT, y: toY(b.y) },
|
||||
thickness,
|
||||
color,
|
||||
});
|
||||
break;
|
||||
case "arrow": {
|
||||
const [left, right] = arrowHead(a, b, stroke.size);
|
||||
for (const point of [b, left, right]) {
|
||||
const from = point === b ? a : b;
|
||||
pdfPage.drawLine({
|
||||
start: { x: from.x * PT_PER_UNIT, y: toY(from.y) },
|
||||
end: { x: point.x * PT_PER_UNIT, y: toY(point.y) },
|
||||
thickness,
|
||||
color,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "rect":
|
||||
pdfPage.drawRectangle({
|
||||
x: Math.min(a.x, b.x) * PT_PER_UNIT,
|
||||
y: toY(Math.max(a.y, b.y)),
|
||||
width: Math.abs(b.x - a.x) * PT_PER_UNIT,
|
||||
height: Math.abs(b.y - a.y) * PT_PER_UNIT,
|
||||
borderColor: color,
|
||||
borderWidth: thickness,
|
||||
});
|
||||
break;
|
||||
case "ellipse":
|
||||
pdfPage.drawEllipse({
|
||||
x: ((a.x + b.x) / 2) * PT_PER_UNIT,
|
||||
y: toY((a.y + b.y) / 2),
|
||||
xScale: (Math.abs(b.x - a.x) / 2) * PT_PER_UNIT,
|
||||
yScale: (Math.abs(b.y - a.y) / 2) * PT_PER_UNIT,
|
||||
borderColor: color,
|
||||
borderWidth: thickness,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function drawPattern(
|
||||
pdfPage: PDFPage,
|
||||
pattern: PagePattern,
|
||||
paperColor: string,
|
||||
rgb: (r: number, g: number, b: number) => RGB,
|
||||
): void {
|
||||
if (pattern === "blank") return;
|
||||
const { lines, dots } = patternLayout(pattern);
|
||||
const dark = isDarkColor(paperColor);
|
||||
const color = dark ? rgb(1, 1, 1) : rgb(0, 0, 0);
|
||||
const opacity = dark ? 0.22 : 0.16;
|
||||
for (const line of lines) {
|
||||
pdfPage.drawLine({
|
||||
start: { x: line.x1 * PT_PER_UNIT, y: toY(line.y1) },
|
||||
end: { x: line.x2 * PT_PER_UNIT, y: toY(line.y2) },
|
||||
thickness: 0.75,
|
||||
color,
|
||||
opacity,
|
||||
...(line.dashed ? { dashArray: PATTERN_DASH.map((v) => v * PT_PER_UNIT) } : {}),
|
||||
});
|
||||
}
|
||||
for (const dot of dots) {
|
||||
pdfPage.drawCircle({
|
||||
x: dot.x * PT_PER_UNIT,
|
||||
y: toY(dot.y),
|
||||
size: 0.9,
|
||||
color,
|
||||
opacity,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useBoardStore } from "../store/useBoardStore";
|
||||
import { db, type ImageRecord } from "./db";
|
||||
|
||||
export async function saveImage(record: ImageRecord): Promise<void> {
|
||||
await (await db()).put("images", record);
|
||||
}
|
||||
|
||||
export async function saveImages(records: ImageRecord[]): Promise<void> {
|
||||
if (records.length === 0) return;
|
||||
const tx = (await db()).transaction("images", "readwrite");
|
||||
for (const record of records) await tx.store.put(record);
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
export async function getImage(id: string): Promise<ImageRecord | undefined> {
|
||||
return (await db()).get("images", id);
|
||||
}
|
||||
|
||||
export async function deleteImages(ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const tx = (await db()).transaction("images", "readwrite");
|
||||
for (const id of ids) await tx.store.delete(id);
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
export async function gcUnreferencedImages(): Promise<void> {
|
||||
const database = await db();
|
||||
const keep = new Set<string>();
|
||||
for (const page of await database.getAll("pages")) {
|
||||
for (const image of page.images ?? []) keep.add(image.imageId);
|
||||
}
|
||||
const state = useBoardStore.getState();
|
||||
for (const page of state.pages) {
|
||||
for (const image of page.images) keep.add(image.imageId);
|
||||
}
|
||||
for (const image of state.clipboard.images) keep.add(image.imageId);
|
||||
const tx = database.transaction("images", "readwrite");
|
||||
const keys = await tx.store.getAllKeys();
|
||||
for (const key of keys) {
|
||||
if (!keep.has(key)) await tx.store.delete(key);
|
||||
}
|
||||
await tx.done;
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import { placeImageCentered } from "../model/image";
|
||||
import { createPage, type Page } from "../model/page";
|
||||
import { newId } from "../model/stroke";
|
||||
import { deleteImages, saveImages } from "./images";
|
||||
import { createNotebook, deleteNotebook, replacePages } from "./notebooks";
|
||||
|
||||
const PDF_RENDER_SCALE = 3;
|
||||
const JPEG_QUALITY = 0.9;
|
||||
|
||||
export interface RasterizedPdfPage {
|
||||
imageId: string;
|
||||
mimeType: string;
|
||||
blob: Blob;
|
||||
naturalWidth: number;
|
||||
naturalHeight: number;
|
||||
}
|
||||
|
||||
type PdfJs = typeof import("pdfjs-dist");
|
||||
|
||||
let pdfjsPromise: Promise<PdfJs> | null = null;
|
||||
|
||||
function loadPdfJs(): Promise<PdfJs> {
|
||||
pdfjsPromise ??= (async () => {
|
||||
const [pdfjs, worker] = await Promise.all([
|
||||
import("pdfjs-dist"),
|
||||
import("pdfjs-dist/build/pdf.worker.min.mjs?url"),
|
||||
]);
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = worker.default;
|
||||
return pdfjs;
|
||||
})();
|
||||
pdfjsPromise.catch(() => {
|
||||
pdfjsPromise = null;
|
||||
});
|
||||
return pdfjsPromise;
|
||||
}
|
||||
|
||||
export async function saveRasterizedImages(rasterized: RasterizedPdfPage[]): Promise<void> {
|
||||
await saveImages(rasterized.map((raster) => ({ ...raster, id: raster.imageId })));
|
||||
}
|
||||
|
||||
export async function rasterizePdf(
|
||||
file: File,
|
||||
onProgress?: (done: number, total: number) => void,
|
||||
): Promise<RasterizedPdfPage[]> {
|
||||
const pdfjs = await loadPdfJs();
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
let cancelled = false;
|
||||
const task = pdfjs.getDocument({ data });
|
||||
task.onPassword = (updatePassword: (password: string) => void, reason: number) => {
|
||||
const message =
|
||||
reason === pdfjs.PasswordResponses.INCORRECT_PASSWORD
|
||||
? "Incorrect password. Please try again:"
|
||||
: "This PDF is password protected. Enter its password:";
|
||||
const password = window.prompt(message);
|
||||
if (password === null) {
|
||||
cancelled = true;
|
||||
void task.destroy();
|
||||
return;
|
||||
}
|
||||
updatePassword(password);
|
||||
};
|
||||
let doc: import("pdfjs-dist").PDFDocumentProxy;
|
||||
try {
|
||||
doc = await task.promise;
|
||||
} catch (error) {
|
||||
if (cancelled) throw new Error("Import cancelled: password required");
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
if (doc.numPages < 1) throw new Error("This PDF contains no pages");
|
||||
const pages: RasterizedPdfPage[] = [];
|
||||
for (let index = 1; index <= doc.numPages; index++) {
|
||||
pages.push(await rasterizePage(doc, index));
|
||||
onProgress?.(index, doc.numPages);
|
||||
}
|
||||
return pages;
|
||||
} finally {
|
||||
void task.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export async function importPdfFile(
|
||||
file: File,
|
||||
onProgress?: (done: number, total: number) => void,
|
||||
): Promise<string> {
|
||||
const rasterized = await rasterizePdf(file, onProgress);
|
||||
const pages: Page[] = rasterized.map((raster) => {
|
||||
const page = createPage("#ffffff", "blank");
|
||||
page.images = [
|
||||
{
|
||||
id: newId(),
|
||||
imageId: raster.imageId,
|
||||
...placeImageCentered(raster.naturalWidth, raster.naturalHeight),
|
||||
locked: true,
|
||||
},
|
||||
];
|
||||
return page;
|
||||
});
|
||||
const title = file.name.replace(/\.pdf$/i, "").trim() || "Imported PDF";
|
||||
const meta = await createNotebook(title);
|
||||
try {
|
||||
await saveRasterizedImages(rasterized);
|
||||
await replacePages(meta.id, pages);
|
||||
} catch (error) {
|
||||
await deleteNotebook(meta.id);
|
||||
await deleteImages(rasterized.map((raster) => raster.imageId));
|
||||
throw error;
|
||||
}
|
||||
return meta.id;
|
||||
}
|
||||
|
||||
async function rasterizePage(
|
||||
doc: import("pdfjs-dist").PDFDocumentProxy,
|
||||
index: number,
|
||||
): Promise<RasterizedPdfPage> {
|
||||
const pdfPage = await doc.getPage(index);
|
||||
const base = pdfPage.getViewport({ scale: 1 });
|
||||
const displayWidth = placeImageCentered(base.width, base.height).width;
|
||||
const viewport = pdfPage.getViewport({ scale: (displayWidth * PDF_RENDER_SCALE) / base.width });
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.max(1, Math.ceil(viewport.width));
|
||||
canvas.height = Math.max(1, Math.ceil(viewport.height));
|
||||
await pdfPage.render({ canvas, viewport }).promise;
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, "image/jpeg", JPEG_QUALITY),
|
||||
);
|
||||
if (!blob) throw new Error(`Failed to rasterize page ${index}`);
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
return {
|
||||
imageId: newId(),
|
||||
mimeType: "image/jpeg",
|
||||
blob,
|
||||
naturalWidth: base.width,
|
||||
naturalHeight: base.height,
|
||||
};
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { decodeBlob, primeImage } from "../engine/imageCache";
|
||||
import { newId } from "../model/stroke";
|
||||
import { useBoardStore } from "../store/useBoardStore";
|
||||
import { saveImage } from "./images";
|
||||
|
||||
export async function insertImageFile(file: File): Promise<void> {
|
||||
if (!file.type.startsWith("image/")) throw new Error("Not an image file");
|
||||
const imageId = newId();
|
||||
const decoded = await decodeBlob(file);
|
||||
await saveImage({
|
||||
id: imageId,
|
||||
mimeType: file.type || "application/octet-stream",
|
||||
blob: file,
|
||||
});
|
||||
primeImage(imageId, decoded);
|
||||
useBoardStore.getState().insertImage(imageId, decoded.naturalWidth, decoded.naturalHeight);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { clonePageWithNewIds, createPage, type Page } from "../model/page";
|
||||
import { newId } from "../model/stroke";
|
||||
import type { ViewState } from "../model/viewState";
|
||||
import { db, type NotebookRecord } from "./db";
|
||||
|
||||
const DEFAULT_PAPER_COLOR = "#ffffff";
|
||||
|
||||
export async function listNotebooks(): Promise<NotebookRecord[]> {
|
||||
const all = await (await db()).getAll("notebooks");
|
||||
return all.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
}
|
||||
|
||||
export async function createNotebook(title: string): Promise<NotebookRecord> {
|
||||
const now = Date.now();
|
||||
const meta: NotebookRecord = { id: newId(), title, createdAt: now, updatedAt: now, pageCount: 1 };
|
||||
const tx = (await db()).transaction(["notebooks", "pages"], "readwrite");
|
||||
await tx.objectStore("notebooks").put(meta);
|
||||
await tx.objectStore("pages").put(toPageRecord(meta.id, 0, createPage(DEFAULT_PAPER_COLOR)));
|
||||
await tx.done;
|
||||
return meta;
|
||||
}
|
||||
|
||||
function toPageRecord(notebookId: string, index: number, page: Page) {
|
||||
return {
|
||||
id: page.id,
|
||||
notebookId,
|
||||
index,
|
||||
paperColor: page.paperColor,
|
||||
pattern: page.pattern,
|
||||
strokes: page.strokes,
|
||||
images: page.images,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadNotebook(id: string): Promise<{ meta: NotebookRecord; pages: Page[] }> {
|
||||
const database = await db();
|
||||
const meta = await database.get("notebooks", id);
|
||||
if (!meta) throw new Error("Notebook not found");
|
||||
const records = await database.getAllFromIndex("pages", "by-notebook", id);
|
||||
records.sort((a, b) => a.index - b.index);
|
||||
const pages = records.map((record) => ({
|
||||
id: record.id,
|
||||
paperColor: record.paperColor,
|
||||
pattern: record.pattern ?? "blank",
|
||||
strokes: record.strokes,
|
||||
images: record.images ?? [],
|
||||
}));
|
||||
if (pages.length === 0) pages.push(createPage(DEFAULT_PAPER_COLOR));
|
||||
return { meta, pages };
|
||||
}
|
||||
|
||||
export async function savePage(notebookId: string, index: number, page: Page): Promise<void> {
|
||||
const tx = (await db()).transaction(["notebooks", "pages"], "readwrite");
|
||||
await tx.objectStore("pages").put(toPageRecord(notebookId, index, page));
|
||||
const meta = await tx.objectStore("notebooks").get(notebookId);
|
||||
if (meta) {
|
||||
await tx.objectStore("notebooks").put({
|
||||
...meta,
|
||||
updatedAt: Date.now(),
|
||||
pageCount: Math.max(meta.pageCount, index + 1),
|
||||
});
|
||||
}
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
export async function replacePages(notebookId: string, pages: Page[]): Promise<void> {
|
||||
const tx = (await db()).transaction(["notebooks", "pages"], "readwrite");
|
||||
const store = tx.objectStore("pages");
|
||||
const keys = await store.index("by-notebook").getAllKeys(notebookId);
|
||||
for (const key of keys) await store.delete(key);
|
||||
for (const [index, page] of pages.entries()) {
|
||||
await store.put(toPageRecord(notebookId, index, page));
|
||||
}
|
||||
const meta = await tx.objectStore("notebooks").get(notebookId);
|
||||
if (meta) {
|
||||
await tx
|
||||
.objectStore("notebooks")
|
||||
.put({ ...meta, updatedAt: Date.now(), pageCount: pages.length });
|
||||
}
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
export async function renameNotebook(id: string, title: string): Promise<void> {
|
||||
const tx = (await db()).transaction("notebooks", "readwrite");
|
||||
const meta = await tx.store.get(id);
|
||||
if (meta) await tx.store.put({ ...meta, title, updatedAt: Date.now() });
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
export async function saveViewState(id: string, viewState: ViewState): Promise<void> {
|
||||
const tx = (await db()).transaction("notebooks", "readwrite");
|
||||
const meta = await tx.store.get(id);
|
||||
if (meta) await tx.store.put({ ...meta, viewState });
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
export async function mergeNotebooks(ids: string[], title: string): Promise<string> {
|
||||
if (ids.length === 0) throw new Error("No notebooks selected");
|
||||
const sources: Page[] = [];
|
||||
for (const id of ids) {
|
||||
const { pages } = await loadNotebook(id);
|
||||
sources.push(...pages);
|
||||
}
|
||||
const meta = await createNotebook(title);
|
||||
try {
|
||||
await replacePages(meta.id, sources.map(clonePageWithNewIds));
|
||||
} catch (error) {
|
||||
await deleteNotebook(meta.id);
|
||||
throw error;
|
||||
}
|
||||
return meta.id;
|
||||
}
|
||||
|
||||
export async function deleteNotebook(id: string): Promise<void> {
|
||||
const tx = (await db()).transaction(["notebooks", "pages"], "readwrite");
|
||||
await tx.objectStore("notebooks").delete(id);
|
||||
const keys = await tx.objectStore("pages").index("by-notebook").getAllKeys(id);
|
||||
for (const key of keys) await tx.objectStore("pages").delete(key);
|
||||
await tx.done;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseToolPrefs } from "./prefs";
|
||||
|
||||
describe("parseToolPrefs", () => {
|
||||
it("returns an empty object for missing or invalid input", () => {
|
||||
expect(parseToolPrefs(null)).toEqual({});
|
||||
expect(parseToolPrefs(undefined)).toEqual({});
|
||||
expect(parseToolPrefs("garbage")).toEqual({});
|
||||
expect(parseToolPrefs(42)).toEqual({});
|
||||
});
|
||||
|
||||
it("omits keys that are absent instead of setting them to undefined", () => {
|
||||
const parsed = parseToolPrefs({});
|
||||
expect(parsed).toEqual({});
|
||||
expect("color" in parsed).toBe(false);
|
||||
expect("size" in parsed).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps only the valid entries from a partial record", () => {
|
||||
const parsed = parseToolPrefs({ color: "#ff0000", size: "big", tool: "fountain" });
|
||||
expect(parsed).toEqual({ color: "#ff0000" });
|
||||
});
|
||||
|
||||
it("parses a full valid record", () => {
|
||||
const parsed = parseToolPrefs({
|
||||
tool: "highlighter",
|
||||
color: "#2F6FDD",
|
||||
size: 5,
|
||||
paperColor: "#003423",
|
||||
pattern: "grid",
|
||||
sidebarOpen: true,
|
||||
});
|
||||
expect(parsed).toEqual({
|
||||
tool: "highlighter",
|
||||
color: "#2f6fdd",
|
||||
size: 5,
|
||||
paperColor: "#003423",
|
||||
pattern: "grid",
|
||||
sidebarOpen: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("migrates the legacy pen field", () => {
|
||||
expect(parseToolPrefs({ pen: "highlighter" })).toEqual({ tool: "highlighter" });
|
||||
expect(parseToolPrefs({ pen: "eraser" })).toEqual({});
|
||||
});
|
||||
|
||||
it("rejects absurd or non-finite sizes", () => {
|
||||
expect(parseToolPrefs({ size: 500 })).toEqual({});
|
||||
expect(parseToolPrefs({ size: Infinity })).toEqual({});
|
||||
expect(parseToolPrefs({ size: -2 })).toEqual({});
|
||||
expect(parseToolPrefs({ size: 2.5 })).toEqual({ size: 2.5 });
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import { normalizeHex } from "../model/color";
|
||||
import { PAGE_PATTERNS, type PagePattern } from "../model/page";
|
||||
import { TOOL_KINDS, type ToolKind } from "../model/stroke";
|
||||
import { useBoardStore } from "../store/useBoardStore";
|
||||
|
||||
const PREFS_KEY = "vas.toolPrefs";
|
||||
|
||||
interface ToolPrefs {
|
||||
tool?: ToolKind;
|
||||
color?: string;
|
||||
size?: number;
|
||||
paperColor?: string;
|
||||
pattern?: PagePattern;
|
||||
sidebarOpen?: boolean;
|
||||
}
|
||||
|
||||
export function loadToolPrefs(): ToolPrefs {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(localStorage.getItem(PREFS_KEY) ?? "{}");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
return parseToolPrefs(raw);
|
||||
}
|
||||
|
||||
export function parseToolPrefs(raw: unknown): ToolPrefs {
|
||||
if (typeof raw !== "object" || raw === null) return {};
|
||||
const prefs = raw as Record<string, unknown>;
|
||||
const out: ToolPrefs = {};
|
||||
if (TOOL_KINDS.includes(prefs.tool as ToolKind)) out.tool = prefs.tool as ToolKind;
|
||||
if (prefs.tool === undefined && (prefs.pen === "pen" || prefs.pen === "highlighter")) {
|
||||
out.tool = prefs.pen;
|
||||
}
|
||||
const color = typeof prefs.color === "string" ? normalizeHex(prefs.color) : null;
|
||||
if (color) out.color = color;
|
||||
if (
|
||||
typeof prefs.size === "number" &&
|
||||
Number.isFinite(prefs.size) &&
|
||||
prefs.size > 0 &&
|
||||
prefs.size <= 48
|
||||
) {
|
||||
out.size = prefs.size;
|
||||
}
|
||||
const paperColor = typeof prefs.paperColor === "string" ? normalizeHex(prefs.paperColor) : null;
|
||||
if (paperColor) out.paperColor = paperColor;
|
||||
if (PAGE_PATTERNS.includes(prefs.pattern as PagePattern)) {
|
||||
out.pattern = prefs.pattern as PagePattern;
|
||||
}
|
||||
if (typeof prefs.sidebarOpen === "boolean") out.sidebarOpen = prefs.sidebarOpen;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function startPrefsSync(): () => void {
|
||||
let timer: number | undefined;
|
||||
const unsubscribe = useBoardStore.subscribe((state, prev) => {
|
||||
if (
|
||||
state.tool === prev.tool &&
|
||||
state.color === prev.color &&
|
||||
state.size === prev.size &&
|
||||
state.paperColor === prev.paperColor &&
|
||||
state.pattern === prev.pattern &&
|
||||
state.sidebarOpen === prev.sidebarOpen
|
||||
) {
|
||||
return;
|
||||
}
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
const { tool, color, size, paperColor, pattern, sidebarOpen } = useBoardStore.getState();
|
||||
try {
|
||||
localStorage.setItem(
|
||||
PREFS_KEY,
|
||||
JSON.stringify({ tool, color, size, paperColor, pattern, sidebarOpen }),
|
||||
);
|
||||
} catch {
|
||||
// storage may be unavailable
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
unsubscribe();
|
||||
};
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { decodeBlob } from "../engine/imageCache";
|
||||
|
||||
const RASTER_SCALE = 3;
|
||||
|
||||
export async function rasterizeToPng(blob: Blob): Promise<Uint8Array | null> {
|
||||
try {
|
||||
const image = await decodeBlob(blob);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.max(1, Math.round((image.naturalWidth || 300) * RASTER_SCALE));
|
||||
canvas.height = Math.max(1, Math.round((image.naturalHeight || 150) * RASTER_SCALE));
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
const png = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
|
||||
if (!png) return null;
|
||||
return new Uint8Array(await png.arrayBuffer());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { ViewState } from "../model/viewState";
|
||||
import { useBoardStore } from "../store/useBoardStore";
|
||||
import { gcUnreferencedImages } from "./images";
|
||||
import { loadNotebook, saveViewState } from "./notebooks";
|
||||
|
||||
const LAST_NOTEBOOK_KEY = "vas.lastNotebookId";
|
||||
const VIEW_STATE_SAVE_DELAY_MS = 400;
|
||||
|
||||
let pendingViewState: { id: string; viewState: ViewState } | null = null;
|
||||
let viewStateTimer: number | undefined;
|
||||
|
||||
export function scheduleViewStateSave(id: string, viewState: ViewState): void {
|
||||
pendingViewState = { id, viewState };
|
||||
window.clearTimeout(viewStateTimer);
|
||||
viewStateTimer = window.setTimeout(() => void flushViewStateSave(), VIEW_STATE_SAVE_DELAY_MS);
|
||||
}
|
||||
|
||||
export async function flushViewStateSave(): Promise<void> {
|
||||
window.clearTimeout(viewStateTimer);
|
||||
viewStateTimer = undefined;
|
||||
const pending = pendingViewState;
|
||||
pendingViewState = null;
|
||||
if (!pending) return;
|
||||
try {
|
||||
await saveViewState(pending.id, pending.viewState);
|
||||
} catch (error) {
|
||||
console.error("Failed to save view state", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function openNotebook(id: string): Promise<void> {
|
||||
await flushViewStateSave();
|
||||
const { meta, pages } = await loadNotebook(id);
|
||||
useBoardStore
|
||||
.getState()
|
||||
.loadDocument({ id: meta.id, title: meta.title, pages, viewState: meta.viewState });
|
||||
void gcUnreferencedImages().catch((error) => console.error("Image GC failed", error));
|
||||
try {
|
||||
localStorage.setItem(LAST_NOTEBOOK_KEY, id);
|
||||
} catch {
|
||||
// storage may be unavailable
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeNotebook(): Promise<void> {
|
||||
await flushViewStateSave();
|
||||
useBoardStore.getState().unloadDocument();
|
||||
try {
|
||||
localStorage.removeItem(LAST_NOTEBOOK_KEY);
|
||||
} catch {
|
||||
// storage may be unavailable
|
||||
}
|
||||
}
|
||||
|
||||
export function readLastNotebookId(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(LAST_NOTEBOOK_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
import { strFromU8, unzipSync } from "fflate";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Page } from "../model/page";
|
||||
import {
|
||||
buildNotebookZip,
|
||||
imageEntryPath,
|
||||
NOTEBOOK_JSON_ENTRY,
|
||||
parseNotebookFile,
|
||||
resolveImageEntries,
|
||||
serializeNotebook,
|
||||
} from "./transfer";
|
||||
|
||||
function samplePage(): Page {
|
||||
return {
|
||||
id: "page-1",
|
||||
paperColor: "#003423",
|
||||
pattern: "grid",
|
||||
images: [],
|
||||
strokes: [
|
||||
{
|
||||
id: "stroke-1",
|
||||
pen: "highlighter",
|
||||
color: "#f2b134",
|
||||
size: 9,
|
||||
simulatePressure: true,
|
||||
points: [
|
||||
{ x: 1, y: 2, pressure: 0.4 },
|
||||
{ x: 30, y: 40, pressure: 0.8 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "stroke-2",
|
||||
pen: "pen",
|
||||
color: "#2f6fdd",
|
||||
size: 3,
|
||||
simulatePressure: false,
|
||||
shape: "arrow",
|
||||
points: [
|
||||
{ x: 10, y: 10, pressure: 0.5 },
|
||||
{ x: 100, y: 80, pressure: 0.5 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("serializeNotebook / parseNotebookFile", () => {
|
||||
it("round-trips a notebook preserving content", () => {
|
||||
const text = serializeNotebook("My notes", [samplePage()]);
|
||||
const parsed = parseNotebookFile(text);
|
||||
expect(parsed.title).toBe("My notes");
|
||||
expect(parsed.pages).toHaveLength(1);
|
||||
const page = parsed.pages[0];
|
||||
expect(page.paperColor).toBe("#003423");
|
||||
expect(page.pattern).toBe("grid");
|
||||
expect(page.strokes).toHaveLength(2);
|
||||
const stroke = page.strokes[0];
|
||||
expect(stroke.pen).toBe("highlighter");
|
||||
expect(stroke.color).toBe("#f2b134");
|
||||
expect(stroke.size).toBe(9);
|
||||
expect(stroke.simulatePressure).toBe(true);
|
||||
expect(stroke.points).toEqual([
|
||||
{ x: 1, y: 2, pressure: 0.4 },
|
||||
{ x: 30, y: 40, pressure: 0.8 },
|
||||
]);
|
||||
const shape = page.strokes[1];
|
||||
expect(shape.shape).toBe("arrow");
|
||||
expect(shape.points).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("regenerates ids on import", () => {
|
||||
const text = serializeNotebook("My notes", [samplePage()]);
|
||||
const parsed = parseNotebookFile(text);
|
||||
expect(parsed.pages[0].id).not.toBe("page-1");
|
||||
expect(parsed.pages[0].strokes[0].id).not.toBe("stroke-1");
|
||||
const second = parseNotebookFile(text);
|
||||
expect(second.pages[0].id).not.toBe(parsed.pages[0].id);
|
||||
});
|
||||
|
||||
it("rejects non-JSON input", () => {
|
||||
expect(() => parseNotebookFile("not json")).toThrow();
|
||||
});
|
||||
|
||||
it("rejects a foreign file format", () => {
|
||||
expect(() => parseNotebookFile(JSON.stringify({ format: "other", version: 1 }))).toThrow(
|
||||
"Not a vas notebook file",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an unsupported version", () => {
|
||||
expect(() =>
|
||||
parseNotebookFile(
|
||||
JSON.stringify({ format: "vas-notebook", version: 99, pages: [{ strokes: [] }] }),
|
||||
),
|
||||
).toThrow("Unsupported file version");
|
||||
});
|
||||
|
||||
it("rejects a file without pages", () => {
|
||||
expect(() =>
|
||||
parseNotebookFile(JSON.stringify({ format: "vas-notebook", version: 1, pages: [] })),
|
||||
).toThrow("no pages");
|
||||
});
|
||||
|
||||
it("applies defaults for missing optional fields", () => {
|
||||
const text = JSON.stringify({
|
||||
format: "vas-notebook",
|
||||
version: 1,
|
||||
pages: [{ strokes: [{ points: [{ x: 1, y: 2 }] }] }],
|
||||
});
|
||||
const parsed = parseNotebookFile(text);
|
||||
expect(parsed.title).toBe("Imported notebook");
|
||||
const stroke = parsed.pages[0].strokes[0];
|
||||
expect(parsed.pages[0].paperColor).toBe("#ffffff");
|
||||
expect(parsed.pages[0].pattern).toBe("blank");
|
||||
expect(stroke.pen).toBe("pen");
|
||||
expect(stroke.color).toBe("#1a1a1a");
|
||||
expect(stroke.size).toBe(5);
|
||||
expect(stroke.simulatePressure).toBe(false);
|
||||
expect(stroke.points[0].pressure).toBe(0.5);
|
||||
});
|
||||
|
||||
it("rejects a stroke with invalid points", () => {
|
||||
const text = JSON.stringify({
|
||||
format: "vas-notebook",
|
||||
version: 1,
|
||||
pages: [{ strokes: [{ points: [{ x: "1", y: 2 }] }] }],
|
||||
});
|
||||
expect(() => parseNotebookFile(text)).toThrow("Invalid point");
|
||||
});
|
||||
|
||||
it("rejects non-finite point coordinates", () => {
|
||||
const text = `{
|
||||
"format": "vas-notebook",
|
||||
"version": 1,
|
||||
"pages": [{ "strokes": [{ "points": [{ "x": 1e999, "y": 2 }] }] }]
|
||||
}`;
|
||||
expect(() => parseNotebookFile(text)).toThrow("Invalid point");
|
||||
});
|
||||
|
||||
it("falls back to the default size for non-finite stroke size", () => {
|
||||
const text = `{
|
||||
"format": "vas-notebook",
|
||||
"version": 1,
|
||||
"pages": [{ "strokes": [{ "size": 1e999, "points": [{ "x": 1, "y": 2 }] }] }]
|
||||
}`;
|
||||
const parsed = parseNotebookFile(text);
|
||||
expect(parsed.pages[0].strokes[0].size).toBe(5);
|
||||
});
|
||||
|
||||
it("round-trips the view state when present", () => {
|
||||
const text = serializeNotebook("Views", [samplePage()], [], { x: 12, y: 400, zoom: 2.5 });
|
||||
expect(parseNotebookFile(text).viewState).toEqual({ x: 12, y: 400, zoom: 2.5 });
|
||||
});
|
||||
|
||||
it("omits the view state when absent", () => {
|
||||
expect(parseNotebookFile(serializeNotebook("Views", [samplePage()])).viewState).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects an invalid view state", () => {
|
||||
const text = JSON.stringify({
|
||||
format: "vas-notebook",
|
||||
version: 2,
|
||||
viewState: { x: 0, y: 0, zoom: -1 },
|
||||
pages: [{ strokes: [] }],
|
||||
});
|
||||
expect(() => parseNotebookFile(text)).toThrow("Invalid view state");
|
||||
});
|
||||
});
|
||||
|
||||
describe("notebook images and zip packaging", () => {
|
||||
function imagedPage(): Page {
|
||||
return {
|
||||
...samplePage(),
|
||||
images: [
|
||||
{ id: "item-1", imageId: "blob-1", x: 40, y: 40, width: 200, height: 100, locked: true },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
it("round-trips pages with images and remaps the image ids", () => {
|
||||
const text = serializeNotebook(
|
||||
"With images",
|
||||
[imagedPage()],
|
||||
[{ imageId: "blob-1", mimeType: "image/png" }],
|
||||
);
|
||||
const parsed = parseNotebookFile(text);
|
||||
expect(parsed.images).toHaveLength(1);
|
||||
const entry = parsed.images[0];
|
||||
expect(entry.sourceId).toBe("blob-1");
|
||||
expect(entry.imageId).not.toBe("blob-1");
|
||||
expect(entry.mimeType).toBe("image/png");
|
||||
const item = parsed.pages[0].images[0];
|
||||
expect(item.imageId).toBe(entry.imageId);
|
||||
expect(item.id).not.toBe("item-1");
|
||||
expect(item).toMatchObject({ x: 40, y: 40, width: 200, height: 100, locked: true });
|
||||
});
|
||||
|
||||
it("rejects a page referencing an image missing from the manifest", () => {
|
||||
const text = JSON.stringify({
|
||||
format: "vas-notebook",
|
||||
version: 2,
|
||||
pages: [{ strokes: [], images: [{ imageId: "ghost", x: 0, y: 0, width: 10, height: 10 }] }],
|
||||
});
|
||||
expect(() => parseNotebookFile(text)).toThrow("unknown image");
|
||||
});
|
||||
|
||||
it("rejects invalid image geometry", () => {
|
||||
const text = JSON.stringify({
|
||||
format: "vas-notebook",
|
||||
version: 2,
|
||||
images: [{ imageId: "blob-1", mimeType: "image/png" }],
|
||||
pages: [{ strokes: [], images: [{ imageId: "blob-1", x: 0, y: 0, width: -5, height: 10 }] }],
|
||||
});
|
||||
expect(() => parseNotebookFile(text)).toThrow("Invalid image width");
|
||||
});
|
||||
|
||||
it("builds a zip that parses back with image bytes resolved", () => {
|
||||
const json = serializeNotebook(
|
||||
"Zip",
|
||||
[imagedPage()],
|
||||
[{ imageId: "blob-1", mimeType: "image/png" }],
|
||||
);
|
||||
const pngBytes = new Uint8Array([1, 2, 3, 4]);
|
||||
const zip = buildNotebookZip(json, [
|
||||
{ path: imageEntryPath("blob-1", "image/png"), data: pngBytes },
|
||||
]);
|
||||
expect(zip[0]).toBe(0x50);
|
||||
expect(zip[1]).toBe(0x4b);
|
||||
const entries = unzipSync(zip);
|
||||
const parsed = parseNotebookFile(strFromU8(entries[NOTEBOOK_JSON_ENTRY]));
|
||||
const resolved = resolveImageEntries(entries, parsed.images);
|
||||
expect([...resolved[0]]).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it("resolveImageEntries throws when an image file is missing", () => {
|
||||
const json = serializeNotebook(
|
||||
"Zip",
|
||||
[imagedPage()],
|
||||
[{ imageId: "blob-1", mimeType: "image/png" }],
|
||||
);
|
||||
const zip = buildNotebookZip(json, []);
|
||||
const entries = unzipSync(zip);
|
||||
const parsed = parseNotebookFile(strFromU8(entries[NOTEBOOK_JSON_ENTRY]));
|
||||
expect(() => resolveImageEntries(entries, parsed.images)).toThrow("Missing image data");
|
||||
});
|
||||
});
|
||||
@@ -1,356 +0,0 @@
|
||||
import { strFromU8, strToU8, unzipSync, zipSync } from "fflate";
|
||||
import { normalizeHex } from "../model/color";
|
||||
import { type ImageItem, imageExtension } from "../model/image";
|
||||
import { PAGE_PATTERNS, type Page, type PagePattern } from "../model/page";
|
||||
import {
|
||||
newId,
|
||||
type PenKind,
|
||||
SHAPE_KINDS,
|
||||
type ShapeKind,
|
||||
type Stroke,
|
||||
type StrokePoint,
|
||||
} from "../model/stroke";
|
||||
import type { ViewState } from "../model/viewState";
|
||||
import type { ImageRecord } from "./db";
|
||||
import { deleteImages, getImage, saveImages } from "./images";
|
||||
import {
|
||||
createNotebook,
|
||||
deleteNotebook,
|
||||
loadNotebook,
|
||||
replacePages,
|
||||
saveViewState,
|
||||
} from "./notebooks";
|
||||
|
||||
export const FILE_FORMAT = "vas-notebook";
|
||||
export const FILE_VERSION = 2;
|
||||
export const NOTEBOOK_JSON_ENTRY = "notebook.json";
|
||||
|
||||
const FALLBACK_INK = "#1a1a1a";
|
||||
const FALLBACK_PAPER = "#ffffff";
|
||||
const FALLBACK_SIZE = 5;
|
||||
|
||||
export interface ImageManifestEntry {
|
||||
imageId: string;
|
||||
mimeType: string;
|
||||
sourceId?: string;
|
||||
}
|
||||
|
||||
export function serializeNotebook(
|
||||
title: string,
|
||||
pages: Page[],
|
||||
images: ImageManifestEntry[] = [],
|
||||
viewState?: ViewState,
|
||||
): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
format: FILE_FORMAT,
|
||||
version: FILE_VERSION,
|
||||
title,
|
||||
...(viewState ? { viewState } : {}),
|
||||
pages: pages.map((page) => ({
|
||||
paperColor: page.paperColor,
|
||||
pattern: page.pattern,
|
||||
strokes: page.strokes.map((stroke) => ({
|
||||
pen: stroke.pen,
|
||||
color: stroke.color,
|
||||
size: stroke.size,
|
||||
simulatePressure: stroke.simulatePressure,
|
||||
shape: stroke.shape,
|
||||
points: stroke.points,
|
||||
})),
|
||||
images: page.images.map((image) => ({
|
||||
imageId: image.imageId,
|
||||
x: image.x,
|
||||
y: image.y,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
...(image.locked ? { locked: true } : {}),
|
||||
})),
|
||||
})),
|
||||
...(images.length > 0
|
||||
? { images: images.map((e) => ({ imageId: e.imageId, mimeType: e.mimeType })) }
|
||||
: {}),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
export function parseNotebookFile(text: string): {
|
||||
title: string;
|
||||
pages: Page[];
|
||||
images: Required<ImageManifestEntry>[];
|
||||
viewState?: ViewState;
|
||||
} {
|
||||
const data: unknown = JSON.parse(text);
|
||||
if (!isRecord(data) || data.format !== FILE_FORMAT) {
|
||||
throw new Error("Not a vas notebook file");
|
||||
}
|
||||
if (data.version !== 1 && data.version !== FILE_VERSION) {
|
||||
throw new Error(`Unsupported file version: ${String(data.version)}`);
|
||||
}
|
||||
const title =
|
||||
typeof data.title === "string" && data.title.trim().length > 0
|
||||
? data.title.trim()
|
||||
: "Imported notebook";
|
||||
if (!Array.isArray(data.pages) || data.pages.length === 0) {
|
||||
throw new Error("File contains no pages");
|
||||
}
|
||||
const images = parseImageManifest(data);
|
||||
const remap = new Map(images.map((entry) => [entry.sourceId, entry.imageId]));
|
||||
return {
|
||||
title,
|
||||
pages: data.pages.map((page) => parsePage(page, remap)),
|
||||
images,
|
||||
viewState: parseViewState(data.viewState),
|
||||
};
|
||||
}
|
||||
|
||||
function parseViewState(raw: unknown): ViewState | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
if (!isRecord(raw)) throw new Error("Invalid view state");
|
||||
if (
|
||||
typeof raw.x !== "number" ||
|
||||
!Number.isFinite(raw.x) ||
|
||||
typeof raw.y !== "number" ||
|
||||
!Number.isFinite(raw.y) ||
|
||||
typeof raw.zoom !== "number" ||
|
||||
!Number.isFinite(raw.zoom) ||
|
||||
raw.zoom <= 0
|
||||
) {
|
||||
throw new Error("Invalid view state");
|
||||
}
|
||||
return { x: raw.x, y: raw.y, zoom: raw.zoom };
|
||||
}
|
||||
|
||||
export function buildNotebookZip(
|
||||
json: string,
|
||||
files: { path: string; data: Uint8Array }[],
|
||||
): Uint8Array {
|
||||
const entries: Record<string, Uint8Array> = { [NOTEBOOK_JSON_ENTRY]: strToU8(json) };
|
||||
for (const file of files) entries[file.path] = file.data;
|
||||
return zipSync(entries);
|
||||
}
|
||||
|
||||
export function imageEntryPath(imageId: string, mimeType: string): string {
|
||||
return `images/${imageId}.${imageExtension(mimeType)}`;
|
||||
}
|
||||
|
||||
export function resolveImageEntries(
|
||||
entries: Record<string, Uint8Array>,
|
||||
manifest: Required<ImageManifestEntry>[],
|
||||
): Uint8Array[] {
|
||||
return manifest.map((entry) => {
|
||||
const data = entries[imageEntryPath(entry.sourceId, entry.mimeType)];
|
||||
if (!data) throw new Error(`Missing image data for ${entry.sourceId}`);
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadNotebook(id: string): Promise<void> {
|
||||
const { meta, pages } = await loadNotebook(id);
|
||||
const referenced = new Set<string>();
|
||||
for (const page of pages) {
|
||||
for (const image of page.images) referenced.add(image.imageId);
|
||||
}
|
||||
if (referenced.size === 0) {
|
||||
const blob = new Blob([serializeNotebook(meta.title, pages, [], meta.viewState)], {
|
||||
type: "application/json",
|
||||
});
|
||||
downloadBlob(blob, `${meta.title}.vas.json`);
|
||||
return;
|
||||
}
|
||||
const records = new Map<string, ImageRecord>();
|
||||
for (const imageId of referenced) {
|
||||
const record = await getImage(imageId);
|
||||
if (record) records.set(imageId, record);
|
||||
}
|
||||
const cleanPages = pages.map((page) => ({
|
||||
...page,
|
||||
images: page.images.filter((image) => records.has(image.imageId)),
|
||||
}));
|
||||
const manifest: ImageManifestEntry[] = [...records.values()].map((record) => ({
|
||||
imageId: record.id,
|
||||
mimeType: record.mimeType,
|
||||
}));
|
||||
const json = serializeNotebook(meta.title, cleanPages, manifest, meta.viewState);
|
||||
const files: { path: string; data: Uint8Array }[] = [];
|
||||
for (const record of records.values()) {
|
||||
files.push({
|
||||
path: imageEntryPath(record.id, record.mimeType),
|
||||
data: new Uint8Array(await record.blob.arrayBuffer()),
|
||||
});
|
||||
}
|
||||
const zip = buildNotebookZip(json, files);
|
||||
downloadBlob(
|
||||
new Blob([zip.buffer as ArrayBuffer], { type: "application/zip" }),
|
||||
`${meta.title}.vas.zip`,
|
||||
);
|
||||
}
|
||||
|
||||
export function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename.replace(/[\\/:*?"<>|]/g, "_");
|
||||
anchor.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
export async function importNotebookFile(file: File): Promise<string> {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
if (bytes[0] === 0x50 && bytes[1] === 0x4b) {
|
||||
return importNotebookZip(bytes);
|
||||
}
|
||||
const parsed = parseNotebookFile(strFromU8(bytes));
|
||||
if (parsed.images.length > 0) {
|
||||
throw new Error("This file references images; import the original .vas.zip archive instead");
|
||||
}
|
||||
const meta = await createNotebook(parsed.title);
|
||||
try {
|
||||
await replacePages(meta.id, parsed.pages);
|
||||
if (parsed.viewState) await saveViewState(meta.id, parsed.viewState);
|
||||
} catch (error) {
|
||||
await deleteNotebook(meta.id);
|
||||
throw error;
|
||||
}
|
||||
return meta.id;
|
||||
}
|
||||
|
||||
async function importNotebookZip(bytes: Uint8Array): Promise<string> {
|
||||
let entries: Record<string, Uint8Array>;
|
||||
try {
|
||||
entries = unzipSync(bytes);
|
||||
} catch {
|
||||
throw new Error("Invalid zip archive");
|
||||
}
|
||||
const jsonEntry = entries[NOTEBOOK_JSON_ENTRY];
|
||||
if (!jsonEntry) throw new Error("Archive contains no notebook.json");
|
||||
const parsed = parseNotebookFile(strFromU8(jsonEntry));
|
||||
const imageData = resolveImageEntries(entries, parsed.images);
|
||||
const records: ImageRecord[] = parsed.images.map((entry, index) => ({
|
||||
id: entry.imageId,
|
||||
mimeType: entry.mimeType,
|
||||
blob: new Blob([imageData[index].buffer as ArrayBuffer], { type: entry.mimeType }),
|
||||
}));
|
||||
const meta = await createNotebook(parsed.title);
|
||||
try {
|
||||
await saveImages(records);
|
||||
await replacePages(meta.id, parsed.pages);
|
||||
if (parsed.viewState) await saveViewState(meta.id, parsed.viewState);
|
||||
} catch (error) {
|
||||
await deleteNotebook(meta.id);
|
||||
await deleteImages(records.map((record) => record.id));
|
||||
throw error;
|
||||
}
|
||||
return meta.id;
|
||||
}
|
||||
|
||||
function parseImageManifest(data: Record<string, unknown>): Required<ImageManifestEntry>[] {
|
||||
if (data.images === undefined) return [];
|
||||
if (!Array.isArray(data.images)) throw new Error("Invalid images manifest");
|
||||
return data.images.map((raw) => {
|
||||
if (!isRecord(raw) || typeof raw.imageId !== "string" || raw.imageId.length === 0) {
|
||||
throw new Error("Invalid image entry");
|
||||
}
|
||||
return {
|
||||
imageId: newId(),
|
||||
mimeType:
|
||||
typeof raw.mimeType === "string" && raw.mimeType.length > 0
|
||||
? raw.mimeType
|
||||
: "application/octet-stream",
|
||||
sourceId: raw.imageId,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parsePage(raw: unknown, remap: Map<string, string>): Page {
|
||||
if (!isRecord(raw)) throw new Error("Invalid page");
|
||||
if (!Array.isArray(raw.strokes)) throw new Error("Invalid page strokes");
|
||||
return {
|
||||
id: newId(),
|
||||
paperColor: parseColor(raw.paperColor, FALLBACK_PAPER),
|
||||
pattern: PAGE_PATTERNS.includes(raw.pattern as PagePattern)
|
||||
? (raw.pattern as PagePattern)
|
||||
: "blank",
|
||||
strokes: raw.strokes.map(parseStroke),
|
||||
images: parsePageImages(raw.images, remap),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePageImages(raw: unknown, remap: Map<string, string>): ImageItem[] {
|
||||
if (raw === undefined) return [];
|
||||
if (!Array.isArray(raw)) throw new Error("Invalid page images");
|
||||
return raw.map((entry) => {
|
||||
if (!isRecord(entry) || typeof entry.imageId !== "string") throw new Error("Invalid image");
|
||||
const imageId = remap.get(entry.imageId);
|
||||
if (!imageId) throw new Error("Page references an unknown image");
|
||||
return {
|
||||
id: newId(),
|
||||
imageId,
|
||||
x: parseFiniteNumber(entry.x, "image x"),
|
||||
y: parseFiniteNumber(entry.y, "image y"),
|
||||
width: parsePositiveNumber(entry.width, "image width"),
|
||||
height: parsePositiveNumber(entry.height, "image height"),
|
||||
...(entry.locked === true ? { locked: true } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseFiniteNumber(raw: unknown, label: string): number {
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) throw new Error(`Invalid ${label}`);
|
||||
return raw;
|
||||
}
|
||||
|
||||
function parsePositiveNumber(raw: unknown, label: string): number {
|
||||
const value = parseFiniteNumber(raw, label);
|
||||
if (value <= 0) throw new Error(`Invalid ${label}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseStroke(raw: unknown): Stroke {
|
||||
if (!isRecord(raw)) throw new Error("Invalid stroke");
|
||||
if (!Array.isArray(raw.points)) throw new Error("Invalid stroke points");
|
||||
const points = raw.points.map(parsePoint);
|
||||
if (points.length === 0) throw new Error("Stroke has no points");
|
||||
const pen: PenKind = raw.pen === "highlighter" ? "highlighter" : "pen";
|
||||
const shape = SHAPE_KINDS.includes(raw.shape as ShapeKind) ? (raw.shape as ShapeKind) : undefined;
|
||||
return {
|
||||
id: newId(),
|
||||
pen,
|
||||
color: parseColor(raw.color, FALLBACK_INK),
|
||||
size:
|
||||
typeof raw.size === "number" && Number.isFinite(raw.size) && raw.size > 0
|
||||
? raw.size
|
||||
: FALLBACK_SIZE,
|
||||
simulatePressure: raw.simulatePressure === true,
|
||||
shape,
|
||||
points,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePoint(raw: unknown): StrokePoint {
|
||||
if (
|
||||
!isRecord(raw) ||
|
||||
typeof raw.x !== "number" ||
|
||||
!Number.isFinite(raw.x) ||
|
||||
typeof raw.y !== "number" ||
|
||||
!Number.isFinite(raw.y)
|
||||
) {
|
||||
throw new Error("Invalid point");
|
||||
}
|
||||
return {
|
||||
x: raw.x,
|
||||
y: raw.y,
|
||||
pressure:
|
||||
typeof raw.pressure === "number" && Number.isFinite(raw.pressure) ? raw.pressure : 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
function parseColor(raw: unknown, fallback: string): string {
|
||||
return typeof raw === "string" ? (normalizeHex(raw) ?? fallback) : fallback;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import { registerSW } from "virtual:pwa-register";
|
||||
|
||||
export function registerServiceWorker(): void {
|
||||
registerSW({ immediate: true });
|
||||
}
|
||||
@@ -1,639 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { ImageItem } from "../model/image";
|
||||
import { createPage } from "../model/page";
|
||||
import type { Stroke } from "../model/stroke";
|
||||
import { useBoardStore } from "./useBoardStore";
|
||||
|
||||
function sampleStroke(id: string): Stroke {
|
||||
return {
|
||||
id,
|
||||
pen: "pen",
|
||||
color: "#1a1a1a",
|
||||
size: 5,
|
||||
simulatePressure: false,
|
||||
points: [
|
||||
{ x: 0, y: 0, pressure: 0.5 },
|
||||
{ x: 12, y: 8, pressure: 0.6 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function sampleImage(id: string): ImageItem {
|
||||
return { id, imageId: "blob-1", x: 100, y: 100, width: 200, height: 100 };
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
useBoardStore.setState({
|
||||
pages: [createPage("#ffffff")],
|
||||
past: [],
|
||||
future: [],
|
||||
viewPageIndex: 0,
|
||||
pendingScrollToPage: null,
|
||||
paperColor: "#ffffff",
|
||||
selection: null,
|
||||
selectionAnchor: null,
|
||||
clipboard: { strokes: [], images: [] },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(reset);
|
||||
|
||||
describe("board store", () => {
|
||||
it("starts with a single blank page", () => {
|
||||
const state = useBoardStore.getState();
|
||||
expect(state.pages).toHaveLength(1);
|
||||
expect(state.pages[0].strokes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("appends a stroke to the target page", () => {
|
||||
const pageId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||
expect(useBoardStore.getState().pages[0].strokes.map((s) => s.id)).toEqual(["s1"]);
|
||||
});
|
||||
|
||||
it("auto-appends a blank page when writing on the last page", () => {
|
||||
const pageId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||
expect(useBoardStore.getState().pages).toHaveLength(2);
|
||||
expect(useBoardStore.getState().pages[1].strokes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not auto-append when writing on an earlier page", () => {
|
||||
const firstId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.getState().addStroke(firstId, sampleStroke("s1"));
|
||||
useBoardStore.getState().addStroke(firstId, sampleStroke("s2"));
|
||||
expect(useBoardStore.getState().pages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("addPage inserts right after the current page", () => {
|
||||
const firstId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.getState().addStroke(firstId, sampleStroke("s1"));
|
||||
useBoardStore.getState().setViewPageIndex(0);
|
||||
useBoardStore.getState().addPage();
|
||||
const pages = useBoardStore.getState().pages;
|
||||
expect(pages).toHaveLength(3);
|
||||
expect(pages[1].strokes).toHaveLength(0);
|
||||
expect(pages[1].paperColor).toBe(useBoardStore.getState().paperColor);
|
||||
expect(useBoardStore.getState().pendingScrollToPage).toBe(1);
|
||||
});
|
||||
|
||||
it("undo removes the last stroke and redo restores it", () => {
|
||||
const pageId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||
useBoardStore.getState().undo();
|
||||
expect(useBoardStore.getState().pages[0].strokes).toHaveLength(0);
|
||||
useBoardStore.getState().redo();
|
||||
expect(useBoardStore.getState().pages[0].strokes.map((s) => s.id)).toEqual(["s1"]);
|
||||
});
|
||||
|
||||
it("clearPage empties the page and undo restores it", () => {
|
||||
const pageId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||
useBoardStore.getState().clearPage(pageId);
|
||||
expect(useBoardStore.getState().pages[0].strokes).toHaveLength(0);
|
||||
useBoardStore.getState().undo();
|
||||
expect(useBoardStore.getState().pages[0].strokes.map((s) => s.id)).toEqual(["s1"]);
|
||||
});
|
||||
|
||||
it("clearPage on an empty page is a no-op", () => {
|
||||
const pageId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.getState().clearPage(pageId);
|
||||
expect(useBoardStore.getState().past).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("a new edit clears the redo stack", () => {
|
||||
const pageId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||
useBoardStore.getState().undo();
|
||||
useBoardStore.getState().addStroke(pageId, sampleStroke("s2"));
|
||||
expect(useBoardStore.getState().future).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("undo and redo on empty stacks are no-ops", () => {
|
||||
useBoardStore.getState().undo();
|
||||
useBoardStore.getState().redo();
|
||||
expect(useBoardStore.getState().pages[0].strokes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("setPaperColor recolors only the current page", () => {
|
||||
useBoardStore.getState().addPage();
|
||||
useBoardStore.getState().setPaperColor("#003423");
|
||||
const pages = useBoardStore.getState().pages;
|
||||
expect(pages[0].paperColor).toBe("#003423");
|
||||
expect(pages[1].paperColor).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("setPaperColor on another page keeps earlier pages untouched", () => {
|
||||
useBoardStore.getState().addPage();
|
||||
useBoardStore.getState().setViewPageIndex(1);
|
||||
useBoardStore.getState().setPaperColor("#003423");
|
||||
const pages = useBoardStore.getState().pages;
|
||||
expect(pages[0].paperColor).toBe("#ffffff");
|
||||
expect(pages[1].paperColor).toBe("#003423");
|
||||
});
|
||||
|
||||
it("a manually added page copies the current page's paper color", () => {
|
||||
useBoardStore.setState({ pages: [createPage("#003423")], paperColor: "#ffffff" });
|
||||
useBoardStore.getState().addPage();
|
||||
expect(useBoardStore.getState().pages[1].paperColor).toBe("#003423");
|
||||
});
|
||||
|
||||
it("an auto-appended page copies the last page's paper color", () => {
|
||||
useBoardStore.setState({ pages: [createPage("#b98a5f")], paperColor: "#ffffff" });
|
||||
const pageId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||
const pages = useBoardStore.getState().pages;
|
||||
expect(pages).toHaveLength(2);
|
||||
expect(pages[1].paperColor).toBe("#b98a5f");
|
||||
});
|
||||
|
||||
it("loadDocument hydrates the notebook and clears history", () => {
|
||||
const pageId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||
useBoardStore.getState().loadDocument({
|
||||
id: "nb-1",
|
||||
title: "Loaded",
|
||||
pages: [createPage("#fbf3db")],
|
||||
});
|
||||
const state = useBoardStore.getState();
|
||||
expect(state.notebookId).toBe("nb-1");
|
||||
expect(state.notebookTitle).toBe("Loaded");
|
||||
expect(state.pages[0].paperColor).toBe("#fbf3db");
|
||||
expect(state.past).toHaveLength(0);
|
||||
expect(state.viewPageIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("unloadDocument returns to a blank notebook", () => {
|
||||
useBoardStore.getState().loadDocument({
|
||||
id: "nb-1",
|
||||
title: "Loaded",
|
||||
pages: [createPage("#fbf3db")],
|
||||
});
|
||||
useBoardStore.getState().unloadDocument();
|
||||
const state = useBoardStore.getState();
|
||||
expect(state.notebookId).toBeNull();
|
||||
expect(state.pages).toHaveLength(1);
|
||||
expect(state.pages[0].strokes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("clearPage leaves other pages untouched", () => {
|
||||
const firstId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.getState().addStroke(firstId, sampleStroke("s1"));
|
||||
const secondId = useBoardStore.getState().pages[1].id;
|
||||
useBoardStore.getState().addStroke(secondId, sampleStroke("s2"));
|
||||
useBoardStore.getState().clearPage(firstId);
|
||||
const pages = useBoardStore.getState().pages;
|
||||
expect(pages[0].strokes).toHaveLength(0);
|
||||
expect(pages[1].strokes.map((s) => s.id)).toEqual(["s2"]);
|
||||
});
|
||||
|
||||
it("clearPage keeps locked images and undo restores the removed content", () => {
|
||||
const pageId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.setState({
|
||||
pages: [
|
||||
{
|
||||
...useBoardStore.getState().pages[0],
|
||||
strokes: [sampleStroke("s1")],
|
||||
images: [sampleImage("i1"), { ...sampleImage("i2"), locked: true }],
|
||||
},
|
||||
],
|
||||
});
|
||||
useBoardStore.getState().clearPage(pageId);
|
||||
let state = useBoardStore.getState();
|
||||
expect(state.pages[0].strokes).toHaveLength(0);
|
||||
expect(state.pages[0].images.map((i) => i.id)).toEqual(["i2"]);
|
||||
useBoardStore.getState().undo();
|
||||
state = useBoardStore.getState();
|
||||
expect(state.pages[0].strokes.map((s) => s.id)).toEqual(["s1"]);
|
||||
expect(state.pages[0].images.map((i) => i.id)).toEqual(["i1", "i2"]);
|
||||
});
|
||||
|
||||
it("clearPage is a no-op on a page with only locked images", () => {
|
||||
const pageId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.setState({
|
||||
pages: [
|
||||
{
|
||||
...useBoardStore.getState().pages[0],
|
||||
images: [{ ...sampleImage("i1"), locked: true }],
|
||||
},
|
||||
],
|
||||
});
|
||||
useBoardStore.getState().clearPage(pageId);
|
||||
expect(useBoardStore.getState().pages[0].images).toHaveLength(1);
|
||||
expect(useBoardStore.getState().past).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("deletePage removes the page and resets history", () => {
|
||||
const firstId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.getState().addStroke(firstId, sampleStroke("s1"));
|
||||
const secondId = useBoardStore.getState().pages[1].id;
|
||||
useBoardStore.getState().deletePage(firstId);
|
||||
const state = useBoardStore.getState();
|
||||
expect(state.pages).toHaveLength(1);
|
||||
expect(state.pages[0].id).toBe(secondId);
|
||||
expect(state.past).toHaveLength(0);
|
||||
expect(state.future).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("deletePage is a no-op on the last remaining page", () => {
|
||||
const onlyId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.getState().deletePage(onlyId);
|
||||
expect(useBoardStore.getState().pages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("deletePage clamps the view page index", () => {
|
||||
useBoardStore.getState().addPage();
|
||||
useBoardStore.getState().setViewPageIndex(1);
|
||||
const secondId = useBoardStore.getState().pages[1].id;
|
||||
useBoardStore.getState().deletePage(secondId);
|
||||
expect(useBoardStore.getState().viewPageIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("removeStroke erases the stroke and undo restores it at its original index", () => {
|
||||
const pageId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||
useBoardStore.getState().addStroke(pageId, sampleStroke("s2"));
|
||||
useBoardStore.getState().removeStroke(pageId, "s1");
|
||||
expect(useBoardStore.getState().pages[0].strokes.map((s) => s.id)).toEqual(["s2"]);
|
||||
useBoardStore.getState().undo();
|
||||
expect(useBoardStore.getState().pages[0].strokes.map((s) => s.id)).toEqual(["s1", "s2"]);
|
||||
useBoardStore.getState().redo();
|
||||
expect(useBoardStore.getState().pages[0].strokes.map((s) => s.id)).toEqual(["s2"]);
|
||||
});
|
||||
|
||||
it("removeStroke with an unknown id is a no-op", () => {
|
||||
const pageId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.getState().removeStroke(pageId, "missing");
|
||||
expect(useBoardStore.getState().past).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("setPattern applies to the current page only", () => {
|
||||
useBoardStore.getState().addPage();
|
||||
useBoardStore.getState().setPattern("grid");
|
||||
const pages = useBoardStore.getState().pages;
|
||||
expect(pages[0].pattern).toBe("grid");
|
||||
expect(pages[1].pattern).toBe("blank");
|
||||
});
|
||||
|
||||
it("a manually added page copies the current page's pattern", () => {
|
||||
useBoardStore.getState().setPattern("lined");
|
||||
useBoardStore.getState().addPage();
|
||||
expect(useBoardStore.getState().pages[1].pattern).toBe("lined");
|
||||
});
|
||||
|
||||
it("an auto-appended page copies the last page's pattern", () => {
|
||||
useBoardStore.getState().setPattern("dots");
|
||||
const pageId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||
expect(useBoardStore.getState().pages[1].pattern).toBe("dots");
|
||||
});
|
||||
|
||||
it("setTool remembers the last pen kind across the eraser", () => {
|
||||
useBoardStore.getState().setTool("highlighter");
|
||||
useBoardStore.getState().setTool("eraser");
|
||||
expect(useBoardStore.getState().lastPenKind).toBe("highlighter");
|
||||
useBoardStore.getState().setTool(useBoardStore.getState().lastPenKind);
|
||||
expect(useBoardStore.getState().tool).toBe("highlighter");
|
||||
});
|
||||
|
||||
it("setTool keeps the pen kind when switching to the laser", () => {
|
||||
useBoardStore.getState().setTool("highlighter");
|
||||
useBoardStore.getState().setTool("laser");
|
||||
expect(useBoardStore.getState().lastPenKind).toBe("highlighter");
|
||||
});
|
||||
|
||||
it("addStroke with an unknown page id is a no-op", () => {
|
||||
useBoardStore.getState().addStroke("missing-page", sampleStroke("s1"));
|
||||
const state = useBoardStore.getState();
|
||||
expect(state.pages[0].strokes).toHaveLength(0);
|
||||
expect(state.past).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selection and clipboard", () => {
|
||||
function setupSelection(ids: string[] = ["s1", "s2"]): string {
|
||||
const pageId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||
useBoardStore.getState().addStroke(pageId, sampleStroke("s2"));
|
||||
useBoardStore.getState().setSelection({ pageId, strokeIds: ids, imageIds: [] });
|
||||
return pageId;
|
||||
}
|
||||
|
||||
it("transformSelection replaces the strokes as a single undoable edit", () => {
|
||||
setupSelection();
|
||||
const before = useBoardStore.getState().pages[0].strokes;
|
||||
const after = before.map((s) => ({
|
||||
...s,
|
||||
points: s.points.map((p) => ({ ...p, x: p.x + 100 })),
|
||||
}));
|
||||
useBoardStore
|
||||
.getState()
|
||||
.transformSelection({ strokes: [...before], images: [] }, { strokes: after, images: [] });
|
||||
expect(useBoardStore.getState().pages[0].strokes[0].points[0].x).toBe(100);
|
||||
expect(useBoardStore.getState().past).toHaveLength(3);
|
||||
useBoardStore.getState().undo();
|
||||
expect(useBoardStore.getState().pages[0].strokes[0].points[0].x).toBe(0);
|
||||
useBoardStore.getState().redo();
|
||||
expect(useBoardStore.getState().pages[0].strokes[0].points[0].x).toBe(100);
|
||||
});
|
||||
|
||||
it("transformSelection without a selection is a no-op", () => {
|
||||
const pageId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||
const before = useBoardStore.getState().past.length;
|
||||
useBoardStore
|
||||
.getState()
|
||||
.transformSelection({ strokes: [], images: [] }, { strokes: [], images: [] });
|
||||
expect(useBoardStore.getState().past).toHaveLength(before);
|
||||
});
|
||||
|
||||
it("recolorSelection recolors only the selected strokes", () => {
|
||||
setupSelection(["s1"]);
|
||||
useBoardStore.getState().recolorSelection("#d64541");
|
||||
const strokes = useBoardStore.getState().pages[0].strokes;
|
||||
expect(strokes[0].color).toBe("#d64541");
|
||||
expect(strokes[1].color).toBe("#1a1a1a");
|
||||
useBoardStore.getState().undo();
|
||||
expect(useBoardStore.getState().pages[0].strokes[0].color).toBe("#1a1a1a");
|
||||
});
|
||||
|
||||
it("recolorSelection with the same color is a no-op", () => {
|
||||
setupSelection(["s1"]);
|
||||
const past = useBoardStore.getState().past.length;
|
||||
useBoardStore.getState().recolorSelection("#1a1a1a");
|
||||
expect(useBoardStore.getState().past).toHaveLength(past);
|
||||
});
|
||||
|
||||
it("deleteSelection removes the strokes and undo restores their order", () => {
|
||||
const pageId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||
useBoardStore.getState().addStroke(pageId, sampleStroke("s2"));
|
||||
useBoardStore.getState().addStroke(pageId, sampleStroke("s3"));
|
||||
useBoardStore.getState().setSelection({ pageId, strokeIds: ["s1", "s3"], imageIds: [] });
|
||||
useBoardStore.getState().deleteSelection();
|
||||
let state = useBoardStore.getState();
|
||||
expect(state.pages[0].strokes.map((s) => s.id)).toEqual(["s2"]);
|
||||
expect(state.selection).toBeNull();
|
||||
useBoardStore.getState().undo();
|
||||
state = useBoardStore.getState();
|
||||
expect(state.pages[0].strokes.map((s) => s.id)).toEqual(["s1", "s2", "s3"]);
|
||||
});
|
||||
|
||||
it("copySelection keeps the strokes and snapshots the clipboard", () => {
|
||||
setupSelection(["s1"]);
|
||||
useBoardStore.getState().copySelection();
|
||||
const state = useBoardStore.getState();
|
||||
expect(state.pages[0].strokes).toHaveLength(2);
|
||||
expect(state.clipboard.strokes.map((s) => s.id)).toEqual(["s1"]);
|
||||
state.clipboard.strokes[0].points[0].x = 9999;
|
||||
expect(useBoardStore.getState().pages[0].strokes[0].points[0].x).toBe(0);
|
||||
});
|
||||
|
||||
it("cutSelection removes the strokes and fills the clipboard", () => {
|
||||
setupSelection(["s2"]);
|
||||
useBoardStore.getState().cutSelection();
|
||||
const state = useBoardStore.getState();
|
||||
expect(state.pages[0].strokes.map((s) => s.id)).toEqual(["s1"]);
|
||||
expect(state.clipboard.strokes.map((s) => s.id)).toEqual(["s2"]);
|
||||
expect(state.selection).toBeNull();
|
||||
useBoardStore.getState().undo();
|
||||
expect(useBoardStore.getState().pages[0].strokes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("pasteClipboard pastes at the top-left margin with fresh ids and selects the result", () => {
|
||||
setupSelection(["s1"]);
|
||||
useBoardStore.getState().copySelection();
|
||||
useBoardStore.getState().setSelection(null);
|
||||
useBoardStore.getState().pasteClipboard();
|
||||
const state = useBoardStore.getState();
|
||||
expect(state.pages[0].strokes).toHaveLength(3);
|
||||
const pasted = state.pages[0].strokes[2];
|
||||
expect(pasted.id).not.toBe("s1");
|
||||
expect(pasted.points[0].x).toBe(42.5);
|
||||
expect(pasted.points[0].y).toBe(42.5);
|
||||
expect(state.selection?.strokeIds).toEqual([pasted.id]);
|
||||
expect(state.tool).toBe("select");
|
||||
useBoardStore.getState().undo();
|
||||
expect(useBoardStore.getState().pages[0].strokes).toHaveLength(2);
|
||||
useBoardStore.getState().redo();
|
||||
expect(useBoardStore.getState().pages[0].strokes).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("pasting twice yields distinct stroke ids", () => {
|
||||
setupSelection(["s1"]);
|
||||
useBoardStore.getState().copySelection();
|
||||
useBoardStore.getState().pasteClipboard();
|
||||
useBoardStore.getState().pasteClipboard();
|
||||
const ids = useBoardStore.getState().pages[0].strokes.map((s) => s.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("pasteClipboard with an empty clipboard is a no-op", () => {
|
||||
const pageId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||
const past = useBoardStore.getState().past.length;
|
||||
useBoardStore.getState().pasteClipboard();
|
||||
expect(useBoardStore.getState().past).toHaveLength(past);
|
||||
});
|
||||
|
||||
it("cut then paste on another page moves the content across pages", () => {
|
||||
const firstId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.getState().addStroke(firstId, sampleStroke("s1"));
|
||||
useBoardStore.getState().setSelection({ pageId: firstId, strokeIds: ["s1"], imageIds: [] });
|
||||
useBoardStore.getState().cutSelection();
|
||||
useBoardStore.getState().setViewPageIndex(1);
|
||||
useBoardStore.getState().pasteClipboard();
|
||||
const state = useBoardStore.getState();
|
||||
expect(state.pages[0].strokes).toHaveLength(0);
|
||||
expect(state.pages[1].strokes).toHaveLength(1);
|
||||
expect(state.selection?.pageId).toBe(state.pages[1].id);
|
||||
});
|
||||
|
||||
it("setSelection(null) clears the anchor as well", () => {
|
||||
setupSelection(["s1"]);
|
||||
useBoardStore.getState().setSelectionAnchor({ x: 10, y: 20 });
|
||||
useBoardStore.getState().setSelection(null);
|
||||
expect(useBoardStore.getState().selectionAnchor).toBeNull();
|
||||
});
|
||||
|
||||
it("entering presentation mode clears the selection", () => {
|
||||
setupSelection(["s1"]);
|
||||
useBoardStore.getState().setPresentation(true);
|
||||
expect(useBoardStore.getState().selection).toBeNull();
|
||||
useBoardStore.getState().setPresentation(false);
|
||||
expect(useBoardStore.getState().presentation).toBe(false);
|
||||
});
|
||||
|
||||
it("pasteClipboard on the last page auto-appends a blank page", () => {
|
||||
useBoardStore.setState({ pages: [createPage("#b98a5f")], viewPageIndex: 0 });
|
||||
const firstId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.getState().addStroke(firstId, sampleStroke("s1"));
|
||||
useBoardStore.getState().setSelection({ pageId: firstId, strokeIds: ["s1"], imageIds: [] });
|
||||
useBoardStore.getState().copySelection();
|
||||
useBoardStore.getState().setViewPageIndex(1);
|
||||
useBoardStore.getState().pasteClipboard();
|
||||
const pages = useBoardStore.getState().pages;
|
||||
expect(pages).toHaveLength(3);
|
||||
expect(pages[2].strokes).toHaveLength(0);
|
||||
expect(pages[2].paperColor).toBe("#b98a5f");
|
||||
});
|
||||
|
||||
it("pasteClipboard on an earlier page does not append a page", () => {
|
||||
const pageId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||
useBoardStore.getState().setSelection({ pageId, strokeIds: ["s1"], imageIds: [] });
|
||||
useBoardStore.getState().copySelection();
|
||||
useBoardStore.getState().setViewPageIndex(0);
|
||||
useBoardStore.getState().pasteClipboard();
|
||||
expect(useBoardStore.getState().pages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("insertImage places the image, selects it, and undo removes it", () => {
|
||||
useBoardStore.getState().insertImage("blob-1", 200, 100);
|
||||
const state = useBoardStore.getState();
|
||||
expect(state.pages[0].images).toHaveLength(1);
|
||||
expect(state.pages[0].images[0].imageId).toBe("blob-1");
|
||||
expect(state.pages[0].images[0].x).toBe(40);
|
||||
expect(state.pages[0].images[0].y).toBe(40);
|
||||
expect(state.selection?.imageIds).toEqual([state.pages[0].images[0].id]);
|
||||
expect(state.tool).toBe("select");
|
||||
expect(state.pages).toHaveLength(2);
|
||||
useBoardStore.getState().undo();
|
||||
expect(useBoardStore.getState().pages[0].images).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("insertImage on an earlier page does not append a page", () => {
|
||||
const pageId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.getState().addStroke(pageId, sampleStroke("s1"));
|
||||
useBoardStore.getState().setViewPageIndex(0);
|
||||
useBoardStore.getState().insertImage("blob-1", 100, 100);
|
||||
expect(useBoardStore.getState().pages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("clearPage also clears images and undo restores them", () => {
|
||||
const pageId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.setState({
|
||||
pages: [{ ...useBoardStore.getState().pages[0], images: [sampleImage("i1")] }],
|
||||
});
|
||||
useBoardStore.getState().clearPage(pageId);
|
||||
expect(useBoardStore.getState().pages[0].images).toHaveLength(0);
|
||||
useBoardStore.getState().undo();
|
||||
expect(useBoardStore.getState().pages[0].images.map((i) => i.id)).toEqual(["i1"]);
|
||||
});
|
||||
|
||||
it("deleteSelection removes strokes and images as a single edit", () => {
|
||||
const pageId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.setState({
|
||||
pages: [
|
||||
{
|
||||
...useBoardStore.getState().pages[0],
|
||||
strokes: [sampleStroke("s1")],
|
||||
images: [sampleImage("i1")],
|
||||
},
|
||||
],
|
||||
});
|
||||
useBoardStore.getState().setSelection({ pageId, strokeIds: ["s1"], imageIds: ["i1"] });
|
||||
useBoardStore.getState().deleteSelection();
|
||||
let state = useBoardStore.getState();
|
||||
expect(state.pages[0].strokes).toHaveLength(0);
|
||||
expect(state.pages[0].images).toHaveLength(0);
|
||||
expect(state.past).toHaveLength(1);
|
||||
useBoardStore.getState().undo();
|
||||
state = useBoardStore.getState();
|
||||
expect(state.pages[0].strokes.map((s) => s.id)).toEqual(["s1"]);
|
||||
expect(state.pages[0].images.map((i) => i.id)).toEqual(["i1"]);
|
||||
});
|
||||
|
||||
it("copy and paste duplicates images with fresh item ids sharing the blob", () => {
|
||||
const pageId = useBoardStore.getState().pages[0].id;
|
||||
useBoardStore.setState({
|
||||
pages: [
|
||||
{
|
||||
...useBoardStore.getState().pages[0],
|
||||
strokes: [sampleStroke("s1")],
|
||||
images: [sampleImage("i1")],
|
||||
},
|
||||
],
|
||||
});
|
||||
useBoardStore.getState().setSelection({ pageId, strokeIds: ["s1"], imageIds: ["i1"] });
|
||||
useBoardStore.getState().copySelection();
|
||||
expect(useBoardStore.getState().clipboard.images[0].imageId).toBe("blob-1");
|
||||
useBoardStore.getState().pasteClipboard();
|
||||
const state = useBoardStore.getState();
|
||||
expect(state.pages[0].images).toHaveLength(2);
|
||||
const pasted = state.pages[0].images[1];
|
||||
expect(pasted.id).not.toBe("i1");
|
||||
expect(pasted.imageId).toBe("blob-1");
|
||||
expect(pasted.x).toBe(142.5);
|
||||
expect(pasted.y).toBe(142.5);
|
||||
expect(state.selection?.imageIds).toEqual([pasted.id]);
|
||||
useBoardStore.getState().undo();
|
||||
expect(useBoardStore.getState().pages[0].images).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("insertPdfPages", () => {
|
||||
it("inserts locked pages after the current page inheriting its style", () => {
|
||||
useBoardStore.setState({
|
||||
pages: [createPage("#003423", "grid"), createPage("#ffffff")],
|
||||
viewPageIndex: 0,
|
||||
});
|
||||
useBoardStore.getState().insertPdfPages([
|
||||
{ imageId: "blob-1", naturalWidth: 200, naturalHeight: 100 },
|
||||
{ imageId: "blob-2", naturalWidth: 100, naturalHeight: 100 },
|
||||
]);
|
||||
const state = useBoardStore.getState();
|
||||
expect(state.pages).toHaveLength(4);
|
||||
const inserted = state.pages[1];
|
||||
expect(inserted.paperColor).toBe("#003423");
|
||||
expect(inserted.pattern).toBe("grid");
|
||||
expect(inserted.images).toHaveLength(1);
|
||||
expect(inserted.images[0].locked).toBe(true);
|
||||
expect(inserted.images[0].imageId).toBe("blob-1");
|
||||
expect(inserted.images[0].x).toBeCloseTo(0);
|
||||
expect(inserted.images[0].width).toBeCloseTo(794);
|
||||
expect(state.pages[2].images[0].imageId).toBe("blob-2");
|
||||
expect(state.pendingScrollToPage).toBe(1);
|
||||
expect(state.past).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("insertPdfPages with an empty list is a no-op", () => {
|
||||
useBoardStore.getState().insertPdfPages([]);
|
||||
expect(useBoardStore.getState().pages).toHaveLength(1);
|
||||
expect(useBoardStore.getState().pendingScrollToPage).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("movePage", () => {
|
||||
it("reorders pages and keeps the view on the same page", () => {
|
||||
useBoardStore.setState({
|
||||
pages: [createPage("#ffffff"), createPage("#ffffff"), createPage("#ffffff")],
|
||||
viewPageIndex: 2,
|
||||
});
|
||||
const [a, b, c] = useBoardStore.getState().pages.map((p) => p.id);
|
||||
useBoardStore.getState().movePage(2, 0);
|
||||
const state = useBoardStore.getState();
|
||||
expect(state.pages.map((p) => p.id)).toEqual([c, a, b]);
|
||||
expect(state.viewPageIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the view page when an earlier page moves after it", () => {
|
||||
useBoardStore.setState({
|
||||
pages: [createPage("#ffffff"), createPage("#ffffff"), createPage("#ffffff")],
|
||||
viewPageIndex: 2,
|
||||
});
|
||||
const [a, b, c] = useBoardStore.getState().pages.map((p) => p.id);
|
||||
useBoardStore.getState().movePage(0, 2);
|
||||
const state = useBoardStore.getState();
|
||||
expect(state.pages.map((p) => p.id)).toEqual([b, c, a]);
|
||||
expect(state.viewPageIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("is a no-op for invalid indices", () => {
|
||||
const before = useBoardStore.getState().pages;
|
||||
useBoardStore.getState().movePage(0, 0);
|
||||
useBoardStore.getState().movePage(-1, 0);
|
||||
useBoardStore.getState().movePage(0, 5);
|
||||
expect(useBoardStore.getState().pages).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -1,668 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import { createImageItem, type ImageItem, placeImageCentered } from "../model/image";
|
||||
import { createPage, type Page, type PagePattern, PLACEMENT_MARGIN } from "../model/page";
|
||||
import { newId, type PenKind, type Stroke, type ToolKind } from "../model/stroke";
|
||||
import {
|
||||
imagesBounds,
|
||||
strokesBounds,
|
||||
translateImage,
|
||||
translateStroke,
|
||||
unionBounds,
|
||||
} from "../model/transform";
|
||||
import type { ViewState } from "../model/viewState";
|
||||
|
||||
export const COLORS = ["#1a1a1a", "#d64541", "#2f6fdd", "#2e9e5b", "#f2b134", "#ffffff"] as const;
|
||||
export const PAPER_COLORS = [
|
||||
"#ffffff",
|
||||
"#fbf3db",
|
||||
"#eef1f4",
|
||||
"#26262a",
|
||||
"#003423",
|
||||
"#b98a5f",
|
||||
] as const;
|
||||
export const SIZES = [1.5, 2.5, 4.5] as const;
|
||||
|
||||
export type Edit =
|
||||
| { kind: "add-stroke"; pageId: string; stroke: Stroke }
|
||||
| { kind: "remove-stroke"; pageId: string; index: number; stroke: Stroke }
|
||||
| { kind: "clear-page"; pageId: string; strokes: Stroke[]; images: ImageItem[] }
|
||||
| { kind: "add-elements"; pageId: string; strokes: Stroke[]; images: ImageItem[] }
|
||||
| {
|
||||
kind: "remove-elements";
|
||||
pageId: string;
|
||||
strokes: { index: number; stroke: Stroke }[];
|
||||
images: { index: number; image: ImageItem }[];
|
||||
}
|
||||
| {
|
||||
kind: "replace-elements";
|
||||
pageId: string;
|
||||
strokesBefore: Stroke[];
|
||||
strokesAfter: Stroke[];
|
||||
imagesBefore: ImageItem[];
|
||||
imagesAfter: ImageItem[];
|
||||
};
|
||||
|
||||
export interface SelectionTarget {
|
||||
pageId: string;
|
||||
strokeIds: string[];
|
||||
imageIds: string[];
|
||||
}
|
||||
|
||||
export interface ClipboardContent {
|
||||
strokes: Stroke[];
|
||||
images: ImageItem[];
|
||||
}
|
||||
|
||||
interface ElementEntries {
|
||||
pageId: string;
|
||||
strokes: { index: number; stroke: Stroke }[];
|
||||
images: { index: number; image: ImageItem }[];
|
||||
}
|
||||
|
||||
interface BoardState {
|
||||
notebookId: string | null;
|
||||
notebookTitle: string;
|
||||
pages: Page[];
|
||||
past: Edit[];
|
||||
future: Edit[];
|
||||
viewPageIndex: number;
|
||||
pendingScrollToPage: number | null;
|
||||
tool: ToolKind;
|
||||
lastPenKind: PenKind;
|
||||
presentation: boolean;
|
||||
sidebarOpen: boolean;
|
||||
color: string;
|
||||
size: number;
|
||||
paperColor: string;
|
||||
pattern: PagePattern;
|
||||
selection: SelectionTarget | null;
|
||||
selectionAnchor: { x: number; y: number } | null;
|
||||
clipboard: ClipboardContent;
|
||||
viewState: ViewState | null;
|
||||
loadDocument: (doc: { id: string; title: string; pages: Page[]; viewState?: ViewState }) => void;
|
||||
unloadDocument: () => void;
|
||||
addStroke: (pageId: string, stroke: Stroke) => void;
|
||||
removeStroke: (pageId: string, strokeId: string) => void;
|
||||
addPage: () => void;
|
||||
deletePage: (pageId: string) => void;
|
||||
clearPage: (pageId: string) => void;
|
||||
clearPendingScroll: () => void;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
setViewPageIndex: (index: number) => void;
|
||||
setTool: (tool: ToolKind) => void;
|
||||
setPresentation: (on: boolean) => void;
|
||||
toggleSidebar: () => void;
|
||||
requestScrollToPage: (index: number) => void;
|
||||
setColor: (color: string) => void;
|
||||
setSize: (size: number) => void;
|
||||
setPaperColor: (color: string) => void;
|
||||
setPattern: (pattern: PagePattern) => void;
|
||||
movePage: (from: number, to: number) => void;
|
||||
setSelection: (selection: SelectionTarget | null) => void;
|
||||
setSelectionAnchor: (anchor: { x: number; y: number } | null) => void;
|
||||
transformSelection: (
|
||||
before: { strokes: Stroke[]; images: ImageItem[] },
|
||||
after: { strokes: Stroke[]; images: ImageItem[] },
|
||||
) => void;
|
||||
recolorSelection: (color: string) => void;
|
||||
deleteSelection: () => void;
|
||||
copySelection: () => void;
|
||||
cutSelection: () => void;
|
||||
pasteClipboard: () => void;
|
||||
insertImage: (imageId: string, naturalWidth: number, naturalHeight: number) => void;
|
||||
insertPdfPages: (
|
||||
pdfPages: { imageId: string; naturalWidth: number; naturalHeight: number }[],
|
||||
) => void;
|
||||
}
|
||||
|
||||
function withStroke(pages: Page[], pageId: string, stroke: Stroke): Page[] {
|
||||
return pages.map((p) => (p.id === pageId ? { ...p, strokes: [...p.strokes, stroke] } : p));
|
||||
}
|
||||
|
||||
function withoutStroke(pages: Page[], pageId: string, strokeId: string): Page[] {
|
||||
return pages.map((p) =>
|
||||
p.id === pageId ? { ...p, strokes: p.strokes.filter((s) => s.id !== strokeId) } : p,
|
||||
);
|
||||
}
|
||||
|
||||
function withoutElements(
|
||||
pages: Page[],
|
||||
pageId: string,
|
||||
strokeIds: Set<string>,
|
||||
imageIds: Set<string>,
|
||||
): Page[] {
|
||||
return pages.map((p) =>
|
||||
p.id === pageId
|
||||
? {
|
||||
...p,
|
||||
strokes: p.strokes.filter((s) => !strokeIds.has(s.id)),
|
||||
images: p.images.filter((i) => !imageIds.has(i.id)),
|
||||
}
|
||||
: p,
|
||||
);
|
||||
}
|
||||
|
||||
function withInsertedStroke(pages: Page[], pageId: string, index: number, stroke: Stroke): Page[] {
|
||||
return pages.map((p) => {
|
||||
if (p.id !== pageId) return p;
|
||||
const strokes = [...p.strokes];
|
||||
strokes.splice(Math.min(index, strokes.length), 0, stroke);
|
||||
return { ...p, strokes };
|
||||
});
|
||||
}
|
||||
|
||||
function replaceById<T extends { id: string }>(items: T[], before: T[], after: T[]): T[] {
|
||||
const byId = new Map(before.map((item, i) => [item.id, after[i]]));
|
||||
return items.map((item) => byId.get(item.id) ?? item);
|
||||
}
|
||||
|
||||
function applyEdit(pages: Page[], edit: Edit, direction: "do" | "undo"): Page[] {
|
||||
switch (edit.kind) {
|
||||
case "add-stroke":
|
||||
return direction === "do"
|
||||
? withStroke(pages, edit.pageId, edit.stroke)
|
||||
: withoutStroke(pages, edit.pageId, edit.stroke.id);
|
||||
case "remove-stroke":
|
||||
return direction === "do"
|
||||
? withoutStroke(pages, edit.pageId, edit.stroke.id)
|
||||
: withInsertedStroke(pages, edit.pageId, edit.index, edit.stroke);
|
||||
case "clear-page":
|
||||
return pages.map((p) =>
|
||||
p.id === edit.pageId
|
||||
? direction === "do"
|
||||
? { ...p, strokes: [], images: p.images.filter((i) => i.locked) }
|
||||
: { ...p, strokes: edit.strokes, images: edit.images }
|
||||
: p,
|
||||
);
|
||||
case "add-elements": {
|
||||
if (direction === "do") {
|
||||
return pages.map((p) =>
|
||||
p.id === edit.pageId
|
||||
? {
|
||||
...p,
|
||||
strokes: [...p.strokes, ...edit.strokes],
|
||||
images: [...p.images, ...edit.images],
|
||||
}
|
||||
: p,
|
||||
);
|
||||
}
|
||||
return withoutElements(
|
||||
pages,
|
||||
edit.pageId,
|
||||
new Set(edit.strokes.map((s) => s.id)),
|
||||
new Set(edit.images.map((i) => i.id)),
|
||||
);
|
||||
}
|
||||
case "remove-elements": {
|
||||
if (direction === "do") {
|
||||
return withoutElements(
|
||||
pages,
|
||||
edit.pageId,
|
||||
new Set(edit.strokes.map((e) => e.stroke.id)),
|
||||
new Set(edit.images.map((e) => e.image.id)),
|
||||
);
|
||||
}
|
||||
return pages.map((p) => {
|
||||
if (p.id !== edit.pageId) return p;
|
||||
const strokes = [...p.strokes];
|
||||
for (const entry of [...edit.strokes].sort((a, b) => a.index - b.index)) {
|
||||
strokes.splice(Math.min(entry.index, strokes.length), 0, entry.stroke);
|
||||
}
|
||||
const images = [...p.images];
|
||||
for (const entry of [...edit.images].sort((a, b) => a.index - b.index)) {
|
||||
images.splice(Math.min(entry.index, images.length), 0, entry.image);
|
||||
}
|
||||
return { ...p, strokes, images };
|
||||
});
|
||||
}
|
||||
case "replace-elements": {
|
||||
const strokesBefore = direction === "do" ? edit.strokesBefore : edit.strokesAfter;
|
||||
const strokesAfter = direction === "do" ? edit.strokesAfter : edit.strokesBefore;
|
||||
const imagesBefore = direction === "do" ? edit.imagesBefore : edit.imagesAfter;
|
||||
const imagesAfter = direction === "do" ? edit.imagesAfter : edit.imagesBefore;
|
||||
return pages.map((p) =>
|
||||
p.id === edit.pageId
|
||||
? {
|
||||
...p,
|
||||
strokes: replaceById(p.strokes, strokesBefore, strokesAfter),
|
||||
images: replaceById(p.images, imagesBefore, imagesAfter),
|
||||
}
|
||||
: p,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectedElements(state: BoardState): ElementEntries | null {
|
||||
const selection = state.selection;
|
||||
if (!selection) return null;
|
||||
const page = state.pages.find((p) => p.id === selection.pageId);
|
||||
if (!page) return null;
|
||||
const strokeIds = new Set(selection.strokeIds);
|
||||
const imageIds = new Set(selection.imageIds);
|
||||
const strokes: { index: number; stroke: Stroke }[] = [];
|
||||
const images: { index: number; image: ImageItem }[] = [];
|
||||
page.strokes.forEach((stroke, index) => {
|
||||
if (strokeIds.has(stroke.id)) strokes.push({ index, stroke });
|
||||
});
|
||||
page.images.forEach((image, index) => {
|
||||
if (imageIds.has(image.id)) images.push({ index, image });
|
||||
});
|
||||
if (strokes.length === 0 && images.length === 0) return null;
|
||||
return { pageId: page.id, strokes, images };
|
||||
}
|
||||
|
||||
export const useBoardStore = create<BoardState>()((set) => ({
|
||||
notebookId: null,
|
||||
notebookTitle: "",
|
||||
pages: [createPage(PAPER_COLORS[0])],
|
||||
past: [],
|
||||
future: [],
|
||||
viewPageIndex: 0,
|
||||
pendingScrollToPage: null,
|
||||
tool: "pen",
|
||||
lastPenKind: "pen",
|
||||
presentation: false,
|
||||
sidebarOpen: false,
|
||||
color: COLORS[0],
|
||||
size: SIZES[1],
|
||||
paperColor: PAPER_COLORS[0],
|
||||
pattern: "blank",
|
||||
selection: null,
|
||||
selectionAnchor: null,
|
||||
clipboard: { strokes: [], images: [] },
|
||||
viewState: null,
|
||||
loadDocument: (doc) =>
|
||||
set({
|
||||
notebookId: doc.id,
|
||||
notebookTitle: doc.title,
|
||||
pages: doc.pages,
|
||||
past: [],
|
||||
future: [],
|
||||
viewPageIndex: 0,
|
||||
pendingScrollToPage: null,
|
||||
selection: null,
|
||||
selectionAnchor: null,
|
||||
viewState: doc.viewState ?? null,
|
||||
}),
|
||||
unloadDocument: () =>
|
||||
set({
|
||||
notebookId: null,
|
||||
notebookTitle: "",
|
||||
pages: [createPage(PAPER_COLORS[0])],
|
||||
past: [],
|
||||
future: [],
|
||||
viewPageIndex: 0,
|
||||
pendingScrollToPage: null,
|
||||
selection: null,
|
||||
selectionAnchor: null,
|
||||
viewState: null,
|
||||
}),
|
||||
addStroke: (pageId, stroke) =>
|
||||
set((state) => {
|
||||
if (!state.pages.some((p) => p.id === pageId)) return state;
|
||||
let pages = withStroke(state.pages, pageId, stroke);
|
||||
const lastPage = state.pages[state.pages.length - 1];
|
||||
if (lastPage && lastPage.id === pageId) {
|
||||
pages = [...pages, createPage(lastPage.paperColor, lastPage.pattern)];
|
||||
}
|
||||
return {
|
||||
pages,
|
||||
past: [...state.past, { kind: "add-stroke", pageId, stroke }],
|
||||
future: [],
|
||||
};
|
||||
}),
|
||||
removeStroke: (pageId, strokeId) =>
|
||||
set((state) => {
|
||||
const page = state.pages.find((p) => p.id === pageId);
|
||||
if (!page) return state;
|
||||
const index = page.strokes.findIndex((s) => s.id === strokeId);
|
||||
if (index < 0) return state;
|
||||
return {
|
||||
pages: withoutStroke(state.pages, pageId, strokeId),
|
||||
past: [
|
||||
...state.past,
|
||||
{ kind: "remove-stroke", pageId, index, stroke: page.strokes[index] },
|
||||
],
|
||||
future: [],
|
||||
};
|
||||
}),
|
||||
addPage: () =>
|
||||
set((state) => {
|
||||
const current = state.pages[state.viewPageIndex];
|
||||
const insertIndex = state.viewPageIndex + 1;
|
||||
const pages = [...state.pages];
|
||||
pages.splice(
|
||||
insertIndex,
|
||||
0,
|
||||
createPage(current?.paperColor ?? state.paperColor, current?.pattern ?? state.pattern),
|
||||
);
|
||||
return { pages, pendingScrollToPage: insertIndex };
|
||||
}),
|
||||
deletePage: (pageId) =>
|
||||
set((state) => {
|
||||
if (state.pages.length <= 1) return state;
|
||||
const pages = state.pages.filter((p) => p.id !== pageId);
|
||||
if (pages.length === state.pages.length) return state;
|
||||
return {
|
||||
pages,
|
||||
past: [],
|
||||
future: [],
|
||||
viewPageIndex: Math.min(state.viewPageIndex, pages.length - 1),
|
||||
...(state.selection?.pageId === pageId ? { selection: null, selectionAnchor: null } : {}),
|
||||
};
|
||||
}),
|
||||
clearPage: (pageId) =>
|
||||
set((state) => {
|
||||
const page = state.pages.find((p) => p.id === pageId);
|
||||
const hasUnlockedContent =
|
||||
page && (page.strokes.length > 0 || page.images.some((i) => !i.locked));
|
||||
if (!page || !hasUnlockedContent) return state;
|
||||
return {
|
||||
pages: state.pages.map((p) =>
|
||||
p.id === pageId ? { ...p, strokes: [], images: p.images.filter((i) => i.locked) } : p,
|
||||
),
|
||||
past: [
|
||||
...state.past,
|
||||
{ kind: "clear-page", pageId, strokes: page.strokes, images: page.images },
|
||||
],
|
||||
future: [],
|
||||
...(state.selection?.pageId === pageId ? { selection: null, selectionAnchor: null } : {}),
|
||||
};
|
||||
}),
|
||||
clearPendingScroll: () => set({ pendingScrollToPage: null }),
|
||||
undo: () =>
|
||||
set((state) => {
|
||||
const edit = state.past[state.past.length - 1];
|
||||
if (!edit) return state;
|
||||
return {
|
||||
pages: applyEdit(state.pages, edit, "undo"),
|
||||
past: state.past.slice(0, -1),
|
||||
future: [...state.future, edit],
|
||||
};
|
||||
}),
|
||||
redo: () =>
|
||||
set((state) => {
|
||||
const edit = state.future[state.future.length - 1];
|
||||
if (!edit) return state;
|
||||
return {
|
||||
pages: applyEdit(state.pages, edit, "do"),
|
||||
past: [...state.past, edit],
|
||||
future: state.future.slice(0, -1),
|
||||
};
|
||||
}),
|
||||
setViewPageIndex: (index) => set({ viewPageIndex: index }),
|
||||
setTool: (tool) =>
|
||||
set((state) => ({
|
||||
tool,
|
||||
lastPenKind: tool === "pen" || tool === "highlighter" ? tool : state.lastPenKind,
|
||||
})),
|
||||
setPresentation: (on) =>
|
||||
set(
|
||||
on ? { presentation: true, selection: null, selectionAnchor: null } : { presentation: false },
|
||||
),
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
requestScrollToPage: (index) => set({ pendingScrollToPage: index }),
|
||||
setColor: (color) => set({ color }),
|
||||
setSize: (size) => set({ size }),
|
||||
setPaperColor: (paperColor) =>
|
||||
set((state) => ({
|
||||
paperColor,
|
||||
pages: state.pages.map((p, i) => (i === state.viewPageIndex ? { ...p, paperColor } : p)),
|
||||
})),
|
||||
setPattern: (pattern) =>
|
||||
set((state) => ({
|
||||
pattern,
|
||||
pages: state.pages.map((p, i) => (i === state.viewPageIndex ? { ...p, pattern } : p)),
|
||||
})),
|
||||
movePage: (from, to) =>
|
||||
set((state) => {
|
||||
const count = state.pages.length;
|
||||
if (from === to || from < 0 || to < 0 || from >= count || to >= count) return state;
|
||||
const viewId = state.pages[state.viewPageIndex]?.id;
|
||||
const pages = [...state.pages];
|
||||
const [moved] = pages.splice(from, 1);
|
||||
pages.splice(to, 0, moved);
|
||||
return {
|
||||
pages,
|
||||
viewPageIndex: Math.max(
|
||||
0,
|
||||
pages.findIndex((p) => p.id === viewId),
|
||||
),
|
||||
};
|
||||
}),
|
||||
setSelection: (selection) =>
|
||||
set((state) => {
|
||||
if (!selection && !state.selection) return state;
|
||||
return selection ? { selection } : { selection: null, selectionAnchor: null };
|
||||
}),
|
||||
setSelectionAnchor: (anchor) =>
|
||||
set((state) => {
|
||||
if (!anchor && !state.selectionAnchor) return state;
|
||||
if (
|
||||
anchor &&
|
||||
state.selectionAnchor &&
|
||||
Math.round(anchor.x) === Math.round(state.selectionAnchor.x) &&
|
||||
Math.round(anchor.y) === Math.round(state.selectionAnchor.y)
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
return { selectionAnchor: anchor };
|
||||
}),
|
||||
transformSelection: (before, after) =>
|
||||
set((state) => {
|
||||
const selection = state.selection;
|
||||
if (!selection) return state;
|
||||
if (
|
||||
before.strokes.length !== after.strokes.length ||
|
||||
before.images.length !== after.images.length ||
|
||||
(before.strokes.length === 0 && before.images.length === 0)
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
if (!state.pages.some((p) => p.id === selection.pageId)) return state;
|
||||
return {
|
||||
pages: state.pages.map((p) =>
|
||||
p.id === selection.pageId
|
||||
? {
|
||||
...p,
|
||||
strokes: replaceById(p.strokes, before.strokes, after.strokes),
|
||||
images: replaceById(p.images, before.images, after.images),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
past: [
|
||||
...state.past,
|
||||
{
|
||||
kind: "replace-elements",
|
||||
pageId: selection.pageId,
|
||||
strokesBefore: before.strokes,
|
||||
strokesAfter: after.strokes,
|
||||
imagesBefore: before.images,
|
||||
imagesAfter: after.images,
|
||||
},
|
||||
],
|
||||
future: [],
|
||||
};
|
||||
}),
|
||||
recolorSelection: (color) =>
|
||||
set((state) => {
|
||||
const selected = selectedElements(state);
|
||||
if (!selected || selected.strokes.length === 0) return state;
|
||||
const before = selected.strokes.map((e) => e.stroke);
|
||||
if (before.every((s) => s.color === color)) return state;
|
||||
const after = before.map((s) => ({ ...s, color }));
|
||||
return {
|
||||
pages: state.pages.map((p) =>
|
||||
p.id === selected.pageId ? { ...p, strokes: replaceById(p.strokes, before, after) } : p,
|
||||
),
|
||||
past: [
|
||||
...state.past,
|
||||
{
|
||||
kind: "replace-elements",
|
||||
pageId: selected.pageId,
|
||||
strokesBefore: before,
|
||||
strokesAfter: after,
|
||||
imagesBefore: [],
|
||||
imagesAfter: [],
|
||||
},
|
||||
],
|
||||
future: [],
|
||||
};
|
||||
}),
|
||||
deleteSelection: () =>
|
||||
set((state) => {
|
||||
const selected = selectedElements(state);
|
||||
if (!selected) return state;
|
||||
return {
|
||||
pages: withoutElements(
|
||||
state.pages,
|
||||
selected.pageId,
|
||||
new Set(selected.strokes.map((e) => e.stroke.id)),
|
||||
new Set(selected.images.map((e) => e.image.id)),
|
||||
),
|
||||
past: [
|
||||
...state.past,
|
||||
{
|
||||
kind: "remove-elements",
|
||||
pageId: selected.pageId,
|
||||
strokes: selected.strokes,
|
||||
images: selected.images,
|
||||
},
|
||||
],
|
||||
future: [],
|
||||
selection: null,
|
||||
selectionAnchor: null,
|
||||
};
|
||||
}),
|
||||
copySelection: () =>
|
||||
set((state) => {
|
||||
const selected = selectedElements(state);
|
||||
if (!selected) return state;
|
||||
return {
|
||||
clipboard: {
|
||||
strokes: structuredClone(selected.strokes.map((e) => e.stroke)),
|
||||
images: structuredClone(selected.images.map((e) => e.image)),
|
||||
},
|
||||
};
|
||||
}),
|
||||
cutSelection: () =>
|
||||
set((state) => {
|
||||
const selected = selectedElements(state);
|
||||
if (!selected) return state;
|
||||
return {
|
||||
clipboard: {
|
||||
strokes: structuredClone(selected.strokes.map((e) => e.stroke)),
|
||||
images: structuredClone(selected.images.map((e) => e.image)),
|
||||
},
|
||||
pages: withoutElements(
|
||||
state.pages,
|
||||
selected.pageId,
|
||||
new Set(selected.strokes.map((e) => e.stroke.id)),
|
||||
new Set(selected.images.map((e) => e.image.id)),
|
||||
),
|
||||
past: [
|
||||
...state.past,
|
||||
{
|
||||
kind: "remove-elements",
|
||||
pageId: selected.pageId,
|
||||
strokes: selected.strokes,
|
||||
images: selected.images,
|
||||
},
|
||||
],
|
||||
future: [],
|
||||
selection: null,
|
||||
selectionAnchor: null,
|
||||
};
|
||||
}),
|
||||
pasteClipboard: () =>
|
||||
set((state) => {
|
||||
const clip = state.clipboard;
|
||||
if (clip.strokes.length === 0 && clip.images.length === 0) return state;
|
||||
const page = state.pages[state.viewPageIndex] ?? state.pages[0];
|
||||
if (!page) return state;
|
||||
const strokes = clip.strokes.map((s) => ({ ...structuredClone(s), id: newId() }));
|
||||
const images = clip.images.map((i) => ({ ...structuredClone(i), id: newId() }));
|
||||
const bounds = unionBounds(strokesBounds(strokes), imagesBounds(images));
|
||||
if (!bounds) return state;
|
||||
const dx = PLACEMENT_MARGIN - bounds.minX;
|
||||
const dy = PLACEMENT_MARGIN - bounds.minY;
|
||||
const placedStrokes = strokes.map((s) => translateStroke(s, dx, dy));
|
||||
const placedImages = images.map((i) => translateImage(i, dx, dy));
|
||||
let pages = state.pages.map((p) =>
|
||||
p.id === page.id
|
||||
? {
|
||||
...p,
|
||||
strokes: [...p.strokes, ...placedStrokes],
|
||||
images: [...p.images, ...placedImages],
|
||||
}
|
||||
: p,
|
||||
);
|
||||
const lastPage = state.pages[state.pages.length - 1];
|
||||
if (lastPage && lastPage.id === page.id) {
|
||||
pages = [...pages, createPage(lastPage.paperColor, lastPage.pattern)];
|
||||
}
|
||||
return {
|
||||
pages,
|
||||
past: [
|
||||
...state.past,
|
||||
{ kind: "add-elements", pageId: page.id, strokes: placedStrokes, images: placedImages },
|
||||
],
|
||||
future: [],
|
||||
selection: {
|
||||
pageId: page.id,
|
||||
strokeIds: placedStrokes.map((s) => s.id),
|
||||
imageIds: placedImages.map((i) => i.id),
|
||||
},
|
||||
tool: "select",
|
||||
};
|
||||
}),
|
||||
insertImage: (imageId, naturalWidth, naturalHeight) =>
|
||||
set((state) => {
|
||||
const page = state.pages[state.viewPageIndex] ?? state.pages[0];
|
||||
if (!page) return state;
|
||||
const image = createImageItem(imageId, naturalWidth, naturalHeight);
|
||||
let pages = state.pages.map((p) =>
|
||||
p.id === page.id ? { ...p, images: [...p.images, image] } : p,
|
||||
);
|
||||
const lastPage = state.pages[state.pages.length - 1];
|
||||
if (lastPage && lastPage.id === page.id) {
|
||||
pages = [...pages, createPage(lastPage.paperColor, lastPage.pattern)];
|
||||
}
|
||||
return {
|
||||
pages,
|
||||
past: [
|
||||
...state.past,
|
||||
{ kind: "add-elements", pageId: page.id, strokes: [], images: [image] },
|
||||
],
|
||||
future: [],
|
||||
selection: { pageId: page.id, strokeIds: [], imageIds: [image.id] },
|
||||
tool: "select",
|
||||
};
|
||||
}),
|
||||
insertPdfPages: (pdfPages) =>
|
||||
set((state) => {
|
||||
if (pdfPages.length === 0) return state;
|
||||
const current = state.pages[state.viewPageIndex];
|
||||
if (!current) return state;
|
||||
const insertIndex = state.viewPageIndex + 1;
|
||||
const inserted: Page[] = pdfPages.map((pdfPage) => ({
|
||||
id: newId(),
|
||||
strokes: [],
|
||||
images: [
|
||||
{
|
||||
id: newId(),
|
||||
imageId: pdfPage.imageId,
|
||||
...placeImageCentered(pdfPage.naturalWidth, pdfPage.naturalHeight),
|
||||
locked: true,
|
||||
},
|
||||
],
|
||||
paperColor: current.paperColor,
|
||||
pattern: current.pattern,
|
||||
}));
|
||||
const pages = [...state.pages];
|
||||
pages.splice(insertIndex, 0, ...inserted);
|
||||
return { pages, pendingScrollToPage: insertIndex };
|
||||
}),
|
||||
}));
|
||||
@@ -1,622 +0,0 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
background: #e8e8e6;
|
||||
color: #1a1a1a;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.board {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #e8e8e6;
|
||||
touch-action: none;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.board-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.page-indicator {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: max(16px, env(safe-area-inset-bottom));
|
||||
padding: 4px 12px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #555555;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
position: absolute;
|
||||
top: max(10px, env(safe-area-inset-top));
|
||||
right: max(10px, env(safe-area-inset-right));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.toolbar button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #333333;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.toolbar button:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.toolbar button.active {
|
||||
background: rgba(47, 111, 221, 0.14);
|
||||
color: #2f6fdd;
|
||||
}
|
||||
|
||||
.toolbar button svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.selection-bar {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
max-width: calc(100vw - 12px);
|
||||
overflow-x: auto;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
transform: translate(-50%, -100%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.selection-bar button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #333333;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.selection-bar button:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.selection-bar button.text-btn {
|
||||
width: auto;
|
||||
padding: 0 9px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.selection-bar .selection-divider {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
margin: 0 3px;
|
||||
}
|
||||
|
||||
.settings-panel button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: #333333;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.settings-panel button:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.settings-panel button.active {
|
||||
background: rgba(47, 111, 221, 0.14);
|
||||
color: #2f6fdd;
|
||||
}
|
||||
|
||||
.settings-panel button:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.settings-panel button svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.swatch span {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--swatch);
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.swatch.active span {
|
||||
outline: 2px solid #2f6fdd;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.size-option span {
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.size-slider {
|
||||
width: 110px;
|
||||
accent-color: #2f6fdd;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.size-value {
|
||||
min-width: 24px;
|
||||
font-size: 12px;
|
||||
color: #888888;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
position: fixed;
|
||||
top: calc(max(10px, env(safe-area-inset-top)) + 46px);
|
||||
right: max(10px, env(safe-area-inset-right));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: calc(100vw - 20px);
|
||||
max-height: calc(100vh - 56px - env(safe-area-inset-bottom));
|
||||
overflow-y: auto;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.settings-label {
|
||||
width: 64px;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
.settings-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.color-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
display: inline-flex;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--value);
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.color-picker input[type="color"] {
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
transform: translate(-25%, -25%);
|
||||
opacity: 0;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hex-input {
|
||||
width: 72px;
|
||||
height: 28px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
border-radius: 6px;
|
||||
padding: 0 6px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: #333333;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.selection-bar .color-field {
|
||||
gap: 4px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.selection-bar .color-picker {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.selection-bar .hex-input {
|
||||
width: 60px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.settings-panel button.text-option {
|
||||
width: auto;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 7px;
|
||||
background: #ffffff;
|
||||
font-size: 12px;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.settings-panel button.text-option.active {
|
||||
border-color: #2f6fdd;
|
||||
background: rgba(47, 111, 221, 0.1);
|
||||
color: #2f6fdd;
|
||||
}
|
||||
|
||||
.settings-panel button.text-option:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.home {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
overflow-y: auto;
|
||||
padding: 48px 24px calc(48px + env(safe-area-inset-bottom));
|
||||
background: #f4f4f2;
|
||||
}
|
||||
|
||||
.home-header {
|
||||
max-width: 880px;
|
||||
margin: 0 auto 32px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.home-header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.home-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.home-actions button {
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
background: #ffffff;
|
||||
font-size: 14px;
|
||||
color: #333333;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.home-actions button.primary {
|
||||
background: #2f6fdd;
|
||||
border: none;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.home-actions button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.home-empty {
|
||||
max-width: 880px;
|
||||
margin: 80px auto 0;
|
||||
text-align: center;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.notebook-grid {
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.notebook-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.notebook-card.selected {
|
||||
border-color: #2f6fdd;
|
||||
}
|
||||
|
||||
.notebook-checkbox {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: #2f6fdd;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notebook-open {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
text-align: left;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notebook-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notebook-meta {
|
||||
font-size: 12px;
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
.notebook-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.notebook-actions button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #666666;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.notebook-actions button:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.notebook-actions button svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.exit-presentation {
|
||||
position: fixed;
|
||||
top: max(10px, env(safe-area-inset-top));
|
||||
right: max(10px, env(safe-area-inset-right));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
color: #555555;
|
||||
cursor: pointer;
|
||||
opacity: 0.35;
|
||||
transition: opacity 0.15s ease;
|
||||
z-index: 10;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.exit-presentation:hover,
|
||||
.exit-presentation:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
button.exit-presentation svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
.board.board-shifted {
|
||||
left: 216px;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 184px;
|
||||
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
|
||||
background: #f4f4f2;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.08);
|
||||
overflow-y: auto;
|
||||
z-index: 9;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: #888888;
|
||||
}
|
||||
|
||||
.sidebar-header button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 16px;
|
||||
color: #999999;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.sidebar-header button:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.sidebar-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
touch-action: pan-y;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.thumbnail.active {
|
||||
border-color: #2f6fdd;
|
||||
}
|
||||
|
||||
.thumbnail.dragging {
|
||||
z-index: 5;
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.25);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.thumbnail.drop-before {
|
||||
box-shadow: 0 -3px 0 #2f6fdd;
|
||||
}
|
||||
|
||||
.thumbnail.drop-after {
|
||||
box-shadow: 0 3px 0 #2f6fdd;
|
||||
}
|
||||
|
||||
.thumbnail canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.thumbnail span {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 4px;
|
||||
font-size: 11px;
|
||||
color: #999999;
|
||||
}
|
||||
Reference in New Issue
Block a user