viteboard 0.1.0 → 0.1.1
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/dist/assets/index-C30zRPsW.js +60 -0
- package/dist/assets/index-e5aqtN8W.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/app/AppController.ts +7 -0
- package/src/elements/renderElement.ts +89 -0
- package/src/elements/types.ts +1 -0
- package/src/shell/Shell.ts +4 -6
- package/src/shell/Sidebar.ts +382 -83
- package/src/styles.css +75 -0
- package/src/templates/handoffTemplate.ts +34 -0
- package/src/templates/helpers.ts +10 -2
- package/src/templates/index.ts +14 -0
- package/src/templates/systemDiagramTemplate.ts +6 -6
- package/src/templates/workflowTemplate.ts +35 -0
- package/src/tools/ArrowTool.ts +11 -1
- package/src/tools/SelectTool.ts +1 -1
- package/src/tools/ShapeTool.ts +2 -2
- package/src/tools/ToolContext.ts +7 -0
- package/src/ui/TextEditorOverlay.ts +136 -11
- package/src/ui/Toolbar.ts +39 -1
- package/src/ui/icons.ts +6 -0
- package/vite.config.ts +16 -10
- 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/src/styles.css
CHANGED
|
@@ -1456,6 +1456,81 @@ body {
|
|
|
1456
1456
|
font-weight: 600;
|
|
1457
1457
|
}
|
|
1458
1458
|
|
|
1459
|
+
/* Canonical project tree: every visible row receives its depth explicitly. */
|
|
1460
|
+
.nav-tree-group,
|
|
1461
|
+
.nav-tree-children {
|
|
1462
|
+
margin: 0;
|
|
1463
|
+
padding: 0;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
.nav-tree-row {
|
|
1467
|
+
--tree-depth: 0;
|
|
1468
|
+
width: 100%;
|
|
1469
|
+
min-height: 28px;
|
|
1470
|
+
display: flex;
|
|
1471
|
+
align-items: center;
|
|
1472
|
+
gap: 6px;
|
|
1473
|
+
padding: 4px 8px 4px calc(8px + var(--tree-depth) * 16px);
|
|
1474
|
+
border: 0;
|
|
1475
|
+
border-radius: 6px;
|
|
1476
|
+
background: transparent;
|
|
1477
|
+
color: var(--text-secondary);
|
|
1478
|
+
font: inherit;
|
|
1479
|
+
font-size: 13px;
|
|
1480
|
+
line-height: 1.35;
|
|
1481
|
+
text-align: left;
|
|
1482
|
+
text-decoration: none;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
button.nav-tree-row { cursor: pointer; }
|
|
1486
|
+
.nav-tree-row:hover { background: var(--bg-hover); color: var(--text-primary); }
|
|
1487
|
+
.nav-tree-row:focus-visible { outline: 2px solid var(--selection); outline-offset: -1px; }
|
|
1488
|
+
.nav-tree-row.active { background: var(--bg-active); color: var(--selection); font-weight: 600; }
|
|
1489
|
+
|
|
1490
|
+
.nav-tree-folder { font-weight: 550; }
|
|
1491
|
+
.nav-tree-docs-root { color: var(--text-primary); font-weight: 650; }
|
|
1492
|
+
.nav-tree-badge { margin-left: auto; color: var(--selection); font-size: 9px; font-weight: 700; letter-spacing: 0.06em; }
|
|
1493
|
+
.nav-tree-doc { color: var(--text-secondary); }
|
|
1494
|
+
.nav-tree-board { color: var(--nav-board-color); }
|
|
1495
|
+
.nav-tree-capability { color: var(--nav-cap-color); }
|
|
1496
|
+
.nav-tree-file,
|
|
1497
|
+
.nav-tree-scaffold { opacity: 0.52; }
|
|
1498
|
+
.nav-tree-file { cursor: default; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1499
|
+
.nav-tree-scaffold:hover { opacity: 0.8; }
|
|
1500
|
+
.nav-tree-group.collapsed > .nav-tree-row .nav-repo-chevron { transform: rotate(0deg); }
|
|
1501
|
+
|
|
1502
|
+
/* A valid folder destination gets one unambiguous, local drop affordance. */
|
|
1503
|
+
.nav-tree-group.nav-drop-target > .nav-tree-row {
|
|
1504
|
+
position: relative;
|
|
1505
|
+
background: color-mix(in srgb, var(--selection) 13%, var(--surface-raised));
|
|
1506
|
+
color: var(--text-primary);
|
|
1507
|
+
outline: 2px solid var(--selection);
|
|
1508
|
+
outline-offset: 0;
|
|
1509
|
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--selection) 17%, transparent);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
.nav-tree-group.nav-drop-target > .nav-tree-row::after {
|
|
1513
|
+
content: "Drop into";
|
|
1514
|
+
margin-left: auto;
|
|
1515
|
+
padding: 2px 6px;
|
|
1516
|
+
border-radius: 4px;
|
|
1517
|
+
background: var(--selection);
|
|
1518
|
+
color: var(--bg-canvas);
|
|
1519
|
+
font-size: 10px;
|
|
1520
|
+
font-weight: 700;
|
|
1521
|
+
letter-spacing: 0.03em;
|
|
1522
|
+
text-transform: uppercase;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/* After the hover delay opens a folder, show the newly revealed drop area. */
|
|
1526
|
+
.nav-tree-group.nav-drop-target.nav-drop-auto-expanded > .nav-tree-children {
|
|
1527
|
+
position: relative;
|
|
1528
|
+
margin: 3px 0;
|
|
1529
|
+
border: 1px dashed color-mix(in srgb, var(--selection) 68%, transparent);
|
|
1530
|
+
border-radius: 5px;
|
|
1531
|
+
background: color-mix(in srgb, var(--selection) 5%, transparent);
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1459
1534
|
[data-theme="dark"] .nav-link.active {
|
|
1460
1535
|
color: var(--neutral-100);
|
|
1461
1536
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { BoardElement } from "../elements/types";
|
|
2
|
+
import { arrow, beginTemplate, label, palette, rect } from "./helpers";
|
|
3
|
+
|
|
4
|
+
/** A neutral collaboration map for decisions, ownership, and feedback. */
|
|
5
|
+
export function handoffTemplate(
|
|
6
|
+
ox: number,
|
|
7
|
+
oy: number,
|
|
8
|
+
theme: "light" | "dark",
|
|
9
|
+
baseZ: number
|
|
10
|
+
): BoardElement[] {
|
|
11
|
+
const p = palette(theme);
|
|
12
|
+
beginTemplate(baseZ);
|
|
13
|
+
const els: BoardElement[] = [];
|
|
14
|
+
els.push(label(ox, oy, 800, "Handoff map", 26, p.heading, "700"));
|
|
15
|
+
const cards = [
|
|
16
|
+
["Explore", "#dbeafe"],
|
|
17
|
+
["Decide", "#fef3c7"],
|
|
18
|
+
["Act", "#dcfce7"],
|
|
19
|
+
] as const;
|
|
20
|
+
cards.forEach(([text, fill], index) => {
|
|
21
|
+
els.push(rect(ox + index * 270, oy + 150, 170, 92, {
|
|
22
|
+
fill, stroke: p.cardStroke, strokeWidth: 1.5, borderRadius: 12,
|
|
23
|
+
shadow: true, fontSize: 16, fontWeight: "650", textColor: "#1f2933",
|
|
24
|
+
}, text));
|
|
25
|
+
});
|
|
26
|
+
els.push(arrow(ox + 170, oy + 196, ox + 270, oy + 196, p.accent, { label: "proposal" }));
|
|
27
|
+
els.push(arrow(ox + 440, oy + 196, ox + 540, oy + 196, p.accent, { label: "owner | outcome", sides: true, arrowStart: true }));
|
|
28
|
+
els.push(arrow(ox + 625, oy + 245, ox + 355, oy + 330, "#9ca3af", { label: "feedback", dashed: true }));
|
|
29
|
+
els.push(rect(ox + 265, oy + 320, 180, 70, {
|
|
30
|
+
fill: p.zone, stroke: p.cardStroke, strokeWidth: 1.5, borderRadius: 10,
|
|
31
|
+
fontSize: 14, textColor: p.text,
|
|
32
|
+
}, "Review signal"));
|
|
33
|
+
return els;
|
|
34
|
+
}
|
package/src/templates/helpers.ts
CHANGED
|
@@ -98,12 +98,20 @@ export function arrow(
|
|
|
98
98
|
y1: number,
|
|
99
99
|
x2: number,
|
|
100
100
|
y2: number,
|
|
101
|
-
stroke: string
|
|
101
|
+
stroke: string,
|
|
102
|
+
options: { label?: string; dashed?: boolean; arrowStart?: boolean; sides?: boolean } = {}
|
|
102
103
|
): BoardElement {
|
|
103
104
|
return createElement("arrow", {
|
|
104
105
|
x: x1, y: y1, width: x2 - x1, height: y2 - y1,
|
|
105
106
|
zIndex: ++z,
|
|
106
|
-
style: {
|
|
107
|
+
style: {
|
|
108
|
+
stroke,
|
|
109
|
+
strokeWidth: 2,
|
|
110
|
+
arrowEnd: true,
|
|
111
|
+
arrowStart: options.arrowStart,
|
|
112
|
+
strokeDash: options.dashed ? [8, 6] : undefined,
|
|
113
|
+
},
|
|
114
|
+
data: { text: options.label ?? "", labelPosition: options.sides ? "sides" : "center" },
|
|
107
115
|
});
|
|
108
116
|
}
|
|
109
117
|
|
package/src/templates/index.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { requirementsFlowTemplate } from "./requirementsFlowTemplate";
|
|
|
6
6
|
import { systemDiagramTemplate } from "./systemDiagramTemplate";
|
|
7
7
|
import { opportunityTreeTemplate } from "./opportunityTreeTemplate";
|
|
8
8
|
import { screenshotReviewTemplate } from "./screenshotReviewTemplate";
|
|
9
|
+
import { workflowTemplate } from "./workflowTemplate";
|
|
10
|
+
import { handoffTemplate } from "./handoffTemplate";
|
|
9
11
|
|
|
10
12
|
export type TemplateDef = {
|
|
11
13
|
id: string;
|
|
@@ -62,4 +64,16 @@ export const TEMPLATES: TemplateDef[] = [
|
|
|
62
64
|
description: "Screenshot zone with observations, problems, UX notes.",
|
|
63
65
|
build: screenshotReviewTemplate,
|
|
64
66
|
},
|
|
67
|
+
{
|
|
68
|
+
id: "workflow",
|
|
69
|
+
name: "Workflow Map",
|
|
70
|
+
description: "Labeled handoffs, a dashed feedback loop, and editable steps.",
|
|
71
|
+
build: workflowTemplate,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: "handoff",
|
|
75
|
+
name: "Handoff Map",
|
|
76
|
+
description: "A shared decision flow with ownership, outcomes, and feedback.",
|
|
77
|
+
build: handoffTemplate,
|
|
78
|
+
},
|
|
65
79
|
];
|
|
@@ -30,12 +30,12 @@ export function systemDiagramTemplate(
|
|
|
30
30
|
// notifications
|
|
31
31
|
els.push(box(ox + 470, oy + 330, 180, 90, "#fef08a", "Notification Service"));
|
|
32
32
|
|
|
33
|
-
els.push(arrow(ox + 115, oy + 185, ox + 195, oy + 185, p.heading));
|
|
34
|
-
els.push(arrow(ox + 385, oy + 185, ox + 465, oy + 185, p.heading));
|
|
35
|
-
els.push(arrow(ox + 655, oy + 165, ox + 735, oy + 110, p.heading));
|
|
36
|
-
els.push(arrow(ox + 655, oy + 205, ox + 735, oy + 240, p.heading));
|
|
37
|
-
els.push(arrow(ox + 560, oy + 235, ox + 560, oy + 325, p.heading));
|
|
38
|
-
els.push(arrow(ox + 470, oy + 375, ox + 80, oy + 245, p.heading));
|
|
33
|
+
els.push(arrow(ox + 115, oy + 185, ox + 195, oy + 185, p.heading, { label: "uses" }));
|
|
34
|
+
els.push(arrow(ox + 385, oy + 185, ox + 465, oy + 185, p.heading, { label: "request | response", sides: true, arrowStart: true }));
|
|
35
|
+
els.push(arrow(ox + 655, oy + 165, ox + 735, oy + 110, p.heading, { label: "read" }));
|
|
36
|
+
els.push(arrow(ox + 655, oy + 205, ox + 735, oy + 240, p.heading, { label: "webhook", dashed: true }));
|
|
37
|
+
els.push(arrow(ox + 560, oy + 235, ox + 560, oy + 325, p.heading, { label: "event", dashed: true }));
|
|
38
|
+
els.push(arrow(ox + 470, oy + 375, ox + 80, oy + 245, p.heading, { label: "notify", dashed: true }));
|
|
39
39
|
|
|
40
40
|
return els;
|
|
41
41
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { BoardElement } from "../elements/types";
|
|
2
|
+
import { arrow, beginTemplate, label, palette, rect } from "./helpers";
|
|
3
|
+
|
|
4
|
+
/** A general-purpose process diagram that demonstrates labeled/dashed arrows. */
|
|
5
|
+
export function workflowTemplate(
|
|
6
|
+
ox: number,
|
|
7
|
+
oy: number,
|
|
8
|
+
theme: "light" | "dark",
|
|
9
|
+
baseZ: number
|
|
10
|
+
): BoardElement[] {
|
|
11
|
+
const p = palette(theme);
|
|
12
|
+
beginTemplate(baseZ);
|
|
13
|
+
const els: BoardElement[] = [];
|
|
14
|
+
els.push(label(ox, oy, 880, "Workflow map", 26, p.heading, "700"));
|
|
15
|
+
const steps = [
|
|
16
|
+
["Request", "#dbeafe"],
|
|
17
|
+
["Review", "#fef3c7"],
|
|
18
|
+
["Deliver", "#dcfce7"],
|
|
19
|
+
] as const;
|
|
20
|
+
for (let i = 0; i < steps.length; i++) {
|
|
21
|
+
const x = ox + i * 280;
|
|
22
|
+
els.push(rect(x, oy + 130, 180, 96, {
|
|
23
|
+
fill: steps[i][1], stroke: p.cardStroke, strokeWidth: 1.5, borderRadius: 12,
|
|
24
|
+
fontSize: 16, fontWeight: "650", textColor: "#1f2933", shadow: true,
|
|
25
|
+
}, steps[i][0]));
|
|
26
|
+
}
|
|
27
|
+
els.push(arrow(ox + 180, oy + 178, ox + 280, oy + 178, p.accent, { label: "approve" }));
|
|
28
|
+
els.push(arrow(ox + 460, oy + 178, ox + 560, oy + 178, p.accent, { label: "ship" }));
|
|
29
|
+
els.push(arrow(ox + 650, oy + 226, ox + 370, oy + 310, "#9ca3af", { label: "feedback", dashed: true }));
|
|
30
|
+
els.push(rect(ox + 280, oy + 300, 180, 72, {
|
|
31
|
+
fill: p.zone, stroke: p.cardStroke, strokeWidth: 1.5, borderRadius: 10,
|
|
32
|
+
fontSize: 14, textColor: p.text,
|
|
33
|
+
}, "Learn and iterate"));
|
|
34
|
+
return els;
|
|
35
|
+
}
|
package/src/tools/ArrowTool.ts
CHANGED
|
@@ -2,14 +2,20 @@ import type { Tool, ToolContext } from "./ToolContext";
|
|
|
2
2
|
import { createElement, type BoardElement } from "../elements/types";
|
|
3
3
|
import { screenToWorld } from "../canvas/Viewport";
|
|
4
4
|
|
|
5
|
+
type ArrowToolName = Extract<Tool["name"], "arrow" | "dashedArrow" | "twoWayArrow" | "labeledArrow" | "sidedLabelArrow">;
|
|
6
|
+
|
|
5
7
|
export class ArrowTool implements Tool {
|
|
6
|
-
name
|
|
8
|
+
name: ArrowToolName;
|
|
7
9
|
cursor = "crosshair";
|
|
8
10
|
|
|
9
11
|
private drawing = false;
|
|
10
12
|
private startWorld = { x: 0, y: 0 };
|
|
11
13
|
private preview: BoardElement | null = null;
|
|
12
14
|
|
|
15
|
+
constructor(name: ArrowToolName = "arrow") {
|
|
16
|
+
this.name = name;
|
|
17
|
+
}
|
|
18
|
+
|
|
13
19
|
onPointerDown(e: PointerEvent, ctx: ToolContext): void {
|
|
14
20
|
const { state } = ctx;
|
|
15
21
|
this.drawing = true;
|
|
@@ -24,7 +30,10 @@ export class ArrowTool implements Tool {
|
|
|
24
30
|
stroke: state.theme === "dark" ? "#d1d5db" : "#374151",
|
|
25
31
|
strokeWidth: 2,
|
|
26
32
|
arrowEnd: true,
|
|
33
|
+
arrowStart: this.name === "twoWayArrow",
|
|
34
|
+
strokeDash: this.name === "dashedArrow" || e.altKey ? [8, 6] : undefined,
|
|
27
35
|
},
|
|
36
|
+
data: { text: "", labelPosition: this.name === "sidedLabelArrow" ? "sides" : "center" },
|
|
28
37
|
});
|
|
29
38
|
ctx.renderer.overlay.preview = this.preview;
|
|
30
39
|
}
|
|
@@ -76,6 +85,7 @@ export class ArrowTool implements Tool {
|
|
|
76
85
|
state.clearSelection();
|
|
77
86
|
state.selectedIds.add(el.id);
|
|
78
87
|
ctx.setTool("select");
|
|
88
|
+
if (this.name === "labeledArrow" || this.name === "sidedLabelArrow") ctx.openTextEditor(el);
|
|
79
89
|
}
|
|
80
90
|
|
|
81
91
|
onCancel(ctx: ToolContext): void {
|
package/src/tools/SelectTool.ts
CHANGED
|
@@ -213,7 +213,7 @@ export class SelectTool implements Tool {
|
|
|
213
213
|
const { state, hitTester } = ctx;
|
|
214
214
|
const world = screenToWorld(e.clientX, e.clientY, state.viewport);
|
|
215
215
|
const hit = hitTester.hitTest(world.x, world.y, state.viewport.scale);
|
|
216
|
-
if (hit && (hit.type === "sticky" || hit.type === "task" || hit.type === "text" || hit.type === "rectangle" || hit.type === "ellipse" || hit.type === "frame")) {
|
|
216
|
+
if (hit && (hit.type === "sticky" || hit.type === "task" || hit.type === "text" || hit.type === "rectangle" || hit.type === "ellipse" || hit.type === "frame" || hit.type === "arrow" || hit.type === "line" || hit.type === "connector")) {
|
|
217
217
|
ctx.openTextEditor(hit);
|
|
218
218
|
}
|
|
219
219
|
}
|
package/src/tools/ShapeTool.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { ToolName } from "./ToolContext";
|
|
|
3
3
|
import { createElement, type BoardElement, type ElementType } from "../elements/types";
|
|
4
4
|
import { screenToWorld } from "../canvas/Viewport";
|
|
5
5
|
|
|
6
|
-
type ShapeToolName = Extract<ToolName, "rectangle" | "ellipse" | "diamond" | "triangle" | "pill" | "chevron">;
|
|
6
|
+
type ShapeToolName = Extract<ToolName, "rectangle" | "ellipse" | "diamond" | "triangle" | "pill" | "chevron" | "parallelogram" | "hexagon" | "cylinder">;
|
|
7
7
|
|
|
8
8
|
export class ShapeTool implements Tool {
|
|
9
9
|
name: ShapeToolName;
|
|
@@ -26,7 +26,7 @@ export class ShapeTool implements Tool {
|
|
|
26
26
|
this.drawing = true;
|
|
27
27
|
this.startWorld = screenToWorld(e.clientX, e.clientY, state.viewport);
|
|
28
28
|
const isDark = state.theme === "dark";
|
|
29
|
-
const isGraphicShape = !["rectangle", "ellipse", "pill"].includes(this.shapeKind);
|
|
29
|
+
const isGraphicShape = !["rectangle", "ellipse", "pill", "cylinder"].includes(this.shapeKind);
|
|
30
30
|
this.preview = createElement(this.shapeType, {
|
|
31
31
|
x: this.startWorld.x,
|
|
32
32
|
y: this.startWorld.y,
|
package/src/tools/ToolContext.ts
CHANGED
|
@@ -16,8 +16,15 @@ export type ToolName =
|
|
|
16
16
|
| "triangle"
|
|
17
17
|
| "pill"
|
|
18
18
|
| "chevron"
|
|
19
|
+
| "parallelogram"
|
|
20
|
+
| "hexagon"
|
|
21
|
+
| "cylinder"
|
|
19
22
|
| "text"
|
|
20
23
|
| "arrow"
|
|
24
|
+
| "dashedArrow"
|
|
25
|
+
| "twoWayArrow"
|
|
26
|
+
| "labeledArrow"
|
|
27
|
+
| "sidedLabelArrow"
|
|
21
28
|
| "image";
|
|
22
29
|
|
|
23
30
|
export type ToolContext = {
|
|
@@ -11,7 +11,7 @@ import { getRenderedTextFontSize, invalidateTextCache } from "../elements/render
|
|
|
11
11
|
export class TextEditorOverlay {
|
|
12
12
|
private state: BoardState;
|
|
13
13
|
private commands: CommandManager;
|
|
14
|
-
private
|
|
14
|
+
private textareas: HTMLTextAreaElement[] = [];
|
|
15
15
|
private editingId: string | null = null;
|
|
16
16
|
|
|
17
17
|
constructor(state: BoardState, commands: CommandManager) {
|
|
@@ -26,14 +26,19 @@ export class TextEditorOverlay {
|
|
|
26
26
|
open(el: BoardElement): void {
|
|
27
27
|
this.close();
|
|
28
28
|
this.editingId = el.id;
|
|
29
|
+
if (el.data.labelPosition === "sides") {
|
|
30
|
+
this.openSideEditors(el);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
29
33
|
|
|
30
34
|
const ta = document.createElement("textarea");
|
|
31
35
|
ta.className = "text-editor";
|
|
32
36
|
ta.value = (el.data.text as string) ?? "";
|
|
37
|
+
if (el.data.labelPosition === "sides") ta.placeholder = "left label | right label";
|
|
33
38
|
this.position(ta, el);
|
|
34
39
|
|
|
35
40
|
document.body.appendChild(ta);
|
|
36
|
-
this.
|
|
41
|
+
this.textareas = [ta];
|
|
37
42
|
ta.focus();
|
|
38
43
|
ta.select();
|
|
39
44
|
|
|
@@ -93,9 +98,121 @@ export class TextEditorOverlay {
|
|
|
93
98
|
});
|
|
94
99
|
}
|
|
95
100
|
|
|
101
|
+
private openSideEditors(el: BoardElement): void {
|
|
102
|
+
const originalText = String(el.data.text ?? "");
|
|
103
|
+
const [leftValue = "", rightValue = ""] = originalText.split("|", 2).map((part) => part.trim());
|
|
104
|
+
const left = this.makeSideEditor("Left label", leftValue);
|
|
105
|
+
const right = this.makeSideEditor("Right label", rightValue);
|
|
106
|
+
this.positionSideEditors(left, right, el);
|
|
107
|
+
document.body.append(left, right);
|
|
108
|
+
this.textareas = [left, right];
|
|
109
|
+
left.focus();
|
|
110
|
+
left.select();
|
|
111
|
+
|
|
112
|
+
el.data.text = "";
|
|
113
|
+
el.updatedAt = Date.now();
|
|
114
|
+
invalidateTextCache(el.id);
|
|
115
|
+
this.state.requestRender();
|
|
116
|
+
|
|
117
|
+
let settled = false;
|
|
118
|
+
const commit = () => {
|
|
119
|
+
if (settled) return;
|
|
120
|
+
settled = true;
|
|
121
|
+
const next = `${left.value.trim()} | ${right.value.trim()}`;
|
|
122
|
+
el.data.text = next;
|
|
123
|
+
el.updatedAt = Date.now();
|
|
124
|
+
invalidateTextCache(el.id);
|
|
125
|
+
if (next !== originalText) this.pushTextCommand(el.id, originalText, next);
|
|
126
|
+
this.state.emitChange();
|
|
127
|
+
this.close();
|
|
128
|
+
};
|
|
129
|
+
const cancel = () => {
|
|
130
|
+
if (settled) return;
|
|
131
|
+
settled = true;
|
|
132
|
+
el.data.text = originalText;
|
|
133
|
+
invalidateTextCache(el.id);
|
|
134
|
+
this.close();
|
|
135
|
+
this.state.requestRender();
|
|
136
|
+
};
|
|
137
|
+
for (const editor of [left, right]) {
|
|
138
|
+
editor.addEventListener("keydown", (e) => {
|
|
139
|
+
e.stopPropagation();
|
|
140
|
+
if (e.key === "Escape") cancel();
|
|
141
|
+
else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) commit();
|
|
142
|
+
});
|
|
143
|
+
editor.addEventListener("blur", () => {
|
|
144
|
+
window.setTimeout(() => {
|
|
145
|
+
if (!this.textareas.includes(document.activeElement as HTMLTextAreaElement)) commit();
|
|
146
|
+
}, 0);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private makeSideEditor(placeholder: string, value: string): HTMLTextAreaElement {
|
|
152
|
+
const ta = document.createElement("textarea");
|
|
153
|
+
ta.className = "text-editor text-editor-side";
|
|
154
|
+
ta.placeholder = placeholder;
|
|
155
|
+
ta.value = value;
|
|
156
|
+
ta.style.fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
|
157
|
+
ta.style.fontWeight = "600";
|
|
158
|
+
ta.style.textAlign = "center";
|
|
159
|
+
ta.style.lineHeight = "1.35";
|
|
160
|
+
ta.style.color = this.state.theme === "dark" ? "#f2f2f2" : "#1f2933";
|
|
161
|
+
return ta;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private positionSideEditors(left: HTMLTextAreaElement, right: HTMLTextAreaElement, el: BoardElement): void {
|
|
165
|
+
const vp = this.state.viewport;
|
|
166
|
+
const start = worldToScreen(el.x, el.y, vp);
|
|
167
|
+
const end = worldToScreen(el.x + el.width, el.y + el.height, vp);
|
|
168
|
+
const fontSize = 13 * vp.scale;
|
|
169
|
+
const width = Math.max(82, Math.min(150, Math.hypot(end.x - start.x, end.y - start.y) * 0.34));
|
|
170
|
+
const height = Math.max(24, fontSize * 1.35 + 4);
|
|
171
|
+
for (const [editor, position] of [[left, 0.24], [right, 0.76]] as const) {
|
|
172
|
+
editor.style.left = (start.x + (end.x - start.x) * position - width / 2) + "px";
|
|
173
|
+
editor.style.top = (start.y + (end.y - start.y) * position - height / 2) + "px";
|
|
174
|
+
editor.style.width = width + "px";
|
|
175
|
+
editor.style.height = height + "px";
|
|
176
|
+
editor.style.fontSize = fontSize + "px";
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private pushTextCommand(id: string, originalText: string, newText: string): void {
|
|
181
|
+
const state = this.state;
|
|
182
|
+
this.commands.push(
|
|
183
|
+
"edit-text",
|
|
184
|
+
() => {
|
|
185
|
+
const target = state.elementsById.get(id);
|
|
186
|
+
if (target) { target.data.text = newText; target.updatedAt = Date.now(); invalidateTextCache(id); state.emitChange(); }
|
|
187
|
+
},
|
|
188
|
+
() => {
|
|
189
|
+
const target = state.elementsById.get(id);
|
|
190
|
+
if (target) { target.data.text = originalText; target.updatedAt = Date.now(); invalidateTextCache(id); state.emitChange(); }
|
|
191
|
+
}
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
96
195
|
/** Reposition while viewport changes (e.g. window resize). */
|
|
97
196
|
private position(ta: HTMLTextAreaElement, el: BoardElement): void {
|
|
98
197
|
const vp = this.state.viewport;
|
|
198
|
+
if (el.type === "arrow" || el.type === "line" || el.type === "connector") {
|
|
199
|
+
const start = worldToScreen(el.x, el.y, vp);
|
|
200
|
+
const end = worldToScreen(el.x + el.width, el.y + el.height, vp);
|
|
201
|
+
const fontSize = 13 * vp.scale;
|
|
202
|
+
const width = Math.max(100, Math.min(240, Math.hypot(end.x - start.x, end.y - start.y) - 16));
|
|
203
|
+
const height = Math.max(24, fontSize * 1.35 + 4);
|
|
204
|
+
ta.style.left = (start.x + end.x) / 2 - width / 2 + "px";
|
|
205
|
+
ta.style.top = (start.y + end.y) / 2 - height / 2 + "px";
|
|
206
|
+
ta.style.width = width + "px";
|
|
207
|
+
ta.style.height = height + "px";
|
|
208
|
+
ta.style.fontSize = fontSize + "px";
|
|
209
|
+
ta.style.lineHeight = "1.35";
|
|
210
|
+
ta.style.fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
|
211
|
+
ta.style.fontWeight = "600";
|
|
212
|
+
ta.style.color = this.state.theme === "dark" ? "#f2f2f2" : "#1f2933";
|
|
213
|
+
ta.style.textAlign = "center";
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
99
216
|
const tl = worldToScreen(el.x, el.y, vp);
|
|
100
217
|
const isChevron = el.data.shapeKind === "chevron";
|
|
101
218
|
const fontSize = getRenderedTextFontSize(el) * vp.scale;
|
|
@@ -104,12 +221,20 @@ export class TextEditorOverlay {
|
|
|
104
221
|
? Math.min(Math.abs(el.width) * 0.18, Math.abs(el.height) * 0.55) + 8
|
|
105
222
|
: 10;
|
|
106
223
|
const xPad = (el.type === "sticky" ? 14 : el.type === "task" ? 50 : el.type === "text" ? 0 : chevronPad) * vp.scale;
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
224
|
+
const isCentered = el.type !== "text" && el.type !== "task";
|
|
225
|
+
const availableHeight = isChevron
|
|
226
|
+
? el.height * vp.scale
|
|
227
|
+
: el.height * vp.scale - (el.type === "sticky" ? 28 : isCentered ? chevronPad * 2 : 0) * vp.scale;
|
|
228
|
+
// A textarea fills from its top edge. Give centered diagram nodes a
|
|
229
|
+
// content-height editor and position that editor on the canvas baseline.
|
|
230
|
+
const lineCount = Math.max(1, String(el.data.text ?? "").split(/\r?\n/).length);
|
|
231
|
+
const contentHeight = Math.max(24, fontSize * (isChevron ? 1.18 : 1.35) * lineCount + 4);
|
|
232
|
+
const editorHeight = isCentered
|
|
233
|
+
? Math.min(Math.max(24, availableHeight), contentHeight)
|
|
234
|
+
: Math.max(24, availableHeight);
|
|
235
|
+
const yPad = isCentered
|
|
111
236
|
? Math.max(0, (el.height * vp.scale - editorHeight) / 2)
|
|
112
|
-
:
|
|
237
|
+
: el.type === "sticky" ? 14 * vp.scale : 0;
|
|
113
238
|
const rightPad = (el.type === "task" ? 16 : el.type === "sticky" ? 14 : el.type === "text" ? 0 : chevronPad) * vp.scale;
|
|
114
239
|
|
|
115
240
|
ta.style.left = tl.x + xPad + "px";
|
|
@@ -127,11 +252,11 @@ export class TextEditorOverlay {
|
|
|
127
252
|
}
|
|
128
253
|
|
|
129
254
|
close(): void {
|
|
130
|
-
if (this.
|
|
131
|
-
const
|
|
132
|
-
this.
|
|
255
|
+
if (this.textareas.length) {
|
|
256
|
+
const editors = this.textareas;
|
|
257
|
+
this.textareas = [];
|
|
133
258
|
this.editingId = null;
|
|
134
|
-
|
|
259
|
+
for (const editor of editors) editor.remove();
|
|
135
260
|
}
|
|
136
261
|
}
|
|
137
262
|
}
|
package/src/ui/Toolbar.ts
CHANGED
|
@@ -17,7 +17,6 @@ const TOOL_BUTTONS: { tool: ToolName; icon: string; label: string; key: string }
|
|
|
17
17
|
{ tool: "sticky", icon: "sticky", label: "Sticky note", key: "S" },
|
|
18
18
|
{ tool: "task", icon: "task", label: "Task", key: "" },
|
|
19
19
|
{ tool: "text", icon: "text", label: "Text", key: "T" },
|
|
20
|
-
{ tool: "arrow", icon: "arrow", label: "Arrow", key: "A" },
|
|
21
20
|
{ tool: "image", icon: "image", label: "Image", key: "I" },
|
|
22
21
|
];
|
|
23
22
|
|
|
@@ -28,6 +27,17 @@ const SHAPE_BUTTONS: { tool: ToolName; icon: string; label: string; key: string
|
|
|
28
27
|
{ tool: "triangle", icon: "triangle", label: "Triangle", key: "" },
|
|
29
28
|
{ tool: "pill", icon: "pill", label: "Pill", key: "" },
|
|
30
29
|
{ tool: "chevron", icon: "chevron", label: "Chevron", key: "" },
|
|
30
|
+
{ tool: "parallelogram", icon: "parallelogram", label: "Parallelogram", key: "" },
|
|
31
|
+
{ tool: "hexagon", icon: "hexagon", label: "Hexagon", key: "" },
|
|
32
|
+
{ tool: "cylinder", icon: "cylinder", label: "Cylinder", key: "" },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const ARROW_BUTTONS: { tool: ToolName; icon: string; label: string; key: string }[] = [
|
|
36
|
+
{ tool: "arrow", icon: "arrow", label: "Arrow (hold Shift to snap)", key: "A" },
|
|
37
|
+
{ tool: "dashedArrow", icon: "arrowDashed", label: "Dashed arrow", key: "" },
|
|
38
|
+
{ tool: "twoWayArrow", icon: "arrowBoth", label: "Two-way arrow", key: "" },
|
|
39
|
+
{ tool: "labeledArrow", icon: "arrowLabel", label: "Centered label arrow", key: "" },
|
|
40
|
+
{ tool: "sidedLabelArrow", icon: "arrowLabel", label: "Left and right labels", key: "" },
|
|
31
41
|
];
|
|
32
42
|
|
|
33
43
|
const SHAPE_TOOLS = new Set<ToolName>(SHAPE_BUTTONS.map((item) => item.tool));
|
|
@@ -38,6 +48,7 @@ export class Toolbar {
|
|
|
38
48
|
private shapeButtons = new Map<ToolName, HTMLButtonElement>();
|
|
39
49
|
private shapeBtn!: HTMLButtonElement;
|
|
40
50
|
private shapeGroup!: HTMLDivElement;
|
|
51
|
+
private arrowGroup!: HTMLDivElement;
|
|
41
52
|
private themeBtn!: HTMLButtonElement;
|
|
42
53
|
|
|
43
54
|
constructor(parent: HTMLElement, actions: ToolbarActions) {
|
|
@@ -50,6 +61,7 @@ export class Toolbar {
|
|
|
50
61
|
this.toolButtons.set(def.tool, btn);
|
|
51
62
|
this.element.appendChild(btn);
|
|
52
63
|
if (def.tool === "sticky") this.element.appendChild(this.makeShapePicker(actions));
|
|
64
|
+
if (def.tool === "text") this.element.appendChild(this.makeArrowPicker(actions));
|
|
53
65
|
}
|
|
54
66
|
|
|
55
67
|
this.element.appendChild(this.divider());
|
|
@@ -127,6 +139,32 @@ export class Toolbar {
|
|
|
127
139
|
return this.shapeGroup;
|
|
128
140
|
}
|
|
129
141
|
|
|
142
|
+
private makeArrowPicker(actions: ToolbarActions): HTMLDivElement {
|
|
143
|
+
this.arrowGroup = document.createElement("div");
|
|
144
|
+
this.arrowGroup.className = "shape-tool-group";
|
|
145
|
+
const trigger = this.makeButton("arrow", "Arrow types", "");
|
|
146
|
+
trigger.classList.add("shape-tool-trigger");
|
|
147
|
+
trigger.addEventListener("click", (e) => {
|
|
148
|
+
e.stopPropagation();
|
|
149
|
+
this.arrowGroup.classList.toggle("open");
|
|
150
|
+
});
|
|
151
|
+
const menu = document.createElement("div");
|
|
152
|
+
menu.className = "shape-tool-popover";
|
|
153
|
+
for (const def of ARROW_BUTTONS) {
|
|
154
|
+
const btn = this.makeButton(def.icon, def.label, def.key);
|
|
155
|
+
btn.classList.add("shape-option-btn");
|
|
156
|
+
btn.addEventListener("click", (e) => {
|
|
157
|
+
e.stopPropagation();
|
|
158
|
+
actions.setTool(def.tool);
|
|
159
|
+
this.arrowGroup.classList.remove("open");
|
|
160
|
+
});
|
|
161
|
+
this.toolButtons.set(def.tool, btn);
|
|
162
|
+
menu.appendChild(btn);
|
|
163
|
+
}
|
|
164
|
+
this.arrowGroup.append(trigger, menu);
|
|
165
|
+
return this.arrowGroup;
|
|
166
|
+
}
|
|
167
|
+
|
|
130
168
|
private divider(): HTMLDivElement {
|
|
131
169
|
const d = document.createElement("div");
|
|
132
170
|
d.className = "toolbar-divider";
|
package/src/ui/icons.ts
CHANGED
|
@@ -13,6 +13,12 @@ export const ICONS: Record<string, string> = {
|
|
|
13
13
|
triangle: `<svg viewBox="0 0 24 24"><path d="M12 4l9 16H3z"/></svg>`,
|
|
14
14
|
pill: `<svg viewBox="0 0 24 24"><rect x="3.5" y="7" width="17" height="10" rx="5"/></svg>`,
|
|
15
15
|
chevron: `<svg viewBox="0 0 24 24"><path d="M4 6h12l5 6-5 6H4l5-6z"/></svg>`,
|
|
16
|
+
parallelogram: `<svg viewBox="0 0 24 24"><path d="M7 5h13l-3 14H4z"/></svg>`,
|
|
17
|
+
hexagon: `<svg viewBox="0 0 24 24"><path d="M8 4h8l5 8-5 8H8l-5-8z"/></svg>`,
|
|
18
|
+
cylinder: `<svg viewBox="0 0 24 24"><ellipse cx="12" cy="6" rx="7" ry="3"/><path d="M5 6v11c0 1.7 3.1 3 7 3s7-1.3 7-3V6"/></svg>`,
|
|
19
|
+
arrowDashed: `<svg viewBox="0 0 24 24"><path stroke-dasharray="4 3" d="M4 12h14m0 0l-5-5m5 5l-5 5"/></svg>`,
|
|
20
|
+
arrowBoth: `<svg viewBox="0 0 24 24"><path d="M4 12h16m-16 0l5-5m-5 5l5 5m11-5l-5-5m5 5l-5 5"/></svg>`,
|
|
21
|
+
arrowLabel: `<svg viewBox="0 0 24 24"><path d="M3 17L17 7m0 0h-6m6 0v6"/><rect x="7" y="9" width="10" height="6" rx="1"/></svg>`,
|
|
16
22
|
text: `<svg viewBox="0 0 24 24"><path d="M5 6V4h14v2M12 4v16m-3 0h6"/></svg>`,
|
|
17
23
|
arrow: `<svg viewBox="0 0 24 24"><path d="M5 19L19 5m0 0h-7m7 0v7"/></svg>`,
|
|
18
24
|
image: `<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="1.6"/><path d="M21 16l-5-5-9 9"/></svg>`,
|
package/vite.config.ts
CHANGED
|
@@ -358,7 +358,7 @@ function contentApiPlugin(): Plugin {
|
|
|
358
358
|
return nodes;
|
|
359
359
|
}
|
|
360
360
|
|
|
361
|
-
/** Walk
|
|
361
|
+
/** Walk the project tree for Raw sidebar mode. */
|
|
362
362
|
async function walkProjectTree(dir: string, base: string): Promise<TreeNode[]> {
|
|
363
363
|
let entries: import("node:fs").Dirent[];
|
|
364
364
|
try {
|
|
@@ -368,18 +368,24 @@ function contentApiPlugin(): Plugin {
|
|
|
368
368
|
}
|
|
369
369
|
const nodes: TreeNode[] = [];
|
|
370
370
|
for (const entry of entries) {
|
|
371
|
-
if (!entry.isDirectory()) continue;
|
|
372
371
|
if (entry.name.startsWith(".")) continue;
|
|
373
|
-
if (isIgnoredDir(entry.name)) continue;
|
|
374
372
|
const rel = base ? `${base}/${entry.name}` : entry.name;
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
373
|
+
if (entry.isDirectory()) {
|
|
374
|
+
if (isIgnoredDir(entry.name)) continue;
|
|
375
|
+
nodes.push({
|
|
376
|
+
type: "dir",
|
|
377
|
+
name: entry.name,
|
|
378
|
+
path: rel,
|
|
379
|
+
children: await walkProjectTree(path.join(dir, entry.name), rel),
|
|
380
|
+
});
|
|
381
|
+
} else {
|
|
382
|
+
nodes.push({ type: "file", name: entry.name, path: rel });
|
|
383
|
+
}
|
|
381
384
|
}
|
|
382
|
-
nodes.sort((a, b) =>
|
|
385
|
+
nodes.sort((a, b) => {
|
|
386
|
+
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
|
|
387
|
+
return a.name.localeCompare(b.name);
|
|
388
|
+
});
|
|
383
389
|
return nodes;
|
|
384
390
|
}
|
|
385
391
|
|