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,130 @@
|
|
|
1
|
+
import type { BoardElement } from "../elements/types";
|
|
2
|
+
import type { Viewport, ViewportBounds } from "../canvas/Viewport";
|
|
3
|
+
import { DEFAULT_ZOOM } from "../canvas/Viewport";
|
|
4
|
+
|
|
5
|
+
export type Theme = "light" | "dark";
|
|
6
|
+
|
|
7
|
+
type Listener = () => void;
|
|
8
|
+
|
|
9
|
+
export class BoardState {
|
|
10
|
+
boardId = "default-board";
|
|
11
|
+
title = "Untitled Board";
|
|
12
|
+
createdAt = Date.now();
|
|
13
|
+
|
|
14
|
+
viewport: Viewport = { offsetX: 0, offsetY: 0, scale: DEFAULT_ZOOM };
|
|
15
|
+
theme: Theme = "light";
|
|
16
|
+
|
|
17
|
+
elements: BoardElement[] = [];
|
|
18
|
+
elementsById = new Map<string, BoardElement>();
|
|
19
|
+
selectedIds = new Set<string>();
|
|
20
|
+
hoveredId: string | null = null;
|
|
21
|
+
|
|
22
|
+
/** sorted render cache */
|
|
23
|
+
private sortedCache: BoardElement[] | null = null;
|
|
24
|
+
|
|
25
|
+
private changeListeners: Listener[] = [];
|
|
26
|
+
private renderListeners: Listener[] = [];
|
|
27
|
+
|
|
28
|
+
// ---------- change events ----------
|
|
29
|
+
|
|
30
|
+
onChange(fn: Listener): void {
|
|
31
|
+
this.changeListeners.push(fn);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
onRenderNeeded(fn: Listener): void {
|
|
35
|
+
this.renderListeners.push(fn);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** structural/document change: triggers autosave + render */
|
|
39
|
+
emitChange(): void {
|
|
40
|
+
this.sortedCache = null;
|
|
41
|
+
for (const fn of this.changeListeners) fn();
|
|
42
|
+
this.requestRender();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** visual-only change: triggers render only */
|
|
46
|
+
requestRender(): void {
|
|
47
|
+
for (const fn of this.renderListeners) fn();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------- elements ----------
|
|
51
|
+
|
|
52
|
+
addElement(el: BoardElement): void {
|
|
53
|
+
this.elements.push(el);
|
|
54
|
+
this.elementsById.set(el.id, el);
|
|
55
|
+
this.sortedCache = null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
removeElement(id: string): BoardElement | undefined {
|
|
59
|
+
const el = this.elementsById.get(id);
|
|
60
|
+
if (!el) return undefined;
|
|
61
|
+
this.elementsById.delete(id);
|
|
62
|
+
const idx = this.elements.indexOf(el);
|
|
63
|
+
if (idx >= 0) this.elements.splice(idx, 1);
|
|
64
|
+
this.selectedIds.delete(id);
|
|
65
|
+
this.sortedCache = null;
|
|
66
|
+
return el;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
getSorted(): BoardElement[] {
|
|
70
|
+
if (!this.sortedCache) {
|
|
71
|
+
this.sortedCache = [...this.elements].sort((a, b) => a.zIndex - b.zIndex);
|
|
72
|
+
}
|
|
73
|
+
return this.sortedCache;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
invalidateSort(): void {
|
|
77
|
+
this.sortedCache = null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
maxZIndex(): number {
|
|
81
|
+
let max = 0;
|
|
82
|
+
for (const el of this.elements) if (el.zIndex > max) max = el.zIndex;
|
|
83
|
+
return max;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
minZIndex(): number {
|
|
87
|
+
let min = 0;
|
|
88
|
+
for (const el of this.elements) if (el.zIndex < min) min = el.zIndex;
|
|
89
|
+
return min;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
getSelected(): BoardElement[] {
|
|
93
|
+
const out: BoardElement[] = [];
|
|
94
|
+
for (const id of this.selectedIds) {
|
|
95
|
+
const el = this.elementsById.get(id);
|
|
96
|
+
if (el) out.push(el);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
clearSelection(): void {
|
|
102
|
+
this.selectedIds.clear();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
getVisibleElements(bounds: ViewportBounds): BoardElement[] {
|
|
106
|
+
const out: BoardElement[] = [];
|
|
107
|
+
for (const el of this.getSorted()) {
|
|
108
|
+
if (el.hidden) continue;
|
|
109
|
+
if (isElementVisible(el, bounds)) out.push(el);
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function isElementVisible(
|
|
116
|
+
element: BoardElement,
|
|
117
|
+
bounds: ViewportBounds
|
|
118
|
+
): boolean {
|
|
119
|
+
// lines/arrows can have negative w/h semantics; use normalized bbox
|
|
120
|
+
const x1 = Math.min(element.x, element.x + element.width);
|
|
121
|
+
const x2 = Math.max(element.x, element.x + element.width);
|
|
122
|
+
const y1 = Math.min(element.y, element.y + element.height);
|
|
123
|
+
const y2 = Math.max(element.y, element.y + element.height);
|
|
124
|
+
return !(
|
|
125
|
+
x2 < bounds.left ||
|
|
126
|
+
x1 > bounds.right ||
|
|
127
|
+
y2 < bounds.top ||
|
|
128
|
+
y1 > bounds.bottom
|
|
129
|
+
);
|
|
130
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { BoardState } from "./BoardState";
|
|
2
|
+
import type { ElementActions } from "./ElementActions";
|
|
3
|
+
import type { ImageInsertService } from "./ImageInsertService";
|
|
4
|
+
import { createElement, type BoardElement } from "../elements/types";
|
|
5
|
+
import { validateElement } from "../storage/BoardSerializer";
|
|
6
|
+
import { screenToWorld } from "../canvas/Viewport";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Paste priority: board elements (internal) > image data > plain text.
|
|
10
|
+
*/
|
|
11
|
+
export class ClipboardService {
|
|
12
|
+
private state: BoardState;
|
|
13
|
+
private actions: ElementActions;
|
|
14
|
+
private imageInsert: ImageInsertService;
|
|
15
|
+
private internalClipboard: BoardElement[] = [];
|
|
16
|
+
lastPointer = { x: window.innerWidth / 2, y: window.innerHeight / 2 };
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
state: BoardState,
|
|
20
|
+
actions: ElementActions,
|
|
21
|
+
imageInsert: ImageInsertService,
|
|
22
|
+
signal?: AbortSignal
|
|
23
|
+
) {
|
|
24
|
+
this.state = state;
|
|
25
|
+
this.actions = actions;
|
|
26
|
+
this.imageInsert = imageInsert;
|
|
27
|
+
|
|
28
|
+
window.addEventListener("pointermove", (e) => {
|
|
29
|
+
this.lastPointer = { x: e.clientX, y: e.clientY };
|
|
30
|
+
}, { signal });
|
|
31
|
+
|
|
32
|
+
window.addEventListener("paste", (e) => {
|
|
33
|
+
if (this.isEditingText(e.target)) return;
|
|
34
|
+
void this.handlePaste(e as ClipboardEvent);
|
|
35
|
+
}, { signal });
|
|
36
|
+
|
|
37
|
+
window.addEventListener("copy", (e) => {
|
|
38
|
+
if (this.isEditingText(e.target)) return;
|
|
39
|
+
if (this.copySelection()) e.preventDefault();
|
|
40
|
+
}, { signal });
|
|
41
|
+
|
|
42
|
+
window.addEventListener("cut", (e) => {
|
|
43
|
+
if (this.isEditingText(e.target)) return;
|
|
44
|
+
if (this.copySelection()) {
|
|
45
|
+
this.actions.deleteSelection();
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
}
|
|
48
|
+
}, { signal });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private isEditingText(target: EventTarget | null): boolean {
|
|
52
|
+
const el = target as HTMLElement | null;
|
|
53
|
+
return (
|
|
54
|
+
!!el &&
|
|
55
|
+
(el.tagName === "TEXTAREA" || el.tagName === "INPUT" || el.isContentEditable)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
copySelection(): boolean {
|
|
60
|
+
const selected = this.state.getSelected();
|
|
61
|
+
if (selected.length === 0) return false;
|
|
62
|
+
this.internalClipboard = selected.map((el) => ({
|
|
63
|
+
...el,
|
|
64
|
+
style: { ...el.style },
|
|
65
|
+
data: { ...el.data },
|
|
66
|
+
}));
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private async handlePaste(e: ClipboardEvent): Promise<void> {
|
|
71
|
+
const items = e.clipboardData?.items;
|
|
72
|
+
|
|
73
|
+
// 1. image data
|
|
74
|
+
if (items) {
|
|
75
|
+
for (const item of items) {
|
|
76
|
+
if (item.type.startsWith("image/")) {
|
|
77
|
+
const blob = item.getAsFile();
|
|
78
|
+
if (blob) {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
await this.imageInsert.insertBlob(
|
|
81
|
+
blob,
|
|
82
|
+
this.lastPointer.x,
|
|
83
|
+
this.lastPointer.y,
|
|
84
|
+
"Pasted screenshot"
|
|
85
|
+
);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 2. internal board elements
|
|
93
|
+
if (this.internalClipboard.length > 0) {
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
this.pasteInternal();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 3. plain text
|
|
100
|
+
const text = e.clipboardData?.getData("text/plain");
|
|
101
|
+
if (text && text.trim()) {
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
this.pasteText(text.trim());
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private pasteInternal(): void {
|
|
108
|
+
const world = screenToWorld(this.lastPointer.x, this.lastPointer.y, this.state.viewport);
|
|
109
|
+
|
|
110
|
+
// center of clipboard contents
|
|
111
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
112
|
+
for (const el of this.internalClipboard) {
|
|
113
|
+
minX = Math.min(minX, Math.min(el.x, el.x + el.width));
|
|
114
|
+
minY = Math.min(minY, Math.min(el.y, el.y + el.height));
|
|
115
|
+
maxX = Math.max(maxX, Math.max(el.x, el.x + el.width));
|
|
116
|
+
maxY = Math.max(maxY, Math.max(el.y, el.y + el.height));
|
|
117
|
+
}
|
|
118
|
+
const cx = (minX + maxX) / 2;
|
|
119
|
+
const cy = (minY + maxY) / 2;
|
|
120
|
+
|
|
121
|
+
let z = this.state.maxZIndex();
|
|
122
|
+
const clones: BoardElement[] = [];
|
|
123
|
+
for (const raw of this.internalClipboard) {
|
|
124
|
+
const validated = validateElement(raw);
|
|
125
|
+
if (!validated) continue;
|
|
126
|
+
const clone = this.actions.cloneElement(validated, 0);
|
|
127
|
+
clone.x = validated.x + (world.x - cx);
|
|
128
|
+
clone.y = validated.y + (world.y - cy);
|
|
129
|
+
clone.zIndex = ++z;
|
|
130
|
+
clones.push(clone);
|
|
131
|
+
}
|
|
132
|
+
if (clones.length === 0) return;
|
|
133
|
+
|
|
134
|
+
this.actions.insertElements(clones, "paste");
|
|
135
|
+
this.state.clearSelection();
|
|
136
|
+
for (const clone of clones) this.state.selectedIds.add(clone.id);
|
|
137
|
+
this.state.requestRender();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private pasteText(text: string): void {
|
|
141
|
+
const world = screenToWorld(this.lastPointer.x, this.lastPointer.y, this.state.viewport);
|
|
142
|
+
const el = createElement("text", {
|
|
143
|
+
x: world.x,
|
|
144
|
+
y: world.y,
|
|
145
|
+
width: Math.min(420, Math.max(160, text.length * 8)),
|
|
146
|
+
height: 28,
|
|
147
|
+
zIndex: this.state.maxZIndex() + 1,
|
|
148
|
+
style: {
|
|
149
|
+
fontSize: 16,
|
|
150
|
+
textColor: this.state.theme === "dark" ? "#f2f2f2" : "#1f2933",
|
|
151
|
+
},
|
|
152
|
+
data: { text: text.slice(0, 5000) },
|
|
153
|
+
});
|
|
154
|
+
this.actions.insertElements([el], "paste-text");
|
|
155
|
+
this.state.clearSelection();
|
|
156
|
+
this.state.selectedIds.add(el.id);
|
|
157
|
+
this.state.requestRender();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { generateId } from "../elements/types";
|
|
2
|
+
|
|
3
|
+
export type Command = {
|
|
4
|
+
id: string;
|
|
5
|
+
type: string;
|
|
6
|
+
timestamp: number;
|
|
7
|
+
do: () => void;
|
|
8
|
+
undo: () => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const MAX_HISTORY = 200;
|
|
12
|
+
|
|
13
|
+
export class CommandManager {
|
|
14
|
+
private undoStack: Command[] = [];
|
|
15
|
+
private redoStack: Command[] = [];
|
|
16
|
+
|
|
17
|
+
/** Execute a command and push it onto the undo stack. */
|
|
18
|
+
execute(type: string, doFn: () => void, undoFn: () => void): void {
|
|
19
|
+
const cmd: Command = {
|
|
20
|
+
id: generateId(),
|
|
21
|
+
type,
|
|
22
|
+
timestamp: Date.now(),
|
|
23
|
+
do: doFn,
|
|
24
|
+
undo: undoFn,
|
|
25
|
+
};
|
|
26
|
+
cmd.do();
|
|
27
|
+
this.undoStack.push(cmd);
|
|
28
|
+
if (this.undoStack.length > MAX_HISTORY) this.undoStack.shift();
|
|
29
|
+
this.redoStack.length = 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Push an already-applied change (e.g. completed drag) without re-running it. */
|
|
33
|
+
push(type: string, doFn: () => void, undoFn: () => void): void {
|
|
34
|
+
const cmd: Command = {
|
|
35
|
+
id: generateId(),
|
|
36
|
+
type,
|
|
37
|
+
timestamp: Date.now(),
|
|
38
|
+
do: doFn,
|
|
39
|
+
undo: undoFn,
|
|
40
|
+
};
|
|
41
|
+
this.undoStack.push(cmd);
|
|
42
|
+
if (this.undoStack.length > MAX_HISTORY) this.undoStack.shift();
|
|
43
|
+
this.redoStack.length = 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
undo(): boolean {
|
|
47
|
+
const cmd = this.undoStack.pop();
|
|
48
|
+
if (!cmd) return false;
|
|
49
|
+
cmd.undo();
|
|
50
|
+
this.redoStack.push(cmd);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
redo(): boolean {
|
|
55
|
+
const cmd = this.redoStack.pop();
|
|
56
|
+
if (!cmd) return false;
|
|
57
|
+
cmd.do();
|
|
58
|
+
this.undoStack.push(cmd);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
clear(): void {
|
|
63
|
+
this.undoStack.length = 0;
|
|
64
|
+
this.redoStack.length = 0;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { BoardState } from "./BoardState";
|
|
2
|
+
import type { CommandManager } from "./CommandManager";
|
|
3
|
+
import type { BoardElement } from "../elements/types";
|
|
4
|
+
import { generateId } from "../elements/types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shared element operations: delete, duplicate, layering. All undoable.
|
|
8
|
+
*/
|
|
9
|
+
export class ElementActions {
|
|
10
|
+
private state: BoardState;
|
|
11
|
+
private commands: CommandManager;
|
|
12
|
+
|
|
13
|
+
constructor(state: BoardState, commands: CommandManager) {
|
|
14
|
+
this.state = state;
|
|
15
|
+
this.commands = commands;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
deleteSelection(): void {
|
|
19
|
+
const selected = this.state.getSelected().filter((el) => !el.locked);
|
|
20
|
+
if (selected.length === 0) return;
|
|
21
|
+
const state = this.state;
|
|
22
|
+
const copies = selected.map((el) => el);
|
|
23
|
+
|
|
24
|
+
this.commands.execute(
|
|
25
|
+
"delete",
|
|
26
|
+
() => {
|
|
27
|
+
for (const el of copies) state.removeElement(el.id);
|
|
28
|
+
state.emitChange();
|
|
29
|
+
},
|
|
30
|
+
() => {
|
|
31
|
+
for (const el of copies) {
|
|
32
|
+
if (!state.elementsById.has(el.id)) state.addElement(el);
|
|
33
|
+
}
|
|
34
|
+
state.emitChange();
|
|
35
|
+
}
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
duplicateSelection(offset = 24): BoardElement[] {
|
|
40
|
+
const selected = this.state.getSelected();
|
|
41
|
+
if (selected.length === 0) return [];
|
|
42
|
+
const clones = selected.map((el) => this.cloneElement(el, offset));
|
|
43
|
+
this.insertElements(clones, "duplicate");
|
|
44
|
+
this.state.clearSelection();
|
|
45
|
+
for (const clone of clones) this.state.selectedIds.add(clone.id);
|
|
46
|
+
this.state.requestRender();
|
|
47
|
+
return clones;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
cloneElement(el: BoardElement, offset: number): BoardElement {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
return {
|
|
53
|
+
...el,
|
|
54
|
+
id: generateId(),
|
|
55
|
+
x: el.x + offset,
|
|
56
|
+
y: el.y + offset,
|
|
57
|
+
zIndex: this.state.maxZIndex() + 1,
|
|
58
|
+
createdAt: now,
|
|
59
|
+
updatedAt: now,
|
|
60
|
+
style: { ...el.style },
|
|
61
|
+
data: { ...el.data },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
insertElements(elements: BoardElement[], commandType: string): void {
|
|
66
|
+
const state = this.state;
|
|
67
|
+
this.commands.execute(
|
|
68
|
+
commandType,
|
|
69
|
+
() => {
|
|
70
|
+
for (const el of elements) {
|
|
71
|
+
if (!state.elementsById.has(el.id)) state.addElement(el);
|
|
72
|
+
}
|
|
73
|
+
state.emitChange();
|
|
74
|
+
},
|
|
75
|
+
() => {
|
|
76
|
+
for (const el of elements) state.removeElement(el.id);
|
|
77
|
+
state.emitChange();
|
|
78
|
+
}
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
nudgeSelection(dx: number, dy: number): void {
|
|
83
|
+
const selected = this.state.getSelected().filter((el) => !el.locked);
|
|
84
|
+
if (selected.length === 0) return;
|
|
85
|
+
const state = this.state;
|
|
86
|
+
const before = selected.map((el) => ({ id: el.id, x: el.x, y: el.y, updatedAt: el.updatedAt }));
|
|
87
|
+
const after = selected.map((el) => ({
|
|
88
|
+
id: el.id,
|
|
89
|
+
x: el.x + dx,
|
|
90
|
+
y: el.y + dy,
|
|
91
|
+
updatedAt: Date.now(),
|
|
92
|
+
}));
|
|
93
|
+
const apply = (snaps: typeof before): void => {
|
|
94
|
+
for (const snap of snaps) {
|
|
95
|
+
const el = state.elementsById.get(snap.id);
|
|
96
|
+
if (!el) continue;
|
|
97
|
+
el.x = snap.x;
|
|
98
|
+
el.y = snap.y;
|
|
99
|
+
el.updatedAt = snap.updatedAt;
|
|
100
|
+
}
|
|
101
|
+
state.emitChange();
|
|
102
|
+
};
|
|
103
|
+
this.commands.execute("nudge", () => apply(after), () => apply(before));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------- layering ----------
|
|
107
|
+
|
|
108
|
+
private applyZChange(mutate: (el: BoardElement) => void): void {
|
|
109
|
+
const selected = this.state.getSelected();
|
|
110
|
+
if (selected.length === 0) return;
|
|
111
|
+
const state = this.state;
|
|
112
|
+
const before = selected.map((el) => ({ id: el.id, z: el.zIndex }));
|
|
113
|
+
|
|
114
|
+
for (const el of selected) mutate(el);
|
|
115
|
+
state.invalidateSort();
|
|
116
|
+
|
|
117
|
+
const after = selected.map((el) => ({ id: el.id, z: el.zIndex }));
|
|
118
|
+
const apply = (snaps: { id: string; z: number }[]) => {
|
|
119
|
+
for (const s of snaps) {
|
|
120
|
+
const el = state.elementsById.get(s.id);
|
|
121
|
+
if (el) el.zIndex = s.z;
|
|
122
|
+
}
|
|
123
|
+
state.invalidateSort();
|
|
124
|
+
state.emitChange();
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
this.commands.push(
|
|
128
|
+
"layer",
|
|
129
|
+
() => apply(after),
|
|
130
|
+
() => apply(before)
|
|
131
|
+
);
|
|
132
|
+
state.emitChange();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
bringForward(): void {
|
|
136
|
+
this.applyZChange((el) => (el.zIndex += 1));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
sendBackward(): void {
|
|
140
|
+
this.applyZChange((el) => (el.zIndex -= 1));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
bringToFront(): void {
|
|
144
|
+
let top = this.state.maxZIndex();
|
|
145
|
+
this.applyZChange((el) => (el.zIndex = ++top));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
sendToBack(): void {
|
|
149
|
+
let bottom = this.state.minZIndex();
|
|
150
|
+
this.applyZChange((el) => (el.zIndex = --bottom));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export type ScreenRect = { x: number; y: number; w: number; h: number };
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Full-screen drag-to-select overlay. The user drags a rectangle over the board
|
|
5
|
+
* and we hand back the selected screen-space rectangle (or null if cancelled).
|
|
6
|
+
* The caller converts it to a world-space region for an embed snippet.
|
|
7
|
+
*/
|
|
8
|
+
export class EmbedRegionSelector {
|
|
9
|
+
private overlay: HTMLElement;
|
|
10
|
+
private rectEl: HTMLElement;
|
|
11
|
+
private abort = new AbortController();
|
|
12
|
+
private start: { x: number; y: number } | null = null;
|
|
13
|
+
private onComplete: (rect: ScreenRect | null) => void;
|
|
14
|
+
private done = false;
|
|
15
|
+
|
|
16
|
+
static begin(root: HTMLElement, onComplete: (rect: ScreenRect | null) => void): void {
|
|
17
|
+
new EmbedRegionSelector(root, onComplete);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private constructor(root: HTMLElement, onComplete: (rect: ScreenRect | null) => void) {
|
|
21
|
+
this.onComplete = onComplete;
|
|
22
|
+
|
|
23
|
+
this.overlay = document.createElement("div");
|
|
24
|
+
this.overlay.className = "embed-select-overlay";
|
|
25
|
+
|
|
26
|
+
const hint = document.createElement("div");
|
|
27
|
+
hint.className = "embed-select-hint";
|
|
28
|
+
hint.textContent = "Drag to select the area to embed · Esc to cancel";
|
|
29
|
+
this.overlay.appendChild(hint);
|
|
30
|
+
|
|
31
|
+
this.rectEl = document.createElement("div");
|
|
32
|
+
this.rectEl.className = "embed-select-rect";
|
|
33
|
+
this.rectEl.style.display = "none";
|
|
34
|
+
this.overlay.appendChild(this.rectEl);
|
|
35
|
+
|
|
36
|
+
root.appendChild(this.overlay);
|
|
37
|
+
|
|
38
|
+
const signal = this.abort.signal;
|
|
39
|
+
this.overlay.addEventListener("pointerdown", (e) => this.onDown(e), { signal });
|
|
40
|
+
this.overlay.addEventListener("pointermove", (e) => this.onMove(e), { signal });
|
|
41
|
+
this.overlay.addEventListener("pointerup", (e) => this.onUp(e), { signal });
|
|
42
|
+
window.addEventListener(
|
|
43
|
+
"keydown",
|
|
44
|
+
(e) => {
|
|
45
|
+
if (e.key === "Escape") {
|
|
46
|
+
e.preventDefault();
|
|
47
|
+
this.finish(null);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
{ signal }
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private onDown(e: PointerEvent): void {
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
this.start = { x: e.clientX, y: e.clientY };
|
|
57
|
+
this.rectEl.style.display = "block";
|
|
58
|
+
this.draw(e.clientX, e.clientY);
|
|
59
|
+
this.overlay.setPointerCapture(e.pointerId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private onMove(e: PointerEvent): void {
|
|
63
|
+
if (!this.start) return;
|
|
64
|
+
this.draw(e.clientX, e.clientY);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private onUp(e: PointerEvent): void {
|
|
68
|
+
if (!this.start) return;
|
|
69
|
+
const rect = this.rectFrom(e.clientX, e.clientY);
|
|
70
|
+
// Ignore accidental clicks / tiny drags.
|
|
71
|
+
if (rect.w < 8 || rect.h < 8) {
|
|
72
|
+
this.finish(null);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
this.finish(rect);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private rectFrom(curX: number, curY: number): ScreenRect {
|
|
79
|
+
const s = this.start!;
|
|
80
|
+
return {
|
|
81
|
+
x: Math.min(s.x, curX),
|
|
82
|
+
y: Math.min(s.y, curY),
|
|
83
|
+
w: Math.abs(curX - s.x),
|
|
84
|
+
h: Math.abs(curY - s.y),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private draw(curX: number, curY: number): void {
|
|
89
|
+
const r = this.rectFrom(curX, curY);
|
|
90
|
+
this.rectEl.style.left = `${r.x}px`;
|
|
91
|
+
this.rectEl.style.top = `${r.y}px`;
|
|
92
|
+
this.rectEl.style.width = `${r.w}px`;
|
|
93
|
+
this.rectEl.style.height = `${r.h}px`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private finish(rect: ScreenRect | null): void {
|
|
97
|
+
if (this.done) return;
|
|
98
|
+
this.done = true;
|
|
99
|
+
this.abort.abort();
|
|
100
|
+
this.overlay.remove();
|
|
101
|
+
this.onComplete(rect);
|
|
102
|
+
}
|
|
103
|
+
}
|