canvas_erd 0.1.0 → 0.2.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.
@@ -11,6 +11,42 @@
11
11
  const CARD_PADDING = 10;
12
12
  const NOTE_PADDING = 12;
13
13
  const CANVAS_FONT_FAMILY = "Arial";
14
+ const DEFAULT_BACKGROUND_COLORS = Object.freeze([
15
+ "#dbeafe", "#fee2e2", "#ffedd5", "#fff2a8", "#dcfce7",
16
+ "#cffafe", "#ede9fe", "#f3f4f6"
17
+ ]);
18
+ const DEFAULT_FOREGROUND_COLORS = Object.freeze([
19
+ "#243b63", "#7f1d1d", "#9a3412", "#854d0e", "#166534",
20
+ "#155e75", "#6b21a8", "#374151"
21
+ ]);
22
+ const NOTE_PARAGRAPH_STYLES = Object.freeze({
23
+ title: Object.freeze({ fontFamily: "Georgia", fontSize: 32, fontWeight: "bold", lineHeight: 1.15 }),
24
+ heading: Object.freeze({ fontFamily: "Georgia", fontSize: 22, fontWeight: "bold", lineHeight: 1.15 }),
25
+ paragraph: Object.freeze({ fontFamily: "Arial", fontSize: 15, fontWeight: "normal", lineHeight: 1.25 }),
26
+ code: Object.freeze({ fontFamily: "Courier New", fontSize: 14, fontWeight: "normal", lineHeight: 1.2 })
27
+ });
28
+ const DEFAULT_TABLE_HEADER_COLOR = "#243b63";
29
+ const DEFAULT_TABLE_HEADER_COLORS = DEFAULT_FOREGROUND_COLORS;
30
+ const DEFAULT_ARROW_FORMATTING = Object.freeze({
31
+ color: DEFAULT_FOREGROUND_COLORS[0],
32
+ strokeWidth: 2.25,
33
+ lineStyle: "solid",
34
+ startHead: "none",
35
+ endHead: "filled"
36
+ });
37
+ const DEFAULT_FRAME_FORMATTING = Object.freeze({
38
+ color: DEFAULT_FOREGROUND_COLORS[0],
39
+ lineStyle: "solid"
40
+ });
41
+ const DEFAULT_NOTE_FORMATTING = Object.freeze({
42
+ paragraphStyle: "paragraph",
43
+ ...NOTE_PARAGRAPH_STYLES.paragraph,
44
+ fontStyle: "normal",
45
+ underline: false,
46
+ fill: "#374151",
47
+ backgroundColor: "#fff2a8",
48
+ textAlign: "left"
49
+ });
14
50
 
