fix the issue docker

This commit is contained in:
saqib mir
2026-08-18 12:20:43 +05:30
parent 3da5276cc1
commit adb599b921
66 changed files with 2 additions and 8753 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ ENV NODE_ENV=development
WORKDIR /app
COPY package*.json ./
RUN npm ci
RUN npm install
FROM base AS development
COPY . .
+1 -4
View File
@@ -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}
-6
View File
@@ -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';
-2
View File
@@ -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',
-297
View File
@@ -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);
});
});
-38
View File
@@ -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
View File
@@ -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);
});
});
-22
View File
@@ -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);
});
});
-138
View File
@@ -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");
});
});
-69
View File
@@ -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";
}
}
-157
View File
@@ -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");
});
});
-85
View File
@@ -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"]);
});
});
-200
View File
@@ -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);
});
});
-51
View File
@@ -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,
});
});
});
-134
View File
@@ -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 };
}),
}));
-622
View File
@@ -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;
}