viteboard 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/bin/viteboard.mjs +59 -0
- package/content/docs/assets/image-mqjpqlyw.png +0 -0
- package/content/docs/assets/image-mqjsmenr.png +0 -0
- package/content/docs/boards/product-roadmap.board.json +2130 -0
- package/content/docs/capabilities.md +89 -0
- package/content/docs/docs.md +84 -0
- package/content/docs/guide/boards-in-markdown.md +48 -0
- package/content/docs/guide/boards.md +92 -0
- package/content/docs/guide/getting-started.md +71 -0
- package/content/docs/guide/test.md +6 -0
- package/content/docs/index.md +47 -0
- package/content/docs/product-roadmap.board.json +2130 -0
- package/content/docs/test.md +219 -0
- package/content/docs/tetst-dir/test-board.board.json +15 -0
- package/content/docs/tetst-dir/test-test.md +1 -0
- package/content/docs/workspace.md +73 -0
- package/dist/assets/index-CEpxLM2o.css +1 -0
- package/dist/assets/index-D4xvJdEQ.js +60 -0
- package/dist/index.html +13 -0
- package/index.html +13 -0
- package/package.json +52 -0
- package/src/app/AppController.ts +955 -0
- package/src/app/BoardState.ts +130 -0
- package/src/app/ClipboardService.ts +159 -0
- package/src/app/CommandManager.ts +66 -0
- package/src/app/ElementActions.ts +152 -0
- package/src/app/EmbedRegionSelector.ts +103 -0
- package/src/app/ExportPng.ts +107 -0
- package/src/app/ImageInsertService.ts +141 -0
- package/src/app/KeyboardShortcuts.ts +124 -0
- package/src/app/main.ts +6 -0
- package/src/board/BoardPreview.ts +189 -0
- package/src/board/BoardService.ts +222 -0
- package/src/canvas/CanvasRenderer.ts +253 -0
- package/src/canvas/CanvasSurface.ts +70 -0
- package/src/canvas/HitTester.ts +110 -0
- package/src/canvas/ImageCache.ts +123 -0
- package/src/canvas/PerformanceMonitor.ts +31 -0
- package/src/canvas/RenderScheduler.ts +26 -0
- package/src/canvas/Viewport.ts +77 -0
- package/src/content/ContentApi.ts +258 -0
- package/src/content/Markdown.ts +431 -0
- package/src/content/MarkdownView.ts +70 -0
- package/src/editor/DocEditor.ts +799 -0
- package/src/editor/htmlToMarkdown.ts +333 -0
- package/src/elements/renderElement.ts +509 -0
- package/src/elements/types.ts +118 -0
- package/src/shell/Shell.ts +2950 -0
- package/src/shell/Sidebar.ts +1352 -0
- package/src/storage/AssetStore.ts +86 -0
- package/src/storage/BoardSerializer.ts +114 -0
- package/src/storage/ImportExportService.ts +153 -0
- package/src/storage/IndexedDbStore.ts +92 -0
- package/src/storage/LocalBoardStore.ts +104 -0
- package/src/styles.css +3257 -0
- package/src/templates/helpers.ts +124 -0
- package/src/templates/index.ts +65 -0
- package/src/templates/journeyMapTemplate.ts +52 -0
- package/src/templates/opportunityTreeTemplate.ts +45 -0
- package/src/templates/personaTemplate.ts +41 -0
- package/src/templates/prioritizationMatrixTemplate.ts +44 -0
- package/src/templates/requirementsFlowTemplate.ts +45 -0
- package/src/templates/screenshotReviewTemplate.ts +52 -0
- package/src/templates/systemDiagramTemplate.ts +41 -0
- package/src/testing/StressTestGenerator.ts +134 -0
- package/src/testing/StressTestPanel.ts +64 -0
- package/src/tools/ArrowTool.ts +87 -0
- package/src/tools/ImageTool.ts +39 -0
- package/src/tools/PanTool.ts +34 -0
- package/src/tools/SelectTool.ts +377 -0
- package/src/tools/ShapeTool.ts +106 -0
- package/src/tools/StickyTool.ts +69 -0
- package/src/tools/TaskTool.ts +52 -0
- package/src/tools/TextTool.ts +45 -0
- package/src/tools/ToolContext.ts +44 -0
- package/src/tools/ToolController.ts +178 -0
- package/src/ui/Modal.ts +58 -0
- package/src/ui/PerformanceOverlay.ts +75 -0
- package/src/ui/StorageManager.ts +94 -0
- package/src/ui/TemplatePicker.ts +67 -0
- package/src/ui/TextEditorOverlay.ts +137 -0
- package/src/ui/Toolbar.ts +151 -0
- package/src/ui/TopControls.ts +137 -0
- package/src/ui/icons.ts +50 -0
- package/tsconfig.json +19 -0
- package/vite.config.ts +694 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { Tool, ToolContext } from "./ToolContext";
|
|
2
|
+
import type { ToolName } from "./ToolContext";
|
|
3
|
+
import { createElement, type BoardElement, type ElementType } from "../elements/types";
|
|
4
|
+
import { screenToWorld } from "../canvas/Viewport";
|
|
5
|
+
|
|
6
|
+
type ShapeToolName = Extract<ToolName, "rectangle" | "ellipse" | "diamond" | "triangle" | "pill" | "chevron">;
|
|
7
|
+
|
|
8
|
+
export class ShapeTool implements Tool {
|
|
9
|
+
name: ShapeToolName;
|
|
10
|
+
cursor = "crosshair";
|
|
11
|
+
|
|
12
|
+
private shapeType: ElementType;
|
|
13
|
+
private shapeKind: ShapeToolName;
|
|
14
|
+
private drawing = false;
|
|
15
|
+
private startWorld = { x: 0, y: 0 };
|
|
16
|
+
private preview: BoardElement | null = null;
|
|
17
|
+
|
|
18
|
+
constructor(name: ShapeToolName) {
|
|
19
|
+
this.name = name;
|
|
20
|
+
this.shapeKind = name;
|
|
21
|
+
this.shapeType = name === "ellipse" ? "ellipse" : "rectangle";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
onPointerDown(e: PointerEvent, ctx: ToolContext): void {
|
|
25
|
+
const { state } = ctx;
|
|
26
|
+
this.drawing = true;
|
|
27
|
+
this.startWorld = screenToWorld(e.clientX, e.clientY, state.viewport);
|
|
28
|
+
const isDark = state.theme === "dark";
|
|
29
|
+
const isGraphicShape = !["rectangle", "ellipse", "pill"].includes(this.shapeKind);
|
|
30
|
+
this.preview = createElement(this.shapeType, {
|
|
31
|
+
x: this.startWorld.x,
|
|
32
|
+
y: this.startWorld.y,
|
|
33
|
+
width: 0,
|
|
34
|
+
height: 0,
|
|
35
|
+
zIndex: state.maxZIndex() + 1,
|
|
36
|
+
style: {
|
|
37
|
+
fill: isGraphicShape ? "#0078d4" : isDark ? "#2a2d33" : "#ffffff",
|
|
38
|
+
stroke: isGraphicShape ? "#495057" : isDark ? "#9ca3af" : "#374151",
|
|
39
|
+
strokeWidth: isGraphicShape ? 0 : 1.5,
|
|
40
|
+
borderRadius: this.shapeKind === "pill" ? 999 : this.shapeKind === "rectangle" ? 8 : 0,
|
|
41
|
+
fontSize: 14,
|
|
42
|
+
textColor: isGraphicShape ? "#ffffff" : isDark ? "#f2f2f2" : "#1f2933",
|
|
43
|
+
},
|
|
44
|
+
data: { text: "", shapeKind: this.shapeKind },
|
|
45
|
+
});
|
|
46
|
+
ctx.renderer.overlay.preview = this.preview;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
onPointerMove(e: PointerEvent, ctx: ToolContext): void {
|
|
50
|
+
if (!this.drawing || !this.preview) return;
|
|
51
|
+
const world = screenToWorld(e.clientX, e.clientY, ctx.state.viewport);
|
|
52
|
+
let w = world.x - this.startWorld.x;
|
|
53
|
+
let h = world.y - this.startWorld.y;
|
|
54
|
+
if (e.shiftKey) {
|
|
55
|
+
const size = Math.max(Math.abs(w), Math.abs(h));
|
|
56
|
+
w = Math.sign(w || 1) * size;
|
|
57
|
+
h = Math.sign(h || 1) * size;
|
|
58
|
+
}
|
|
59
|
+
this.preview.x = Math.min(this.startWorld.x, this.startWorld.x + w);
|
|
60
|
+
this.preview.y = Math.min(this.startWorld.y, this.startWorld.y + h);
|
|
61
|
+
this.preview.width = Math.abs(w);
|
|
62
|
+
this.preview.height = Math.abs(h);
|
|
63
|
+
ctx.state.requestRender();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
onPointerUp(_e: PointerEvent, ctx: ToolContext): void {
|
|
67
|
+
const { state, commands } = ctx;
|
|
68
|
+
if (!this.drawing || !this.preview) return;
|
|
69
|
+
this.drawing = false;
|
|
70
|
+
ctx.renderer.overlay.preview = null;
|
|
71
|
+
|
|
72
|
+
const el = this.preview;
|
|
73
|
+
this.preview = null;
|
|
74
|
+
|
|
75
|
+
if (el.width < 4 || el.height < 4) {
|
|
76
|
+
// tiny click: create a default-size shape
|
|
77
|
+
el.width = this.shapeKind === "chevron" ? 210 : 160;
|
|
78
|
+
el.height = this.shapeKind === "chevron" ? 76 : 100;
|
|
79
|
+
el.x -= el.width / 2;
|
|
80
|
+
el.y -= el.height / 2;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
commands.execute(
|
|
84
|
+
"create-shape",
|
|
85
|
+
() => {
|
|
86
|
+
if (!state.elementsById.has(el.id)) state.addElement(el);
|
|
87
|
+
state.emitChange();
|
|
88
|
+
},
|
|
89
|
+
() => {
|
|
90
|
+
state.removeElement(el.id);
|
|
91
|
+
state.emitChange();
|
|
92
|
+
}
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
state.clearSelection();
|
|
96
|
+
state.selectedIds.add(el.id);
|
|
97
|
+
ctx.setTool("select");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
onCancel(ctx: ToolContext): void {
|
|
101
|
+
this.drawing = false;
|
|
102
|
+
this.preview = null;
|
|
103
|
+
ctx.renderer.overlay.preview = null;
|
|
104
|
+
ctx.state.requestRender();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Tool, ToolContext } from "./ToolContext";
|
|
2
|
+
import { createElement } from "../elements/types";
|
|
3
|
+
import { screenToWorld } from "../canvas/Viewport";
|
|
4
|
+
|
|
5
|
+
export const STICKY_COLORS = [
|
|
6
|
+
"#fef08a",
|
|
7
|
+
"#fdba74",
|
|
8
|
+
"#fca5a5",
|
|
9
|
+
"#f9a8d4",
|
|
10
|
+
"#d8b4fe",
|
|
11
|
+
"#a5b4fc",
|
|
12
|
+
"#93c5fd",
|
|
13
|
+
"#86efac",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
let lastStickyColor = STICKY_COLORS[0];
|
|
17
|
+
|
|
18
|
+
export function getLastStickyColor(): string {
|
|
19
|
+
return lastStickyColor;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function setLastStickyColor(color: string): void {
|
|
23
|
+
lastStickyColor = color;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class StickyTool implements Tool {
|
|
27
|
+
name = "sticky" as const;
|
|
28
|
+
cursor = "crosshair";
|
|
29
|
+
|
|
30
|
+
onPointerDown(e: PointerEvent, ctx: ToolContext): void {
|
|
31
|
+
const { state, commands } = ctx;
|
|
32
|
+
const world = screenToWorld(e.clientX, e.clientY, state.viewport);
|
|
33
|
+
const el = createElement("sticky", {
|
|
34
|
+
x: world.x - 110,
|
|
35
|
+
y: world.y - 80,
|
|
36
|
+
width: 220,
|
|
37
|
+
height: 160,
|
|
38
|
+
zIndex: state.maxZIndex() + 1,
|
|
39
|
+
style: {
|
|
40
|
+
fill: getLastStickyColor(),
|
|
41
|
+
borderRadius: 12,
|
|
42
|
+
shadow: true,
|
|
43
|
+
fontSize: 15,
|
|
44
|
+
textColor: "#1f2933",
|
|
45
|
+
},
|
|
46
|
+
data: { text: "" },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
commands.execute(
|
|
50
|
+
"create-sticky",
|
|
51
|
+
() => {
|
|
52
|
+
if (!state.elementsById.has(el.id)) state.addElement(el);
|
|
53
|
+
state.emitChange();
|
|
54
|
+
},
|
|
55
|
+
() => {
|
|
56
|
+
state.removeElement(el.id);
|
|
57
|
+
state.emitChange();
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
state.clearSelection();
|
|
62
|
+
state.selectedIds.add(el.id);
|
|
63
|
+
ctx.setTool("select");
|
|
64
|
+
ctx.openTextEditor(el);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
onPointerMove(_e: PointerEvent, _ctx: ToolContext): void {}
|
|
68
|
+
onPointerUp(_e: PointerEvent, _ctx: ToolContext): void {}
|
|
69
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Tool, ToolContext } from "./ToolContext";
|
|
2
|
+
import { createElement } from "../elements/types";
|
|
3
|
+
import { screenToWorld } from "../canvas/Viewport";
|
|
4
|
+
|
|
5
|
+
export class TaskTool implements Tool {
|
|
6
|
+
name = "task" as const;
|
|
7
|
+
cursor = "crosshair";
|
|
8
|
+
|
|
9
|
+
onPointerDown(e: PointerEvent, ctx: ToolContext): void {
|
|
10
|
+
const { state, commands } = ctx;
|
|
11
|
+
const world = screenToWorld(e.clientX, e.clientY, state.viewport);
|
|
12
|
+
const isDark = state.theme === "dark";
|
|
13
|
+
const el = createElement("task", {
|
|
14
|
+
x: world.x - 140,
|
|
15
|
+
y: world.y - 28,
|
|
16
|
+
width: 280,
|
|
17
|
+
height: 56,
|
|
18
|
+
zIndex: state.maxZIndex() + 1,
|
|
19
|
+
style: {
|
|
20
|
+
fill: isDark ? "#17191b" : "#ffffff",
|
|
21
|
+
stroke: isDark ? "rgba(255,255,255,0.12)" : "rgba(0,0,0,0.09)",
|
|
22
|
+
strokeWidth: 1,
|
|
23
|
+
borderRadius: 14,
|
|
24
|
+
shadow: true,
|
|
25
|
+
fontSize: 15,
|
|
26
|
+
fontWeight: "650",
|
|
27
|
+
textColor: isDark ? "#f6f6f3" : "#252829",
|
|
28
|
+
},
|
|
29
|
+
data: { text: "Task title", checked: false },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
commands.execute(
|
|
33
|
+
"create-task",
|
|
34
|
+
() => {
|
|
35
|
+
if (!state.elementsById.has(el.id)) state.addElement(el);
|
|
36
|
+
state.emitChange();
|
|
37
|
+
},
|
|
38
|
+
() => {
|
|
39
|
+
state.removeElement(el.id);
|
|
40
|
+
state.emitChange();
|
|
41
|
+
}
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
state.clearSelection();
|
|
45
|
+
state.selectedIds.add(el.id);
|
|
46
|
+
ctx.setTool("select");
|
|
47
|
+
ctx.openTextEditor(el);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
onPointerMove(_e: PointerEvent, _ctx: ToolContext): void {}
|
|
51
|
+
onPointerUp(_e: PointerEvent, _ctx: ToolContext): void {}
|
|
52
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Tool, ToolContext } from "./ToolContext";
|
|
2
|
+
import { createElement } from "../elements/types";
|
|
3
|
+
import { screenToWorld } from "../canvas/Viewport";
|
|
4
|
+
|
|
5
|
+
export class TextTool implements Tool {
|
|
6
|
+
name = "text" as const;
|
|
7
|
+
cursor = "text";
|
|
8
|
+
|
|
9
|
+
onPointerDown(e: PointerEvent, ctx: ToolContext): void {
|
|
10
|
+
const { state, commands } = ctx;
|
|
11
|
+
const world = screenToWorld(e.clientX, e.clientY, state.viewport);
|
|
12
|
+
const el = createElement("text", {
|
|
13
|
+
x: world.x,
|
|
14
|
+
y: world.y - 12,
|
|
15
|
+
width: 240,
|
|
16
|
+
height: 28,
|
|
17
|
+
zIndex: state.maxZIndex() + 1,
|
|
18
|
+
style: {
|
|
19
|
+
fontSize: 16,
|
|
20
|
+
textColor: state.theme === "dark" ? "#f2f2f2" : "#1f2933",
|
|
21
|
+
},
|
|
22
|
+
data: { text: "" },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
commands.execute(
|
|
26
|
+
"create-text",
|
|
27
|
+
() => {
|
|
28
|
+
if (!state.elementsById.has(el.id)) state.addElement(el);
|
|
29
|
+
state.emitChange();
|
|
30
|
+
},
|
|
31
|
+
() => {
|
|
32
|
+
state.removeElement(el.id);
|
|
33
|
+
state.emitChange();
|
|
34
|
+
}
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
state.clearSelection();
|
|
38
|
+
state.selectedIds.add(el.id);
|
|
39
|
+
ctx.setTool("select");
|
|
40
|
+
ctx.openTextEditor(el);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
onPointerMove(_e: PointerEvent, _ctx: ToolContext): void {}
|
|
44
|
+
onPointerUp(_e: PointerEvent, _ctx: ToolContext): void {}
|
|
45
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { BoardState } from "../app/BoardState";
|
|
2
|
+
import type { CommandManager } from "../app/CommandManager";
|
|
3
|
+
import type { CanvasRenderer } from "../canvas/CanvasRenderer";
|
|
4
|
+
import type { CanvasSurface } from "../canvas/CanvasSurface";
|
|
5
|
+
import type { HitTester } from "../canvas/HitTester";
|
|
6
|
+
import type { BoardElement } from "../elements/types";
|
|
7
|
+
|
|
8
|
+
export type ToolName =
|
|
9
|
+
| "select"
|
|
10
|
+
| "hand"
|
|
11
|
+
| "sticky"
|
|
12
|
+
| "task"
|
|
13
|
+
| "rectangle"
|
|
14
|
+
| "ellipse"
|
|
15
|
+
| "diamond"
|
|
16
|
+
| "triangle"
|
|
17
|
+
| "pill"
|
|
18
|
+
| "chevron"
|
|
19
|
+
| "text"
|
|
20
|
+
| "arrow"
|
|
21
|
+
| "image";
|
|
22
|
+
|
|
23
|
+
export type ToolContext = {
|
|
24
|
+
state: BoardState;
|
|
25
|
+
renderer: CanvasRenderer;
|
|
26
|
+
surface: CanvasSurface;
|
|
27
|
+
hitTester: HitTester;
|
|
28
|
+
commands: CommandManager;
|
|
29
|
+
setTool: (tool: ToolName) => void;
|
|
30
|
+
setCursor: (cursor: string) => void;
|
|
31
|
+
openTextEditor: (el: BoardElement) => void;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export interface Tool {
|
|
35
|
+
name: ToolName;
|
|
36
|
+
cursor: string;
|
|
37
|
+
onPointerDown(e: PointerEvent, ctx: ToolContext): void;
|
|
38
|
+
onPointerMove(e: PointerEvent, ctx: ToolContext): void;
|
|
39
|
+
onPointerUp(e: PointerEvent, ctx: ToolContext): void;
|
|
40
|
+
onDoubleClick?(e: PointerEvent, ctx: ToolContext): void;
|
|
41
|
+
onCancel?(ctx: ToolContext): void;
|
|
42
|
+
onActivate?(ctx: ToolContext): void;
|
|
43
|
+
onDeactivate?(ctx: ToolContext): void;
|
|
44
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { Tool, ToolContext, ToolName } from "./ToolContext";
|
|
2
|
+
import { zoomAt } from "../canvas/Viewport";
|
|
3
|
+
|
|
4
|
+
const LINE_HEIGHT_PX = 16;
|
|
5
|
+
const PAGE_HEIGHT_PX = 720;
|
|
6
|
+
const MAX_WHEEL_DELTA = 120;
|
|
7
|
+
const ZOOM_SENSITIVITY = 0.0032;
|
|
8
|
+
|
|
9
|
+
export class ToolController {
|
|
10
|
+
private tools = new Map<ToolName, Tool>();
|
|
11
|
+
private active: Tool;
|
|
12
|
+
private ctx: ToolContext;
|
|
13
|
+
private target: HTMLElement;
|
|
14
|
+
|
|
15
|
+
/** spacebar / middle-mouse temporary pan */
|
|
16
|
+
private spaceDown = false;
|
|
17
|
+
private tempPanning = false;
|
|
18
|
+
private panLast = { x: 0, y: 0 };
|
|
19
|
+
|
|
20
|
+
private toolChangeListeners: ((tool: ToolName) => void)[] = [];
|
|
21
|
+
private signal?: AbortSignal;
|
|
22
|
+
|
|
23
|
+
constructor(target: HTMLElement, ctx: ToolContext, tools: Tool[], signal?: AbortSignal) {
|
|
24
|
+
this.target = target;
|
|
25
|
+
this.ctx = ctx;
|
|
26
|
+
this.signal = signal;
|
|
27
|
+
for (const tool of tools) this.tools.set(tool.name, tool);
|
|
28
|
+
this.active = this.tools.get("select")!;
|
|
29
|
+
this.bindEvents();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get activeName(): ToolName {
|
|
33
|
+
return this.active.name;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
onToolChange(fn: (tool: ToolName) => void): void {
|
|
37
|
+
this.toolChangeListeners.push(fn);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
setTool(name: ToolName): void {
|
|
41
|
+
if (this.active.name === name) {
|
|
42
|
+
// re-trigger activate for tools like image picker
|
|
43
|
+
this.active.onActivate?.(this.ctx);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
this.active.onDeactivate?.(this.ctx);
|
|
47
|
+
const tool = this.tools.get(name);
|
|
48
|
+
if (!tool) return;
|
|
49
|
+
this.active = tool;
|
|
50
|
+
this.ctx.setCursor(tool.cursor);
|
|
51
|
+
tool.onActivate?.(this.ctx);
|
|
52
|
+
for (const fn of this.toolChangeListeners) fn(name);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
cancel(): void {
|
|
56
|
+
this.active.onCancel?.(this.ctx);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private bindEvents(): void {
|
|
60
|
+
const t = this.target;
|
|
61
|
+
|
|
62
|
+
t.addEventListener("pointerdown", (e) => {
|
|
63
|
+
// middle mouse, right mouse, or spacebar temporarily pan.
|
|
64
|
+
if (e.button === 1 || e.button === 2 || this.spaceDown) {
|
|
65
|
+
e.preventDefault();
|
|
66
|
+
this.tempPanning = true;
|
|
67
|
+
this.panLast = { x: e.clientX, y: e.clientY };
|
|
68
|
+
this.ctx.setCursor("grabbing");
|
|
69
|
+
t.setPointerCapture(e.pointerId);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (e.button !== 0) return;
|
|
73
|
+
t.setPointerCapture(e.pointerId);
|
|
74
|
+
this.active.onPointerDown(e, this.ctx);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
t.addEventListener("pointermove", (e) => {
|
|
78
|
+
if (this.tempPanning) {
|
|
79
|
+
const vp = this.ctx.state.viewport;
|
|
80
|
+
vp.offsetX += e.clientX - this.panLast.x;
|
|
81
|
+
vp.offsetY += e.clientY - this.panLast.y;
|
|
82
|
+
this.panLast = { x: e.clientX, y: e.clientY };
|
|
83
|
+
this.ctx.state.requestRender();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
this.active.onPointerMove(e, this.ctx);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
t.addEventListener("pointerup", (e) => {
|
|
90
|
+
if (this.tempPanning) {
|
|
91
|
+
this.tempPanning = false;
|
|
92
|
+
this.ctx.setCursor(this.spaceDown ? "grab" : this.active.cursor);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
this.active.onPointerUp(e, this.ctx);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
t.addEventListener("pointercancel", () => {
|
|
99
|
+
if (!this.tempPanning) return;
|
|
100
|
+
this.tempPanning = false;
|
|
101
|
+
this.ctx.setCursor(this.spaceDown ? "grab" : this.active.cursor);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
t.addEventListener("dblclick", (e) => {
|
|
105
|
+
this.active.onDoubleClick?.(e as unknown as PointerEvent, this.ctx);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// wheel: zoom (ctrl/cmd or pinch) and trackpad pan
|
|
109
|
+
t.addEventListener(
|
|
110
|
+
"wheel",
|
|
111
|
+
(e) => {
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
const vp = this.ctx.state.viewport;
|
|
114
|
+
const dx = normalizeWheelDelta(e.deltaX, e.deltaMode, this.ctx.state.viewport.scale);
|
|
115
|
+
const dy = normalizeWheelDelta(e.deltaY, e.deltaMode, this.ctx.state.viewport.scale);
|
|
116
|
+
if (e.ctrlKey || e.metaKey) {
|
|
117
|
+
// pinch-zoom or ctrl+wheel: cursor-centered zoom with normalized deltas
|
|
118
|
+
const factor = Math.exp(-clampWheelDelta(dy) * ZOOM_SENSITIVITY);
|
|
119
|
+
zoomAt(vp, e.clientX, e.clientY, vp.scale * factor);
|
|
120
|
+
} else {
|
|
121
|
+
// trackpad two-finger pan / mouse wheel scroll.
|
|
122
|
+
// Shift+wheel pans horizontally to make wide boards easier to navigate.
|
|
123
|
+
if (e.shiftKey && Math.abs(dx) < Math.abs(dy)) {
|
|
124
|
+
vp.offsetX -= dy;
|
|
125
|
+
} else {
|
|
126
|
+
vp.offsetX -= dx;
|
|
127
|
+
vp.offsetY -= dy;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
this.ctx.state.requestRender();
|
|
131
|
+
},
|
|
132
|
+
{ passive: false }
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// spacebar temporary hand tool
|
|
136
|
+
window.addEventListener("keydown", (e) => {
|
|
137
|
+
if (e.code === "Space" && !this.isEditingText(e)) {
|
|
138
|
+
if (!this.spaceDown) {
|
|
139
|
+
this.spaceDown = true;
|
|
140
|
+
if (!this.tempPanning) this.ctx.setCursor("grab");
|
|
141
|
+
}
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
}
|
|
144
|
+
}, { signal: this.signal });
|
|
145
|
+
|
|
146
|
+
window.addEventListener("keyup", (e) => {
|
|
147
|
+
if (e.code === "Space") {
|
|
148
|
+
this.spaceDown = false;
|
|
149
|
+
if (!this.tempPanning) this.ctx.setCursor(this.active.cursor);
|
|
150
|
+
}
|
|
151
|
+
}, { signal: this.signal });
|
|
152
|
+
|
|
153
|
+
// prevent context menu so right-drag can pan cleanly.
|
|
154
|
+
t.addEventListener("contextmenu", (e) => e.preventDefault());
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private isEditingText(e: KeyboardEvent): boolean {
|
|
158
|
+
const target = e.target as HTMLElement | null;
|
|
159
|
+
return (
|
|
160
|
+
!!target &&
|
|
161
|
+
(target.tagName === "TEXTAREA" ||
|
|
162
|
+
target.tagName === "INPUT" ||
|
|
163
|
+
target.isContentEditable)
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function normalizeWheelDelta(delta: number, deltaMode: number, scale: number): number {
|
|
169
|
+
const modeFactor = deltaMode === 1 ? LINE_HEIGHT_PX : deltaMode === 2 ? PAGE_HEIGHT_PX : 1;
|
|
170
|
+
const pixels = delta * modeFactor;
|
|
171
|
+
// When zoomed far out, slightly reduce pan velocity to avoid overshooting.
|
|
172
|
+
const zoomFriction = scale < 0.5 ? 0.8 : scale > 2 ? 1.08 : 1;
|
|
173
|
+
return pixels * zoomFriction;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function clampWheelDelta(delta: number): number {
|
|
177
|
+
return Math.max(-MAX_WHEEL_DELTA, Math.min(MAX_WHEEL_DELTA, delta));
|
|
178
|
+
}
|
package/src/ui/Modal.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal modal helper. Content is built with DOM APIs (no user innerHTML).
|
|
3
|
+
*/
|
|
4
|
+
export function openModal(title: string, build: (body: HTMLDivElement, close: () => void) => void): void {
|
|
5
|
+
const backdrop = document.createElement("div");
|
|
6
|
+
backdrop.className = "modal-backdrop";
|
|
7
|
+
|
|
8
|
+
const modal = document.createElement("div");
|
|
9
|
+
modal.className = "modal";
|
|
10
|
+
modal.setAttribute("role", "dialog");
|
|
11
|
+
modal.setAttribute("aria-label", title);
|
|
12
|
+
|
|
13
|
+
const closeBtn = document.createElement("button");
|
|
14
|
+
closeBtn.className = "modal-close";
|
|
15
|
+
closeBtn.textContent = "✕";
|
|
16
|
+
closeBtn.setAttribute("aria-label", "Close");
|
|
17
|
+
|
|
18
|
+
const heading = document.createElement("h2");
|
|
19
|
+
heading.textContent = title;
|
|
20
|
+
|
|
21
|
+
const body = document.createElement("div");
|
|
22
|
+
|
|
23
|
+
const close = () => backdrop.remove();
|
|
24
|
+
closeBtn.addEventListener("click", close);
|
|
25
|
+
backdrop.addEventListener("click", (e) => {
|
|
26
|
+
if (e.target === backdrop) close();
|
|
27
|
+
});
|
|
28
|
+
backdrop.addEventListener("keydown", (e) => {
|
|
29
|
+
e.stopPropagation();
|
|
30
|
+
if (e.key === "Escape") close();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
modal.appendChild(closeBtn);
|
|
34
|
+
modal.appendChild(heading);
|
|
35
|
+
modal.appendChild(body);
|
|
36
|
+
backdrop.appendChild(modal);
|
|
37
|
+
document.body.appendChild(backdrop);
|
|
38
|
+
|
|
39
|
+
build(body, close);
|
|
40
|
+
closeBtn.focus();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let toastEl: HTMLDivElement | null = null;
|
|
44
|
+
let toastTimer: number | null = null;
|
|
45
|
+
|
|
46
|
+
export function showToast(message: string): void {
|
|
47
|
+
if (!toastEl) {
|
|
48
|
+
toastEl = document.createElement("div");
|
|
49
|
+
toastEl.className = "toast";
|
|
50
|
+
document.body.appendChild(toastEl);
|
|
51
|
+
}
|
|
52
|
+
toastEl.textContent = message;
|
|
53
|
+
toastEl.classList.add("visible");
|
|
54
|
+
if (toastTimer !== null) clearTimeout(toastTimer);
|
|
55
|
+
toastTimer = window.setTimeout(() => {
|
|
56
|
+
toastEl?.classList.remove("visible");
|
|
57
|
+
}, 2600);
|
|
58
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { BoardState } from "../app/BoardState";
|
|
2
|
+
import type { CanvasRenderer } from "../canvas/CanvasRenderer";
|
|
3
|
+
import type { HitTester } from "../canvas/HitTester";
|
|
4
|
+
import type { PerformanceMonitor } from "../canvas/PerformanceMonitor";
|
|
5
|
+
import type { ImageCache } from "../canvas/ImageCache";
|
|
6
|
+
import { boardStore } from "../storage/LocalBoardStore";
|
|
7
|
+
import { assetStore } from "../storage/AssetStore";
|
|
8
|
+
|
|
9
|
+
export class PerformanceOverlay {
|
|
10
|
+
element: HTMLDivElement;
|
|
11
|
+
visible = false;
|
|
12
|
+
|
|
13
|
+
private state: BoardState;
|
|
14
|
+
private renderer: CanvasRenderer;
|
|
15
|
+
private hitTester: HitTester;
|
|
16
|
+
private monitor: PerformanceMonitor;
|
|
17
|
+
private imageCache: ImageCache;
|
|
18
|
+
private timer: number | null = null;
|
|
19
|
+
private storageEstimate = "–";
|
|
20
|
+
|
|
21
|
+
constructor(
|
|
22
|
+
parent: HTMLElement,
|
|
23
|
+
state: BoardState,
|
|
24
|
+
renderer: CanvasRenderer,
|
|
25
|
+
hitTester: HitTester,
|
|
26
|
+
monitor: PerformanceMonitor,
|
|
27
|
+
imageCache: ImageCache
|
|
28
|
+
) {
|
|
29
|
+
this.state = state;
|
|
30
|
+
this.renderer = renderer;
|
|
31
|
+
this.hitTester = hitTester;
|
|
32
|
+
this.monitor = monitor;
|
|
33
|
+
this.imageCache = imageCache;
|
|
34
|
+
this.element = document.createElement("div");
|
|
35
|
+
this.element.className = "perf-overlay";
|
|
36
|
+
parent.appendChild(this.element);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
toggle(): void {
|
|
40
|
+
this.visible = !this.visible;
|
|
41
|
+
this.element.classList.toggle("visible", this.visible);
|
|
42
|
+
if (this.visible) {
|
|
43
|
+
this.monitor.start();
|
|
44
|
+
this.update();
|
|
45
|
+
this.timer = window.setInterval(() => this.update(), 500);
|
|
46
|
+
void this.refreshStorage();
|
|
47
|
+
} else if (this.timer !== null) {
|
|
48
|
+
clearInterval(this.timer);
|
|
49
|
+
this.timer = null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private async refreshStorage(): Promise<void> {
|
|
54
|
+
const bytes = await assetStore.totalSizeBytes();
|
|
55
|
+
this.storageEstimate = (bytes / (1024 * 1024)).toFixed(1) + "MB";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private update(): void {
|
|
59
|
+
const vp = this.state.viewport;
|
|
60
|
+
const lines = [
|
|
61
|
+
`FPS: ${this.monitor.fps}`,
|
|
62
|
+
`Total elements: ${this.state.elements.length.toLocaleString()}`,
|
|
63
|
+
`Visible elements: ${this.renderer.visibleCount.toLocaleString()}`,
|
|
64
|
+
`Render time: ${this.renderer.lastRenderMs.toFixed(1)}ms`,
|
|
65
|
+
`Hit-test time: ${this.hitTester.lastHitTestMs.toFixed(2)}ms`,
|
|
66
|
+
`Save time: ${boardStore.lastSaveMs.toFixed(0)}ms`,
|
|
67
|
+
`Image cache: ${this.imageCache.size}`,
|
|
68
|
+
`Estimated storage: ${this.storageEstimate}`,
|
|
69
|
+
`Zoom: ${Math.round(vp.scale * 100)}%`,
|
|
70
|
+
`Viewport: x: ${Math.round(-vp.offsetX / vp.scale)}, y: ${Math.round(-vp.offsetY / vp.scale)}`,
|
|
71
|
+
`Culling: ${this.renderer.cullingEnabled ? "on" : "off"} LOD: ${this.renderer.lodEnabled ? "on" : "off"}`,
|
|
72
|
+
];
|
|
73
|
+
this.element.textContent = lines.join("\n");
|
|
74
|
+
}
|
|
75
|
+
}
|