viteboard 0.1.0 → 0.1.2
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/README.md +110 -61
- package/content/docs/guide/boards-in-markdown.md +28 -26
- package/content/docs/guide/boards.md +44 -82
- package/content/docs/guide/getting-started.md +36 -48
- package/content/docs/index.md +21 -32
- package/content/docs/product-roadmap.board.json +4 -4
- package/content/machine/board-v1.schema.json +138 -0
- package/content/machine/example.board.json +217 -0
- package/content/machine/guide.md +270 -0
- package/content/machine/llms.txt +31 -0
- package/content/machine/viteboard.json +62 -0
- package/dist/assets/index-D2W76uu9.js +96 -0
- package/dist/assets/index-rMkUjGXP.css +1 -0
- package/dist/index.html +4 -2
- package/package.json +10 -12
- package/vite.config.ts +635 -48
- package/content/docs/capabilities.md +0 -89
- package/content/docs/docs.md +0 -84
- package/content/docs/guide/test.md +0 -6
- package/content/docs/test.md +0 -219
- package/content/docs/tetst-dir/test-board.board.json +0 -15
- package/content/docs/tetst-dir/test-test.md +0 -1
- package/content/docs/workspace.md +0 -73
- package/dist/assets/index-CEpxLM2o.css +0 -1
- package/dist/assets/index-D4xvJdEQ.js +0 -60
- package/index.html +0 -13
- package/src/app/AppController.ts +0 -955
- package/src/app/BoardState.ts +0 -130
- package/src/app/ClipboardService.ts +0 -159
- package/src/app/CommandManager.ts +0 -66
- package/src/app/ElementActions.ts +0 -152
- package/src/app/EmbedRegionSelector.ts +0 -103
- package/src/app/ExportPng.ts +0 -107
- package/src/app/ImageInsertService.ts +0 -141
- package/src/app/KeyboardShortcuts.ts +0 -124
- package/src/app/main.ts +0 -6
- package/src/board/BoardPreview.ts +0 -189
- package/src/board/BoardService.ts +0 -222
- package/src/canvas/CanvasRenderer.ts +0 -253
- package/src/canvas/CanvasSurface.ts +0 -70
- package/src/canvas/HitTester.ts +0 -110
- package/src/canvas/ImageCache.ts +0 -123
- package/src/canvas/PerformanceMonitor.ts +0 -31
- package/src/canvas/RenderScheduler.ts +0 -26
- package/src/canvas/Viewport.ts +0 -77
- package/src/content/ContentApi.ts +0 -258
- package/src/content/Markdown.ts +0 -431
- package/src/content/MarkdownView.ts +0 -70
- package/src/editor/DocEditor.ts +0 -799
- package/src/editor/htmlToMarkdown.ts +0 -333
- package/src/elements/renderElement.ts +0 -509
- package/src/elements/types.ts +0 -118
- package/src/shell/Shell.ts +0 -2950
- package/src/shell/Sidebar.ts +0 -1352
- package/src/storage/AssetStore.ts +0 -86
- package/src/storage/BoardSerializer.ts +0 -114
- package/src/storage/ImportExportService.ts +0 -153
- package/src/storage/IndexedDbStore.ts +0 -92
- package/src/storage/LocalBoardStore.ts +0 -104
- package/src/styles.css +0 -3257
- package/src/templates/helpers.ts +0 -124
- package/src/templates/index.ts +0 -65
- package/src/templates/journeyMapTemplate.ts +0 -52
- package/src/templates/opportunityTreeTemplate.ts +0 -45
- package/src/templates/personaTemplate.ts +0 -41
- package/src/templates/prioritizationMatrixTemplate.ts +0 -44
- package/src/templates/requirementsFlowTemplate.ts +0 -45
- package/src/templates/screenshotReviewTemplate.ts +0 -52
- package/src/templates/systemDiagramTemplate.ts +0 -41
- package/src/testing/StressTestGenerator.ts +0 -134
- package/src/testing/StressTestPanel.ts +0 -64
- package/src/tools/ArrowTool.ts +0 -87
- package/src/tools/ImageTool.ts +0 -39
- package/src/tools/PanTool.ts +0 -34
- package/src/tools/SelectTool.ts +0 -377
- package/src/tools/ShapeTool.ts +0 -106
- package/src/tools/StickyTool.ts +0 -69
- package/src/tools/TaskTool.ts +0 -52
- package/src/tools/TextTool.ts +0 -45
- package/src/tools/ToolContext.ts +0 -44
- package/src/tools/ToolController.ts +0 -178
- package/src/ui/Modal.ts +0 -58
- package/src/ui/PerformanceOverlay.ts +0 -75
- package/src/ui/StorageManager.ts +0 -94
- package/src/ui/TemplatePicker.ts +0 -67
- package/src/ui/TextEditorOverlay.ts +0 -137
- package/src/ui/Toolbar.ts +0 -151
- package/src/ui/TopControls.ts +0 -137
- package/src/ui/icons.ts +0 -50
- package/tsconfig.json +0 -19
package/src/tools/ShapeTool.ts
DELETED
|
@@ -1,106 +0,0 @@
|
|
|
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
|
-
}
|
package/src/tools/StickyTool.ts
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
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
|
-
}
|
package/src/tools/TaskTool.ts
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
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
|
-
}
|
package/src/tools/TextTool.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
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
|
-
}
|
package/src/tools/ToolContext.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,178 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,75 +0,0 @@
|
|
|
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
|
-
}
|