15
51
  function topLeft(options = {}) {
16
52
  return {
@@ -37,12 +73,224 @@
37
73
  return NOTE_PADDING * zoom;
38
74
  }
39
75
 
76
+ function noteFormatting(note = {}) {
77
+ const paragraphStyle = noteParagraphStyle(note);
78
+ const style = NOTE_PARAGRAPH_STYLES[paragraphStyle];
79
+ return {
80
+ paragraphStyle,
81
+ fontWeight: ["normal", "bold"].includes(note.fontWeight) ? note.fontWeight : style.fontWeight,
82
+ fontStyle: note.fontStyle === "italic" ? "italic" : DEFAULT_NOTE_FORMATTING.fontStyle,
83
+ underline: note.underline === true,
84
+ fill: typeof note.fill === "string" ? note.fill : DEFAULT_NOTE_FORMATTING.fill,
85
+ backgroundColor: typeof note.backgroundColor === "string"
86
+ ? note.backgroundColor
87
+ : DEFAULT_NOTE_FORMATTING.backgroundColor,
88
+ fontFamily: style.fontFamily,
89
+ fontSize: style.fontSize,
90
+ lineHeight: style.lineHeight,
91
+ textAlign: ["left", "center", "right"].includes(note.textAlign)
92
+ ? note.textAlign
93
+ : DEFAULT_NOTE_FORMATTING.textAlign
94
+ };
95
+ }
96
+
97
+ function noteParagraphStyle(note = {}) {
98
+ if (NOTE_PARAGRAPH_STYLES[note.paragraphStyle]) return note.paragraphStyle;
99
+ if (note.fontFamily === "Courier New") return "code";
100
+ if (["Georgia", "Times New Roman"].includes(note.fontFamily)) {
101
+ return note.fontSize >= 28 ? "title" : "heading";
102
+ }
103
+ return "paragraph";
104
+ }
105
+
106
+ function noteParagraphStyleFormatting(paragraphStyle) {
107
+ const resolved = NOTE_PARAGRAPH_STYLES[paragraphStyle] ? paragraphStyle : "paragraph";
108
+ return { paragraphStyle: resolved, ...NOTE_PARAGRAPH_STYLES[resolved] };
109
+ }
110
+
111
+ function inheritedNoteFormatting(note) {
112
+ return { ...noteFormatting(note || {}) };
113
+ }
114
+
115
+ function noteColorSwatches(notes, property, defaults) {
116
+ const colors = [...defaults];
117
+ notes.forEach((note) => {
118
+ const color = note[property];
119
+ if (typeof color !== "string" || !/^#[0-9a-f]{6}$/i.test(color)) return;
120
+ const normalized = color.toLowerCase();
121
+ if (!colors.includes(normalized)) colors.push(normalized);
122
+ });
123
+ return colors;
124
+ }
125
+
126
+ function colorPaletteSections(colors, defaults) {
127
+ return {
128
+ defaults: colors.slice(0, defaults.length),
129
+ custom: colors.slice(defaults.length)
130
+ };
131
+ }
132
+
133
+ function noteBorderColor(backgroundColor) {
134
+ if (backgroundColor === "transparent") return "#94a3b8";
135
+ const match = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(backgroundColor);
136
+ if (!match) return "#94a3b8";
137
+
138
+ const channels = match.slice(1).map((channel) => parseInt(channel, 16));
139
+ const luminance = channels[0] * 0.299 + channels[1] * 0.587 + channels[2] * 0.114;
140
+ const target = luminance > 150 ? 0 : 255;
141
+ const mixed = channels.map((channel) => Math.round(channel * 0.7 + target * 0.3));
142
+ return `#${mixed.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
143
+ }
144
+
145
+ function tableHeaderColor(state, entityId) {
146
+ return state.tableHeaderColors?.[entityId] || DEFAULT_TABLE_HEADER_COLOR;
147
+ }
148
+
149
+ function setTableHeaderColor(state, entityIds, color) {
150
+ const tableHeaderColors = { ...(state.tableHeaderColors || {}) };
151
+ entityIds.forEach((entityId) => {
152
+ if (color === DEFAULT_TABLE_HEADER_COLOR) delete tableHeaderColors[entityId];
153
+ else tableHeaderColors[entityId] = color;
154
+ });
155
+ return { ...state, tableHeaderColors };
156
+ }
157
+
158
+ function tableHeaderColorSwatches(tableHeaderColors = {}) {
159
+ const colors = [...DEFAULT_TABLE_HEADER_COLORS];
160
+ Object.values(tableHeaderColors).forEach((color) => {
161
+ if (typeof color !== "string" || !/^#[0-9a-f]{6}$/i.test(color)) return;
162
+ const normalized = color.toLowerCase();
163
+ if (!colors.includes(normalized)) colors.push(normalized);
164
+ });
165
+ return colors;
166
+ }
167
+
168
+ function selectedTableHeaderColor(state, entityIds) {
169
+ if (entityIds.length === 0) return null;
170
+ const colors = entityIds.map((entityId) => tableHeaderColor(state, entityId));
171
+ return colors.every((color) => color === colors[0]) ? colors[0] : null;
172
+ }
173
+
174
+ function arrowFormatting(arrow = {}) {
175
+ const strokeWidth = Number(arrow.strokeWidth);
176
+ const headStyles = [
177
+ "none", "filled", "open", "circle", "circle-open", "diamond", "diamond-open"
178
+ ];
179
+ return {
180
+ color: typeof arrow.color === "string" ? arrow.color : DEFAULT_ARROW_FORMATTING.color,
181
+ strokeWidth: Number.isFinite(strokeWidth)
182
+ ? Math.min(20, Math.max(0.5, strokeWidth))
183
+ : DEFAULT_ARROW_FORMATTING.strokeWidth,
184
+ lineStyle: ["solid", "dashed", "dotted"].includes(arrow.lineStyle)
185
+ ? arrow.lineStyle
186
+ : DEFAULT_ARROW_FORMATTING.lineStyle,
187
+ startHead: headStyles.includes(arrow.startHead)
188
+ ? arrow.startHead
189
+ : DEFAULT_ARROW_FORMATTING.startHead,
190
+ endHead: headStyles.includes(arrow.endHead)
191
+ ? arrow.endHead
192
+ : DEFAULT_ARROW_FORMATTING.endHead
193
+ };
194
+ }
195
+
196
+ function arrowStrokeDashArray(lineStyle) {
197
+ return { dashed: [10, 7], dotted: [2, 6] }[lineStyle] || null;
198
+ }
199
+
200
+ function arrowHeadGeometry(style, strokeWidth) {
201
+ const width = arrowFormatting({ strokeWidth }).strokeWidth;
202
+ const length = 10 + width * 1.5;
203
+ const halfWidth = 5 + width;
204
+ const radius = halfWidth * 0.72;
205
+ return {
206
+ length,
207
+ halfWidth,
208
+ radius,
209
+ shaftInset: style === "circle-open" ? radius * 2 : (style === "diamond-open" ? length : 0)
210
+ };
211
+ }
212
+
213
+ function shortenedArrowLinePoints(points, startHead, endHead, strokeWidth) {
214
+ const x = points.x2 - points.x1;
215
+ const y = points.y2 - points.y1;
216
+ const distance = Math.hypot(x, y);
217
+ if (distance === 0) return { ...points };
218
+
219
+ const startInset = arrowHeadGeometry(startHead, strokeWidth).shaftInset;
220
+ const endInset = arrowHeadGeometry(endHead, strokeWidth).shaftInset;
221
+ const scale = startInset + endInset > distance ? distance / (startInset + endInset) : 1;
222
+ const unitX = x / distance;
223
+ const unitY = y / distance;
224
+ return {
225
+ x1: points.x1 + unitX * startInset * scale,
226
+ y1: points.y1 + unitY * startInset * scale,
227
+ x2: points.x2 - unitX * endInset * scale,
228
+ y2: points.y2 - unitY * endInset * scale
229
+ };
230
+ }
231
+
232
+ function arrowColorSwatches(arrows = []) {
233
+ const colors = [...DEFAULT_FOREGROUND_COLORS];
234
+ arrows.forEach((arrow) => {
235
+ const color = arrowFormatting(arrow).color;
236
+ if (!/^#[0-9a-f]{6}$/i.test(color)) return;
237
+ const normalized = color.toLowerCase();
238
+ if (!colors.includes(normalized)) colors.push(normalized);
239
+ });
240
+ return colors;
241
+ }
242
+
243
+ function frameFormatting(frame = {}) {
244
+ return {
245
+ color: typeof frame.color === "string" ? frame.color : DEFAULT_FRAME_FORMATTING.color,
246
+ lineStyle: ["solid", "dashed", "dotted"].includes(frame.lineStyle)
247
+ ? frame.lineStyle
248
+ : DEFAULT_FRAME_FORMATTING.lineStyle
249
+ };
250
+ }
251
+
252
+ function frameColorSwatches(frames = []) {
253
+ const colors = [...DEFAULT_FOREGROUND_COLORS];
254
+ frames.forEach((frame) => {
255
+ const color = frameFormatting(frame).color;
256
+ if (!/^#[0-9a-f]{6}$/i.test(color)) return;
257
+ const normalized = color.toLowerCase();
258
+ if (!colors.includes(normalized)) colors.push(normalized);
259
+ });
260
+ return colors;
261
+ }
262
+
263
+ function frameBounds(first, second) {
264
+ return {
265
+ x: Math.min(first.x, second.x),
266
+ y: Math.min(first.y, second.y),
267
+ width: Math.abs(second.x - first.x),
268
+ height: Math.abs(second.y - first.y)
269
+ };
270
+ }
271
+
272
+ function frameLabelPosition(frame) {
273
+ return { x: frame.x + 10, y: frame.y };
274
+ }
275
+
276
+ function frameLabelCanvasStyle(frame) {
277
+ const position = frameLabelPosition(frame);
278
+ return {
279
+ left: position.x,
280
+ top: position.y,
281
+ originY: "center",
282
+ fill: frameFormatting(frame).color,
283
+ backgroundColor: "#f7f8fb"
284
+ };
285
+ }
286
+
40
287
  function noteCanvasGeometry(note) {
41
288
  return {
42
289
  left: note.x,
43
290
  top: note.y,
44
291
  width: note.width,
45
- angle: note.angle ?? 0
292
+ angle: note.angle ?? 0,
293
+ ...noteFormatting(note)
46
294
  };
47
295
  }
48
296
 
@@ -52,7 +300,8 @@
52
300
  y: object.top,
53
301
  width: object.width * object.scaleX,
54
302
  angle: object.angle,
55
- text: object.text
303
+ text: object.text,
304
+ ...noteFormatting(object)
56
305
  };
57
306
  }
58
307
 
@@ -96,6 +345,19 @@
96
345
  return shortcutAction(event) === "save";
97
346
  }
98
347
 
348
+ function noteFormattingShortcut(event) {
349
+ if (!(event.metaKey || event.ctrlKey)) return null;
350
+ if (event.altKey) {
351
+ return {
352
+ Digit1: "title",
353
+ Digit2: "heading",
354
+ Digit3: "paragraph",
355
+ Digit4: "code"
356
+ }[event.code] || null;
357
+ }
358
+ return { b: "bold", i: "italic", u: "underline" }[String(event.key).toLowerCase()] || null;
359
+ }
360
+
99
361
  function selectedDiagramFilename(filename, availableFilenames) {
100
362
  return filename && availableFilenames.includes(filename) ? filename : "";
101
363
  }
@@ -139,7 +401,13 @@
139
401
  return null;
140
402
  }
141
403
 
404
+ function arrowIdForEndpointEditing(target) {
405
+ return target?.canvasErdType === "arrow" ? target.arrowId : null;
406
+ }
407
+
142
408
  function canvasLayer(type) {
409
+ if (["frame", "frame-preview"].includes(type)) return -2;
410
+ if (type === "frame-label") return -1;
143
411
  if (type === "relationship") return 0;
144
412
  if (["arrow", "arrow-preview"].includes(type)) return 2;
145
413
  if (type === "arrow-endpoint") return 3;
@@ -211,6 +479,8 @@
211
479
  return {
212
480
  includedEntityIds: schema.entities.map((entity) => entity.id),
213
481
  positions,
482
+ tableHeaderColors: {},
483
+ frames: [],
214
484
  notes: [],
215
485
  arrows: []
216
486
  };
@@ -271,6 +541,8 @@
271
541
  ...state,
272
542
  includedEntityIds: state.includedEntityIds.filter((id) => previousEntityIds.has(id) && nextEntityIds.has(id)),
273
543
  positions,
544
+ tableHeaderColors: { ...(state.tableHeaderColors || {}) },
545
+ frames: (state.frames || []).map((frame) => ({ ...frame })),
274
546
  notes: state.notes.map((note) => ({ ...note })),
275
547
  arrows: (state.arrows || []).map((arrow) => ({
276
548
  ...arrow,
@@ -346,6 +618,98 @@
346
618
  return `${rangeLabel(relationship.cardinality.source)} — ${rangeLabel(relationship.cardinality.destination)}`;
347
619
  }
348
620
 
621
+ function crowFootCardinality(range) {
622
+ const minimum = Number(range.minimum);
623
+ const maximum = range.maximum === null ? null : Number(range.maximum);
624
+ if (![0, 1].includes(minimum) || ![1, null].includes(maximum)) return null;
625
+ return { optional: minimum === 0, many: maximum === null };
626
+ }
627
+
628
+ function cardinalityEndpointGeometry(boundary, outward, range) {
629
+ const magnitude = Math.hypot(outward.x, outward.y) || 1;
630
+ const unit = { x: outward.x / magnitude, y: outward.y / magnitude };
631
+ const tangent = { x: -unit.y, y: unit.x };
632
+ const point = (origin, along, across = 0) => ({
633
+ x: origin.x + unit.x * along + tangent.x * across,
634
+ y: origin.y + unit.y * along + tangent.y * across
635
+ });
636
+ const cardinality = crowFootCardinality(range);
637
+ if (!cardinality) {
638
+ return {
639
+ boundary: { ...boundary },
640
+ shaftPoint: { ...boundary },
641
+ segments: [],
642
+ circles: [],
643
+ label: rangeLabel(range),
644
+ labelPosition: point(boundary, 18)
645
+ };
646
+ }
647
+
648
+ const segments = [];
649
+ const circles = [];
650
+ const maximumMarker = point(boundary, 9);
651
+ let cursor;
652
+ if (cardinality.many) {
653
+ const vertex = point(maximumMarker, 12);
654
+ segments.push(
655
+ { start: { ...boundary }, end: maximumMarker },
656
+ { start: vertex, end: point(maximumMarker, 0, -7) },
657
+ { start: vertex, end: maximumMarker },
658
+ { start: vertex, end: point(maximumMarker, 0, 7) }
659
+ );
660
+ cursor = vertex;
661
+ } else {
662
+ cursor = point(maximumMarker, 5);
663
+ segments.push(
664
+ { start: { ...boundary }, end: cursor },
665
+ { start: point(maximumMarker, 0, -7), end: point(maximumMarker, 0, 7) }
666
+ );
667
+ }
668
+
669
+ let shaftPoint;
670
+ if (cardinality.optional) {
671
+ const nearEdge = point(cursor, 3);
672
+ const center = point(nearEdge, 4);
673
+ shaftPoint = point(center, 7);
674
+ segments.push({ start: cursor, end: nearEdge });
675
+ circles.push({ center, radius: 4 });
676
+ } else {
677
+ const minimumMarker = point(cursor, 6);
678
+ shaftPoint = point(cursor, 12);
679
+ segments.push(
680
+ { start: cursor, end: shaftPoint },
681
+ { start: point(minimumMarker, 0, -7), end: point(minimumMarker, 0, 7) }
682
+ );
683
+ }
684
+
685
+ return {
686
+ boundary: { ...boundary },
687
+ shaftPoint,
688
+ segments,
689
+ circles,
690
+ label: null,
691
+ labelPosition: null
692
+ };
693
+ }
694
+
695
+ function relationshipEndpointGeometry(bounds, toward, range) {
696
+ const center = {
697
+ x: bounds.left + bounds.width / 2,
698
+ y: bounds.top + bounds.height / 2
699
+ };
700
+ const delta = { x: toward.x - center.x, y: toward.y - center.y };
701
+ const magnitude = Math.hypot(delta.x, delta.y) || 1;
702
+ const unit = { x: delta.x / magnitude, y: delta.y / magnitude };
703
+ const xDistance = unit.x === 0 ? Infinity : bounds.width / 2 / Math.abs(unit.x);
704
+ const yDistance = unit.y === 0 ? Infinity : bounds.height / 2 / Math.abs(unit.y);
705
+ const distance = Math.min(xDistance, yDistance);
706
+ const boundary = {
707
+ x: center.x + unit.x * distance,
708
+ y: center.y + unit.y * distance
709
+ };
710
+ return cardinalityEndpointGeometry(boundary, unit, range);
711
+ }
712
+
349
713
  function fitViewport(bounds, viewport, padding = 64, maximumZoom = 1) {
350
714
  if (!bounds || bounds.width <= 0 || bounds.height <= 0) return [1, 0, 0, 1, 0, 0];
351
715
 
@@ -435,10 +799,39 @@
435
799
  ROW_HEIGHT,
436
800
  NOTE_PADDING,
437
801
  CANVAS_FONT_FAMILY,
802
+ DEFAULT_BACKGROUND_COLORS,
803
+ DEFAULT_FOREGROUND_COLORS,
804
+ NOTE_PARAGRAPH_STYLES,
805
+ DEFAULT_TABLE_HEADER_COLOR,
806
+ DEFAULT_TABLE_HEADER_COLORS,
807
+ DEFAULT_ARROW_FORMATTING,
808
+ DEFAULT_FRAME_FORMATTING,
809
+ DEFAULT_NOTE_FORMATTING,
438
810
  topLeft,
439
811
  uncachedText,
440
812
  paddedBackgroundBounds,
441
813
  noteSelectionPadding,
814
+ noteFormatting,
815
+ noteParagraphStyle,
816
+ noteParagraphStyleFormatting,
817
+ inheritedNoteFormatting,
818
+ noteColorSwatches,
819
+ colorPaletteSections,
820
+ noteBorderColor,
821
+ tableHeaderColor,
822
+ setTableHeaderColor,
823
+ tableHeaderColorSwatches,
824
+ selectedTableHeaderColor,
825
+ arrowFormatting,
826
+ arrowStrokeDashArray,
827
+ arrowHeadGeometry,
828
+ shortenedArrowLinePoints,
829
+ arrowColorSwatches,
830
+ frameFormatting,
831
+ frameColorSwatches,
832
+ frameBounds,
833
+ frameLabelPosition,
834
+ frameLabelCanvasStyle,
442
835
  noteCanvasGeometry,
443
836
  noteStateGeometry,
444
837
  erdFilename,
@@ -446,11 +839,13 @@
446
839
  confirmDiscardChanges,
447
840
  shortcutAction,
448
841
  isSaveShortcut,
842
+ noteFormattingShortcut,
449
843
  selectedDiagramFilename,
450
844
  notePositionAt,
451
845
  snapPointToBounds,
452
846
  pointAtAnchor,
453
847
  arrowAttachment,
848
+ arrowIdForEndpointEditing,
454
849
  canvasLayer,
455
850
  transformedArrowEndpoints,
456
851
  createChangeTracker,
@@ -462,7 +857,11 @@
462
857
  setEntityIncluded,
463
858
  selectedEntityIds,
464
859
  visibleRelationships,
860
+ rangeLabel,
465
861
  cardinalityLabel,
862
+ crowFootCardinality,
863
+ cardinalityEndpointGeometry,
864
+ relationshipEndpointGeometry,
466
865
  fitViewport,
467
866
  panViewport,
468
867
  zoomForWheel,
@@ -66,6 +66,13 @@
66
66
  <div><dt>New</dt><dd><kbd>⌘/Ctrl</kbd> <kbd>N</kbd></dd></div>
67
67
  <div><dt>Save</dt><dd><kbd>⌘/Ctrl</kbd> <kbd>S</kbd></dd></div>
68
68
  <div><dt>Add note at cursor</dt><dd><kbd>N</kbd></dd></div>
69
+ <div><dt>Bold note</dt><dd><kbd>⌘/Ctrl</kbd> <kbd>B</kbd></dd></div>
70
+ <div><dt>Italic note</dt><dd><kbd>⌘/Ctrl</kbd> <kbd>I</kbd></dd></div>
71
+ <div><dt>Underline note</dt><dd><kbd>⌘/Ctrl</kbd> <kbd>U</kbd></dd></div>
72
+ <div><dt>Title style</dt><dd><kbd>⌘/Ctrl</kbd> <kbd>Alt</kbd> <kbd>1</kbd></dd></div>
73
+ <div><dt>Heading style</dt><dd><kbd>⌘/Ctrl</kbd> <kbd>Alt</kbd> <kbd>2</kbd></dd></div>
74
+ <div><dt>Paragraph style</dt><dd><kbd>⌘/Ctrl</kbd> <kbd>Alt</kbd> <kbd>3</kbd></dd></div>
75
+ <div><dt>Code style</dt><dd><kbd>⌘/Ctrl</kbd> <kbd>Alt</kbd> <kbd>4</kbd></dd></div>
69
76
  <div><dt>Arrow tool</dt><dd><kbd>A</kbd></dd></div>
70
77
  <div><dt>Free arrow endpoint</dt><dd><kbd>Ctrl</kbd></dd></div>
71
78
  <div><dt>Toggle tables</dt><dd><kbd>T</kbd></dd></div>
@@ -76,6 +83,148 @@
76
83
  <div><dt>Shortcut help</dt><dd><kbd>?</kbd></dd></div>
77
84
  </dl>
78
85
  </div>
86
+ <div id="note-formatting" class="note-formatting" role="toolbar" aria-label="Note formatting" hidden>
87
+ <button id="note-bold" class="format-button note-bold" type="button" aria-label="Bold" title="Bold (⌘/Ctrl-B)" aria-pressed="false">B</button>
88
+ <button id="note-italic" class="format-button note-italic" type="button" aria-label="Italic" title="Italic (⌘/Ctrl-I)" aria-pressed="false">I</button>
89
+ <button id="note-underline" class="format-button note-underline" type="button" aria-label="Underline" title="Underline (⌘/Ctrl-U)" aria-pressed="false">U</button>
90
+ <div class="color-control">
91
+ <button id="note-text-color-button" class="note-color-button" type="button" aria-label="Text color" title="Text color" aria-expanded="false">
92
+ <span id="note-text-color-preview" class="color-preview" aria-hidden="true"></span>
93
+ <span>Text</span>
94
+ </button>
95
+ <div id="note-text-color-menu" class="color-menu" hidden>
96
+ <div id="note-text-color-swatches" class="color-swatches" role="group" aria-label="Text color swatches"></div>
97
+ <label class="custom-color">
98
+ <span>Custom…</span>
99
+ <input id="note-text-custom-color" type="color" value="#374151" aria-label="Custom text color">
100
+ </label>
101
+ </div>
102
+ </div>
103
+ <div class="color-control">
104
+ <button id="note-background-color-button" class="note-color-button" type="button" aria-label="Background color" title="Background color" aria-expanded="false">
105
+ <span id="note-background-color-preview" class="color-preview" aria-hidden="true"></span>
106
+ <span>Fill</span>
107
+ </button>
108
+ <div id="note-background-color-menu" class="color-menu" hidden>
109
+ <div id="note-background-color-swatches" class="color-swatches" role="group" aria-label="Background color swatches"></div>
110
+ <div class="color-menu-actions">
111
+ <button id="note-background-transparent" class="transparent-option" type="button" aria-label="Transparent background" aria-pressed="false">
112
+ <span class="color-preview transparent-color" aria-hidden="true"></span>
113
+ </button>
114
+ <label class="custom-color">
115
+ <span>Custom…</span>
116
+ <input id="note-background-custom-color" type="color" value="#fff2a8" aria-label="Custom background color">
117
+ </label>
118
+ </div>
119
+ </div>
120
+ </div>
121
+ <label class="note-format-field">
122
+ <span>Style</span>
123
+ <select id="note-paragraph-style" aria-label="Paragraph style">
124
+ <option value="title">Title</option>
125
+ <option value="heading">Heading</option>
126
+ <option value="paragraph" selected>Paragraph</option>
127
+ <option value="code">Code</option>
128
+ </select>
129
+ </label>
130
+ <label class="note-format-field">
131
+ <span>Align</span>
132
+ <select id="note-alignment" aria-label="Text alignment">
133
+ <option value="left">Left</option>
134
+ <option value="center">Center</option>
135
+ <option value="right">Right</option>
136
+ </select>
137
+ </label>
138
+ </div>
139
+ <div id="table-formatting" class="table-formatting" role="toolbar" aria-label="Table formatting" hidden>
140
+ <div class="color-control">
141
+ <button id="table-header-color-button" class="note-color-button" type="button" aria-label="Table header color" title="Table header color" aria-expanded="false">
142
+ <span id="table-header-color-preview" class="color-preview" aria-hidden="true"></span>
143
+ <span>Header</span>
144
+ </button>
145
+ <div id="table-header-color-menu" class="color-menu" hidden>
146
+ <div id="table-header-color-swatches" class="color-swatches" role="group" aria-label="Table header color swatches"></div>
147
+ <label class="custom-color">
148
+ <span>Custom…</span>
149
+ <input id="table-header-custom-color" type="color" value="#243b63" aria-label="Custom table header color">
150
+ </label>
151
+ </div>
152
+ </div>
153
+ </div>
154
+ <div id="arrow-formatting" class="arrow-formatting" role="toolbar" aria-label="Arrow formatting" hidden>
155
+ <div class="color-control">
156
+ <button id="arrow-color-button" class="note-color-button" type="button" aria-label="Arrow color" title="Arrow color" aria-expanded="false">
157
+ <span id="arrow-color-preview" class="color-preview" aria-hidden="true"></span>
158
+ <span>Color</span>
159
+ </button>
160
+ <div id="arrow-color-menu" class="color-menu" hidden>
161
+ <div id="arrow-color-swatches" class="color-swatches" role="group" aria-label="Arrow color swatches"></div>
162
+ <label class="custom-color">
163
+ <span>Custom…</span>
164
+ <input id="arrow-custom-color" type="color" value="#243b63" aria-label="Custom arrow color">
165
+ </label>
166
+ </div>
167
+ </div>
168
+ <label class="note-format-field">
169
+ <span>Start</span>
170
+ <select id="arrow-start-head" aria-label="Start arrowhead">
171
+ <option value="none" selected>None</option>
172
+ <option value="open">Arrow — open</option>
173
+ <option value="filled">Arrow — closed</option>
174
+ <option value="circle-open">Circle — open</option>
175
+ <option value="circle">Circle — closed</option>
176
+ <option value="diamond-open">Diamond — open</option>
177
+ <option value="diamond">Diamond — closed</option>
178
+ </select>
179
+ </label>
180
+ <label class="note-format-field">
181
+ <span>End</span>
182
+ <select id="arrow-end-head" aria-label="End arrowhead">
183
+ <option value="none">None</option>
184
+ <option value="open">Arrow — open</option>
185
+ <option value="filled" selected>Arrow — closed</option>
186
+ <option value="circle-open">Circle — open</option>
187
+ <option value="circle">Circle — closed</option>
188
+ <option value="diamond-open">Diamond — open</option>
189
+ <option value="diamond">Diamond — closed</option>
190
+ </select>
191
+ </label>
192
+ <label class="note-format-field">
193
+ <span>Line</span>
194
+ <select id="arrow-line-style" aria-label="Arrow line style">
195
+ <option value="solid" selected>Solid</option>
196
+ <option value="dashed">Dashed</option>
197
+ <option value="dotted">Dotted</option>
198
+ </select>
199
+ </label>
200
+ <label class="note-format-field">
201
+ <span>Width</span>
202
+ <input id="arrow-stroke-width" type="number" min="0.5" max="20" step="0.25" value="2.25" aria-label="Arrow line width">
203
+ </label>
204
+ </div>
205
+ <div id="frame-formatting" class="frame-formatting" role="toolbar" aria-label="Frame formatting" hidden>
206
+ <div class="color-control">
207
+ <button id="frame-color-button" class="note-color-button" type="button" aria-label="Frame color" title="Frame color" aria-expanded="false">
208
+ <span id="frame-color-preview" class="color-preview" aria-hidden="true"></span>
209
+ <span>Color</span>
210
+ </button>
211
+ <div id="frame-color-menu" class="color-menu" hidden>
212
+ <div id="frame-color-swatches" class="color-swatches" role="group" aria-label="Frame color swatches"></div>
213
+ <label class="custom-color">
214
+ <span>Custom…</span>
215
+ <input id="frame-custom-color" type="color" value="#243b63" aria-label="Custom frame color">
216
+ </label>
217
+ </div>
218
+ </div>
219
+ <label class="note-format-field">
220
+ <span>Border</span>
221
+ <select id="frame-line-style" aria-label="Frame border style">
222
+ <option value="solid" selected>Solid</option>
223
+ <option value="dashed">Dashed</option>
224
+ <option value="dotted">Dotted</option>
225
+ </select>
226
+ </label>
227
+ </div>
79
228
  <div class="canvas-tools" aria-label="Canvas tools">
80
229
  <button id="toggle-tables" class="icon-button" type="button" aria-label="Show tables" title="Show tables (T)" aria-expanded="false">
81
230
  <svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" color="#000000" aria-hidden="true" focusable="false"><path d="M3 20.4V3.6C3 3.26863 3.26863 3 3.6 3H20.4C20.7314 3 21 3.26863 21 3.6V20.4C21 20.7314 20.7314 21 20.4 21H3.6C3.26863 21 3 20.7314 3 20.4Z" stroke="#000000" stroke-width="1.5"></path><path d="M3 16.5H21" stroke="#000000" stroke-width="1.5"></path><path d="M3 12H21" stroke="#000000" stroke-width="1.5"></path><path d="M21 7.5H3" stroke="#000000" stroke-width="1.5"></path><path d="M12 21V3" stroke="#000000" stroke-width="1.5"></path></svg>
@@ -83,10 +232,13 @@
83
232
  <button id="layout-tables" class="icon-button" type="button" aria-label="Layout tables" title="Layout tables (L)" aria-expanded="false">
84
233
  <svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" color="#000000" aria-hidden="true" focusable="false"><path d="M21 3.6V12H12V3H20.4C20.7314 3 21 3.26863 21 3.6Z" stroke="#000000" stroke-width="1.5"></path><path d="M21 20.4V12H12V21H20.4C20.7314 21 21 20.7314 21 20.4Z" stroke="#000000" stroke-width="1.5"></path><path d="M3 12V3.6C3 3.26863 3.26863 3 3.6 3H12V12H3Z" stroke="#000000" stroke-width="1.5"></path><path d="M3 12V20.4C3 20.7314 3.26863 21 3.6 21H12V12H3Z" stroke="#000000" stroke-width="1.5"></path></svg>
85
234
  </button>
235
+ <button id="add-frame" class="icon-button" type="button" aria-label="Frame tool" title="Frame tool" aria-pressed="false">
236
+ <svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" color="#000000" aria-hidden="true" focusable="false"><rect x="3" y="4" width="18" height="16" rx="1" stroke="#000000" stroke-width="1.5" stroke-dasharray="3 2"></rect><path d="M6 8H12" stroke="#000000" stroke-width="1.5" stroke-linecap="round"></path></svg>
237
+ </button>
86
238
  <button id="add-note" class="icon-button" type="button" aria-label="Add note" title="Add note (N)">
87
239
  <svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" color="#000000" aria-hidden="true" focusable="false"><path d="M12 8L12 16M12 8H8M12 8H16" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M21 13.5V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V13.5M21 10.5V5C21 3.89543 20.1046 3 19 3H5C3.89543 3 3 3.89543 3 5V10.5" stroke="#000000" stroke-width="1.5" stroke-linejoin="round"></path><path d="M19.5 13.5V10.5H22.5V13.5H19.5Z" stroke="#000000" stroke-width="1.5" stroke-linejoin="round"></path><path d="M1.5 13.5V10.5H4.5V13.5H1.5Z" stroke="#000000" stroke-width="1.5" stroke-linejoin="round"></path></svg>
88
240
  </button>
89
- <button id="add-arrow" class="icon-button" type="button" aria-label="Arrow tool" title="Arrow tool (A) · Double-click an arrow to edit endpoints · Hold Ctrl for a free endpoint" aria-pressed="false">
241
+ <button id="add-arrow" class="icon-button" type="button" aria-label="Arrow tool" title="Arrow tool (A) · Select an arrow to edit endpoints · Hold Ctrl for a free endpoint" aria-pressed="false">
90
242
  <svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" color="#000000" aria-hidden="true" focusable="false"><path d="M6.00005 19L19 5.99996M19 5.99996V18.48M19 5.99996H6.52005" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>
91
243
  </button>
92
244
  </div>