js-draw 0.1.1 → 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.
Files changed (52) hide show
  1. package/CHANGELOG.md +3 -0
  2. package/dist/bundle.js +1 -1
  3. package/dist/src/Editor.js +4 -0
  4. package/dist/src/EditorImage.js +3 -0
  5. package/dist/src/Pointer.d.ts +3 -2
  6. package/dist/src/Pointer.js +12 -3
  7. package/dist/src/SVGLoader.d.ts +3 -0
  8. package/dist/src/SVGLoader.js +11 -1
  9. package/dist/src/Viewport.js +10 -0
  10. package/dist/src/components/AbstractComponent.d.ts +6 -0
  11. package/dist/src/components/AbstractComponent.js +11 -0
  12. package/dist/src/components/Stroke.js +1 -1
  13. package/dist/src/geometry/Path.js +97 -66
  14. package/dist/src/rendering/renderers/AbstractRenderer.d.ts +2 -1
  15. package/dist/src/rendering/renderers/AbstractRenderer.js +1 -1
  16. package/dist/src/rendering/renderers/SVGRenderer.d.ts +3 -2
  17. package/dist/src/rendering/renderers/SVGRenderer.js +21 -7
  18. package/dist/src/toolbar/HTMLToolbar.d.ts +2 -1
  19. package/dist/src/toolbar/HTMLToolbar.js +165 -154
  20. package/dist/src/toolbar/icons.d.ts +10 -0
  21. package/dist/src/toolbar/icons.js +180 -0
  22. package/dist/src/toolbar/localization.d.ts +4 -1
  23. package/dist/src/toolbar/localization.js +4 -1
  24. package/dist/src/toolbar/types.d.ts +4 -0
  25. package/dist/src/tools/PanZoom.d.ts +9 -6
  26. package/dist/src/tools/PanZoom.js +30 -21
  27. package/dist/src/tools/Pen.js +8 -3
  28. package/dist/src/tools/ToolController.d.ts +5 -6
  29. package/dist/src/tools/ToolController.js +8 -10
  30. package/dist/src/tools/localization.d.ts +1 -0
  31. package/dist/src/tools/localization.js +1 -0
  32. package/package.json +1 -1
  33. package/src/Editor.ts +4 -0
  34. package/src/EditorImage.ts +4 -0
  35. package/src/Pointer.ts +13 -4
  36. package/src/SVGLoader.ts +25 -2
  37. package/src/Viewport.ts +13 -1
  38. package/src/components/AbstractComponent.ts +16 -1
  39. package/src/components/Stroke.ts +1 -1
  40. package/src/geometry/Path.fromString.test.ts +94 -4
  41. package/src/geometry/Path.ts +99 -67
  42. package/src/rendering/renderers/AbstractRenderer.ts +2 -1
  43. package/src/rendering/renderers/SVGRenderer.ts +22 -10
  44. package/src/toolbar/HTMLToolbar.ts +199 -170
  45. package/src/toolbar/icons.ts +203 -0
  46. package/src/toolbar/localization.ts +9 -2
  47. package/src/toolbar/toolbar.css +21 -8
  48. package/src/toolbar/types.ts +5 -0
  49. package/src/tools/PanZoom.ts +37 -27
  50. package/src/tools/Pen.ts +7 -3
  51. package/src/tools/ToolController.ts +3 -5
  52. package/src/tools/localization.ts +2 -0
@@ -1,6 +1,6 @@
1
1
  import Editor from '../Editor';
2
2
  import { ToolType } from '../tools/ToolController';
3
- import { EditorEventType, StrokeDataPoint } from '../types';
3
+ import { EditorEventType } from '../types';
4
4
 
5
5
  import { coloris, init as colorisInit } from '@melloware/coloris';
6
6
  import Color4 from '../Color4';
@@ -9,25 +9,19 @@ import Eraser from '../tools/Eraser';
9
9
  import BaseTool from '../tools/BaseTool';
10
10
  import SelectionTool from '../tools/SelectionTool';
11
11
  import { makeFreehandLineBuilder } from '../components/builders/FreehandLineBuilder';
12
- import { Vec2 } from '../geometry/Vec2';
13
- import SVGRenderer from '../rendering/renderers/SVGRenderer';
14
- import Viewport from '../Viewport';
15
- import EventDispatcher from '../EventDispatcher';
16
12
  import { ComponentBuilderFactory } from '../components/builders/types';
17
13
  import { makeArrowBuilder } from '../components/builders/ArrowBuilder';
18
14
  import { makeLineBuilder } from '../components/builders/LineBuilder';
19
15
  import { makeFilledRectangleBuilder, makeOutlinedRectangleBuilder } from '../components/builders/RectangleBuilder';
20
16
  import { defaultToolbarLocalization, ToolbarLocalization } from './localization';
17
+ import { ActionButtonIcon } from './types';
18
+ import { makeDropdownIcon, makeEraserIcon, makeIconFromFactory, makePenIcon, makeRedoIcon, makeSelectionIcon, makeHandToolIcon, makeUndoIcon } from './icons';
19
+ import PanZoom, { PanZoomMode } from '../tools/PanZoom';
20
+ import Mat33 from '../geometry/Mat33';
21
+ import Viewport from '../Viewport';
21
22
 
22
- const primaryForegroundFill = `
23
- style='fill: var(--primary-foreground-color);'
24
- `;
25
- const primaryForegroundStrokeFill = `
26
- style='fill: var(--primary-foreground-color); stroke: var(--primary-foreground-color);'
27
- `;
28
23
 
29
24
  const toolbarCSSPrefix = 'toolbar-';
30
- const svgNamespace = 'http://www.w3.org/2000/svg';
31
25
 
32
26
  abstract class ToolbarWidget {
33
27
  protected readonly container: HTMLElement;
@@ -57,11 +51,6 @@ abstract class ToolbarWidget {
57
51
  this.button.setAttribute('role', 'button');
58
52
  this.button.tabIndex = 0;
59
53
 
60
- this.button.onclick = () => {
61
- this.handleClick();
62
- };
63
-
64
-
65
54
  editor.notifier.on(EditorEventType.ToolEnabled, toolEvt => {
66
55
  if (toolEvt.kind !== EditorEventType.ToolEnabled) {
67
56
  throw new Error('Incorrect event type! (Expected ToolEnabled)');
@@ -91,6 +80,12 @@ abstract class ToolbarWidget {
91
80
  // Returns true if such a menu should be created, false otherwise.
92
81
  protected abstract fillDropdown(dropdown: HTMLElement): boolean;
93
82
 
83
+ protected setupActionBtnClickListener(button: HTMLElement) {
84
+ button.onclick = () => {
85
+ this.handleClick();
86
+ };
87
+ }
88
+
94
89
  protected handleClick() {
95
90
  if (this.hasDropdown) {
96
91
  if (!this.targetTool.isEnabled()) {
@@ -107,6 +102,8 @@ abstract class ToolbarWidget {
107
102
  public addTo(parent: HTMLElement) {
108
103
  this.label.innerText = this.getTitle();
109
104
 
105
+ this.setupActionBtnClickListener(this.button);
106
+
110
107
  this.icon = null;
111
108
  this.updateIcon();
112
109
 
@@ -167,6 +164,21 @@ abstract class ToolbarWidget {
167
164
  this.localizationTable.dropdownHidden(this.targetTool.description)
168
165
  );
169
166
  }
167
+
168
+ this.repositionDropdown();
169
+ }
170
+
171
+ protected repositionDropdown() {
172
+ const dropdownBBox = this.dropdownContainer.getBoundingClientRect();
173
+ const screenWidth = document.body.clientWidth;
174
+
175
+ if (dropdownBBox.left > screenWidth / 2) {
176
+ this.dropdownContainer.style.marginLeft = this.button.clientWidth + 'px';
177
+ this.dropdownContainer.style.transform = 'translate(-100%, 0)';
178
+ } else {
179
+ this.dropdownContainer.style.marginLeft = '';
180
+ this.dropdownContainer.style.transform = '';
181
+ }
170
182
  }
171
183
 
172
184
  protected isDropdownVisible(): boolean {
@@ -174,17 +186,8 @@ abstract class ToolbarWidget {
174
186
  }
175
187
 
176
188
  private createDropdownIcon(): Element {
177
- const icon = document.createElementNS(svgNamespace, 'svg');
178
- icon.innerHTML = `
179
- <g>
180
- <path
181
- d='M5,10 L50,90 L95,10 Z'
182
- ${primaryForegroundFill}
183
- />
184
- </g>
185
- `;
189
+ const icon = makeDropdownIcon();
186
190
  icon.classList.add(`${toolbarCSSPrefix}showHideDropdownIcon`);
187
- icon.setAttribute('viewBox', '0 0 100 100');
188
191
  return icon;
189
192
  }
190
193
  }
@@ -194,21 +197,7 @@ class EraserWidget extends ToolbarWidget {
194
197
  return this.localizationTable.eraser;
195
198
  }
196
199
  protected createIcon(): Element {
197
- const icon = document.createElementNS(svgNamespace, 'svg');
198
-
199
- // Draw an eraser-like shape
200
- icon.innerHTML = `
201
- <g>
202
- <rect x=10 y=50 width=80 height=30 rx=10 fill='pink' />
203
- <rect
204
- x=10 y=10 width=80 height=50
205
- ${primaryForegroundFill}
206
- />
207
- </g>
208
- `;
209
- icon.setAttribute('viewBox', '0 0 100 100');
210
-
211
- return icon;
200
+ return makeEraserIcon();
212
201
  }
213
202
 
214
203
  protected fillDropdown(_dropdown: HTMLElement): boolean {
@@ -229,19 +218,9 @@ class SelectionWidget extends ToolbarWidget {
229
218
  }
230
219
 
231
220
  protected createIcon(): Element {
232
- const icon = document.createElementNS(svgNamespace, 'svg');
233
-
234
- // Draw a cursor-like shape
235
- icon.innerHTML = `
236
- <g>
237
- <rect x=10 y=10 width=70 height=70 fill='pink' stroke='black'/>
238
- <rect x=75 y=75 width=10 height=10 fill='white' stroke='black'/>
239
- </g>
240
- `;
241
- icon.setAttribute('viewBox', '0 0 100 100');
242
-
243
- return icon;
221
+ return makeSelectionIcon();
244
222
  }
223
+
245
224
  protected fillDropdown(dropdown: HTMLElement): boolean {
246
225
  const container = document.createElement('div');
247
226
  const resizeButton = document.createElement('button');
@@ -284,42 +263,143 @@ class SelectionWidget extends ToolbarWidget {
284
263
  }
285
264
  }
286
265
 
287
- class TouchDrawingWidget extends ToolbarWidget {
266
+ const makeZoomControl = (localizationTable: ToolbarLocalization, editor: Editor) => {
267
+ const zoomLevelRow = document.createElement('div');
268
+
269
+ const increaseButton = document.createElement('button');
270
+ const decreaseButton = document.createElement('button');
271
+ const zoomLevelDisplay = document.createElement('span');
272
+ increaseButton.innerText = '+';
273
+ decreaseButton.innerText = '-';
274
+ zoomLevelRow.replaceChildren(zoomLevelDisplay, increaseButton, decreaseButton);
275
+
276
+ zoomLevelRow.classList.add(`${toolbarCSSPrefix}zoomLevelEditor`);
277
+ zoomLevelDisplay.classList.add('zoomDisplay');
278
+
279
+ let lastZoom: number|undefined;
280
+ const updateZoomDisplay = () => {
281
+ let zoomLevel = editor.viewport.getScaleFactor() * 100;
282
+
283
+ if (zoomLevel > 0.1) {
284
+ zoomLevel = Math.round(zoomLevel * 10) / 10;
285
+ } else {
286
+ zoomLevel = Math.round(zoomLevel * 1000) / 1000;
287
+ }
288
+
289
+ if (zoomLevel !== lastZoom) {
290
+ zoomLevelDisplay.innerText = localizationTable.zoomLevel(zoomLevel);
291
+ lastZoom = zoomLevel;
292
+ }
293
+ };
294
+ updateZoomDisplay();
295
+
296
+ editor.notifier.on(EditorEventType.ViewportChanged, (event) => {
297
+ if (event.kind === EditorEventType.ViewportChanged) {
298
+ updateZoomDisplay();
299
+ }
300
+ });
301
+
302
+ const zoomBy = (factor: number) => {
303
+ const screenCenter = editor.viewport.visibleRect.center;
304
+ const transformUpdate = Mat33.scaling2D(factor, screenCenter);
305
+ editor.dispatch(new Viewport.ViewportTransform(transformUpdate), false);
306
+ };
307
+
308
+ increaseButton.onclick = () => {
309
+ zoomBy(5.0/4);
310
+ };
311
+
312
+ decreaseButton.onclick = () => {
313
+ zoomBy(4.0/5);
314
+ };
315
+
316
+ return zoomLevelRow;
317
+ };
318
+
319
+ class HandToolWidget extends ToolbarWidget {
320
+ public constructor(
321
+ editor: Editor, protected tool: PanZoom, localizationTable: ToolbarLocalization
322
+ ) {
323
+ super(editor, tool, localizationTable);
324
+ this.container.classList.add('dropdownShowable');
325
+ }
288
326
  protected getTitle(): string {
289
- return this.localizationTable.touchDrawing;
327
+ return this.localizationTable.handTool;
290
328
  }
291
329
 
292
330
  protected createIcon(): Element {
293
- const icon = document.createElementNS(svgNamespace, 'svg');
294
-
295
- // Draw a cursor-like shape
296
- icon.innerHTML = `
297
- <g>
298
- <path d='M11,-30 Q0,10 20,20 Q40,20 40,-30 Z' fill='blue' stroke='black'/>
299
- <path d='
300
- M0,90 L0,50 Q5,40 10,50
301
- L10,20 Q20,15 30,20
302
- L30,50 Q50,40 80,50
303
- L80,90 L10,90 Z'
304
-
305
- ${primaryForegroundStrokeFill}
306
- />
307
- </g>
308
- `;
309
- icon.setAttribute('viewBox', '-10 -30 100 100');
331
+ return makeHandToolIcon();
332
+ }
310
333
 
311
- return icon;
334
+ protected fillDropdown(dropdown: HTMLElement): boolean {
335
+ type OnToggle = (checked: boolean)=>void;
336
+ let idCounter = 0;
337
+ const addCheckbox = (label: string, onToggle: OnToggle) => {
338
+ const rowContainer = document.createElement('div');
339
+ const labelElem = document.createElement('label');
340
+ const checkboxElem = document.createElement('input');
341
+
342
+ checkboxElem.type = 'checkbox';
343
+ checkboxElem.id = `${toolbarCSSPrefix}hand-tool-option-${idCounter++}`;
344
+ labelElem.setAttribute('for', checkboxElem.id);
345
+
346
+ checkboxElem.oninput = () => {
347
+ onToggle(checkboxElem.checked);
348
+ };
349
+ labelElem.innerText = label;
350
+
351
+ rowContainer.replaceChildren(checkboxElem, labelElem);
352
+ dropdown.appendChild(rowContainer);
353
+
354
+ return checkboxElem;
355
+ };
356
+
357
+ const setModeFlag = (enabled: boolean, flag: PanZoomMode) => {
358
+ const mode = this.tool.getMode();
359
+ if (enabled) {
360
+ this.tool.setMode(mode | flag);
361
+ } else {
362
+ this.tool.setMode(mode & ~flag);
363
+ }
364
+ };
365
+
366
+ const touchPanningCheckbox = addCheckbox(this.localizationTable.touchPanning, checked => {
367
+ setModeFlag(checked, PanZoomMode.OneFingerTouchGestures);
368
+ });
369
+
370
+ const anyDevicePanningCheckbox = addCheckbox(this.localizationTable.anyDevicePanning, checked => {
371
+ setModeFlag(checked, PanZoomMode.SinglePointerGestures);
372
+ });
373
+
374
+ dropdown.appendChild(makeZoomControl(this.localizationTable, this.editor));
375
+
376
+ const updateInputs = () => {
377
+ const mode = this.tool.getMode();
378
+ anyDevicePanningCheckbox.checked = !!(mode & PanZoomMode.SinglePointerGestures);
379
+ if (anyDevicePanningCheckbox.checked) {
380
+ touchPanningCheckbox.checked = true;
381
+ touchPanningCheckbox.disabled = true;
382
+ } else {
383
+ touchPanningCheckbox.checked = !!(mode & PanZoomMode.OneFingerTouchGestures);
384
+ touchPanningCheckbox.disabled = false;
385
+ }
386
+ };
387
+
388
+ updateInputs();
389
+ this.editor.notifier.on(EditorEventType.ToolUpdated, event => {
390
+ if (event.kind === EditorEventType.ToolUpdated && event.tool === this.tool) {
391
+ updateInputs();
392
+ }
393
+ });
394
+
395
+ return true;
312
396
  }
313
- protected fillDropdown(_dropdown: HTMLElement): boolean {
314
- // No dropdown
315
- return false;
397
+
398
+ protected updateSelected(_active: boolean) {
316
399
  }
317
- protected updateSelected(active: boolean) {
318
- if (active) {
319
- this.container.classList.remove('selected');
320
- } else {
321
- this.container.classList.add('selected');
322
- }
400
+
401
+ protected handleClick() {
402
+ this.setDropdownVisible(!this.isDropdownVisible());
323
403
  }
324
404
  }
325
405
 
@@ -348,92 +428,17 @@ class PenWidget extends ToolbarWidget {
348
428
  return this.targetTool.description;
349
429
  }
350
430
 
351
- private makePenIcon(elem: SVGSVGElement) {
352
- // Use a square-root scale to prevent the pen's tip from overflowing.
353
- const scale = Math.round(Math.sqrt(this.tool.getThickness()) * 2);
354
- const color = this.tool.getColor();
355
-
356
- // Draw a pen-like shape
357
- const primaryStrokeTipPath = `M14,63 L${50 - scale},95 L${50 + scale},90 L88,60 Z`;
358
- const backgroundStrokeTipPath = `M14,63 L${50 - scale},85 L${50 + scale},83 L88,60 Z`;
359
- elem.innerHTML = `
360
- <defs>
361
- <pattern
362
- id='checkerboard'
363
- viewBox='0,0,10,10'
364
- width='20%'
365
- height='20%'
366
- patternUnits='userSpaceOnUse'
367
- >
368
- <rect x=0 y=0 width=10 height=10 fill='white'/>
369
- <rect x=0 y=0 width=5 height=5 fill='gray'/>
370
- <rect x=5 y=5 width=5 height=5 fill='gray'/>
371
- </pattern>
372
- </defs>
373
- <g>
374
- <!-- Pen grip -->
375
- <path
376
- d='M10,10 L90,10 L90,60 L${50 + scale},80 L${50 - scale},80 L10,60 Z'
377
- ${primaryForegroundStrokeFill}
378
- />
379
- </g>
380
- <g>
381
- <!-- Checkerboard background for slightly transparent pens -->
382
- <path d='${backgroundStrokeTipPath}' fill='url(#checkerboard)'/>
383
-
384
- <!-- Actual pen tip -->
385
- <path
386
- d='${primaryStrokeTipPath}'
387
- fill='${color.toHexString()}'
388
- stroke='${color.toHexString()}'
389
- />
390
- </g>
391
- `;
392
- }
393
-
394
- // Draws an icon with the pen.
395
- private makeDrawnIcon(icon: SVGSVGElement) {
396
- const strokeFactory = this.tool.getStrokeFactory();
397
-
398
- const toolThickness = this.tool.getThickness();
399
-
400
- const nowTime = (new Date()).getTime();
401
- const startPoint: StrokeDataPoint = {
402
- pos: Vec2.of(10, 10),
403
- width: toolThickness / 5,
404
- color: this.tool.getColor(),
405
- time: nowTime - 100,
406
- };
407
- const endPoint: StrokeDataPoint = {
408
- pos: Vec2.of(90, 90),
409
- width: toolThickness / 5,
410
- color: this.tool.getColor(),
411
- time: nowTime,
412
- };
413
-
414
- const builder = strokeFactory(startPoint, this.editor.viewport);
415
- builder.addPoint(endPoint);
416
-
417
- const viewport = new Viewport(new EventDispatcher());
418
- viewport.updateScreenSize(Vec2.of(100, 100));
419
- const renderer = new SVGRenderer(icon, viewport);
420
- builder.preview(renderer);
421
- }
422
-
423
431
  protected createIcon(): Element {
424
- // We need to use createElementNS to embed an SVG element in HTML.
425
- // See http://zhangwenli.com/blog/2017/07/26/createelementns/
426
- const icon = document.createElementNS(svgNamespace, 'svg');
427
- icon.setAttribute('viewBox', '0 0 100 100');
428
-
429
432
  const strokeFactory = this.tool.getStrokeFactory();
430
433
  if (strokeFactory === makeFreehandLineBuilder) {
431
- this.makePenIcon(icon);
434
+ // Use a square-root scale to prevent the pen's tip from overflowing.
435
+ const scale = Math.round(Math.sqrt(this.tool.getThickness()) * 4);
436
+ const color = this.tool.getColor();
437
+ return makePenIcon(scale, color.toHexString());
432
438
  } else {
433
- this.makeDrawnIcon(icon);
439
+ const strokeFactory = this.tool.getStrokeFactory();
440
+ return makeIconFromFactory(this.tool, strokeFactory);
434
441
  }
435
-
436
- return icon;
437
442
  }
438
443
 
439
444
  private static idCounter: number = 0;
@@ -614,10 +619,24 @@ export default class HTMLToolbar {
614
619
  });
615
620
  }
616
621
 
617
- public addActionButton(text: string, command: ()=> void, parent?: Element) {
622
+ public addActionButton(title: string|ActionButtonIcon, command: ()=> void, parent?: Element) {
618
623
  const button = document.createElement('button');
619
- button.innerText = text;
620
624
  button.classList.add(`${toolbarCSSPrefix}toolButton`);
625
+
626
+ if (typeof title === 'string') {
627
+ button.innerText = title;
628
+ } else {
629
+ const iconElem = title.icon.cloneNode(true) as HTMLElement;
630
+ const labelElem = document.createElement('label');
631
+
632
+ // Use the label to describe the icon -- no additional description should be necessary.
633
+ iconElem.setAttribute('alt', '');
634
+ labelElem.innerText = title.label;
635
+ iconElem.classList.add('toolbar-icon');
636
+
637
+ button.replaceChildren(iconElem, labelElem);
638
+ }
639
+
621
640
  button.onclick = command;
622
641
  (parent ?? this.container).appendChild(button);
623
642
 
@@ -628,10 +647,16 @@ export default class HTMLToolbar {
628
647
  const undoRedoGroup = document.createElement('div');
629
648
  undoRedoGroup.classList.add(`${toolbarCSSPrefix}buttonGroup`);
630
649
 
631
- const undoButton = this.addActionButton('Undo', () => {
650
+ const undoButton = this.addActionButton({
651
+ label: 'Undo',
652
+ icon: makeUndoIcon()
653
+ }, () => {
632
654
  this.editor.history.undo();
633
655
  }, undoRedoGroup);
634
- const redoButton = this.addActionButton('Redo', () => {
656
+ const redoButton = this.addActionButton({
657
+ label: 'Redo',
658
+ icon: makeRedoIcon(),
659
+ }, () => {
635
660
  this.editor.history.redo();
636
661
  }, undoRedoGroup);
637
662
  this.container.appendChild(undoRedoGroup);
@@ -677,8 +702,12 @@ export default class HTMLToolbar {
677
702
  (new SelectionWidget(this.editor, tool, this.localizationTable)).addTo(this.container);
678
703
  }
679
704
 
680
- for (const tool of toolController.getMatchingTools(ToolType.TouchPanZoom)) {
681
- (new TouchDrawingWidget(this.editor, tool, this.localizationTable)).addTo(this.container);
705
+ for (const tool of toolController.getMatchingTools(ToolType.PanZoom)) {
706
+ if (!(tool instanceof PanZoom)) {
707
+ throw new Error('All SelectionTools must have kind === ToolType.PanZoom');
708
+ }
709
+
710
+ (new HandToolWidget(this.editor, tool, this.localizationTable)).addTo(this.container);
682
711
  }
683
712
 
684
713
  this.setupColorPickers();
@@ -0,0 +1,203 @@
1
+ import { ComponentBuilderFactory } from '../components/builders/types';
2
+ import EventDispatcher from '../EventDispatcher';
3
+ import { Vec2 } from '../geometry/Vec2';
4
+ import SVGRenderer from '../rendering/renderers/SVGRenderer';
5
+ import Pen from '../tools/Pen';
6
+ import { StrokeDataPoint } from '../types';
7
+ import Viewport from '../Viewport';
8
+
9
+ const svgNamespace = 'http://www.w3.org/2000/svg';
10
+ const primaryForegroundFill = `
11
+ style='fill: var(--primary-foreground-color);'
12
+ `;
13
+ const primaryForegroundStrokeFill = `
14
+ style='fill: var(--primary-foreground-color); stroke: var(--primary-foreground-color);'
15
+ `;
16
+
17
+ export const makeUndoIcon = () => {
18
+ return makeRedoIcon(true);
19
+ };
20
+
21
+ export const makeRedoIcon = (mirror: boolean = false) => {
22
+ const icon = document.createElementNS(svgNamespace, 'svg');
23
+ icon.innerHTML = `
24
+ <style>
25
+ .toolbar-svg-undo-redo-icon {
26
+ stroke: var(--primary-foreground-color);
27
+ stroke-width: 12;
28
+ stroke-linejoin: round;
29
+ stroke-linecap: round;
30
+ fill: none;
31
+
32
+ transform-origin: center;
33
+ }
34
+ </style>
35
+ <path
36
+ d='M20,20 A15,15 0 0 1 70,80 L80,90 L60,70 L65,90 L87,90 L65,80'
37
+ class='toolbar-svg-undo-redo-icon'
38
+ style='${mirror ? 'transform: scale(-1, 1);' : ''}'/>
39
+ `;
40
+ icon.setAttribute('viewBox', '0 0 100 100');
41
+ return icon;
42
+ };
43
+
44
+ export const makeDropdownIcon = () => {
45
+ const icon = document.createElementNS(svgNamespace, 'svg');
46
+ icon.innerHTML = `
47
+ <g>
48
+ <path
49
+ d='M5,10 L50,90 L95,10 Z'
50
+ ${primaryForegroundFill}
51
+ />
52
+ </g>
53
+ `;
54
+ icon.setAttribute('viewBox', '0 0 100 100');
55
+ return icon;
56
+ };
57
+
58
+ export const makeEraserIcon = () => {
59
+ const icon = document.createElementNS(svgNamespace, 'svg');
60
+
61
+ // Draw an eraser-like shape
62
+ icon.innerHTML = `
63
+ <g>
64
+ <rect x=10 y=50 width=80 height=30 rx=10 fill='pink' />
65
+ <rect
66
+ x=10 y=10 width=80 height=50
67
+ ${primaryForegroundFill}
68
+ />
69
+ </g>
70
+ `;
71
+ icon.setAttribute('viewBox', '0 0 100 100');
72
+ return icon;
73
+ };
74
+
75
+ export const makeSelectionIcon = () => {
76
+ const icon = document.createElementNS(svgNamespace, 'svg');
77
+
78
+ // Draw a cursor-like shape
79
+ icon.innerHTML = `
80
+ <g>
81
+ <rect x=10 y=10 width=70 height=70 fill='pink' stroke='black'/>
82
+ <rect x=75 y=75 width=10 height=10 fill='white' stroke='black'/>
83
+ </g>
84
+ `;
85
+ icon.setAttribute('viewBox', '0 0 100 100');
86
+
87
+ return icon;
88
+ };
89
+
90
+ export const makeHandToolIcon = () => {
91
+ const icon = document.createElementNS(svgNamespace, 'svg');
92
+
93
+ // Draw a cursor-like shape
94
+ icon.innerHTML = `
95
+ <g>
96
+ <path d='
97
+ m 10,60
98
+ 5,30
99
+ H 90
100
+ V 30
101
+ C 90,20 75,20 75,30
102
+ V 60
103
+ 20
104
+ C 75,10 60,10 60,20
105
+ V 60
106
+ 15
107
+ C 60,5 45,5 45,15
108
+ V 60
109
+ 25
110
+ C 45,15 30,15 30,25
111
+ V 60
112
+ 75
113
+ L 25,60
114
+ C 20,45 10,50 10,60
115
+ Z'
116
+
117
+ fill='none'
118
+ style='
119
+ stroke: var(--primary-foreground-color);
120
+ stroke-width: 2;
121
+ '
122
+ />
123
+ </g>
124
+ `;
125
+ icon.setAttribute('viewBox', '0 0 100 100');
126
+ return icon;
127
+ };
128
+
129
+ export const makePenIcon = (tipThickness: number, color: string) => {
130
+ const icon = document.createElementNS(svgNamespace, 'svg');
131
+ icon.setAttribute('viewBox', '0 0 100 100');
132
+
133
+ const halfThickness = tipThickness / 2;
134
+
135
+ // Draw a pen-like shape
136
+ const primaryStrokeTipPath = `M14,63 L${50 - halfThickness},95 L${50 + halfThickness},90 L88,60 Z`;
137
+ const backgroundStrokeTipPath = `M14,63 L${50 - halfThickness},85 L${50 + halfThickness},83 L88,60 Z`;
138
+ icon.innerHTML = `
139
+ <defs>
140
+ <pattern
141
+ id='checkerboard'
142
+ viewBox='0,0,10,10'
143
+ width='20%'
144
+ height='20%'
145
+ patternUnits='userSpaceOnUse'
146
+ >
147
+ <rect x=0 y=0 width=10 height=10 fill='white'/>
148
+ <rect x=0 y=0 width=5 height=5 fill='gray'/>
149
+ <rect x=5 y=5 width=5 height=5 fill='gray'/>
150
+ </pattern>
151
+ </defs>
152
+ <g>
153
+ <!-- Pen grip -->
154
+ <path
155
+ d='M10,10 L90,10 L90,60 L${50 + halfThickness},80 L${50 - halfThickness},80 L10,60 Z'
156
+ ${primaryForegroundStrokeFill}
157
+ />
158
+ </g>
159
+ <g>
160
+ <!-- Checkerboard background for slightly transparent pens -->
161
+ <path d='${backgroundStrokeTipPath}' fill='url(#checkerboard)'/>
162
+
163
+ <!-- Actual pen tip -->
164
+ <path
165
+ d='${primaryStrokeTipPath}'
166
+ fill='${color}'
167
+ stroke='${color}'
168
+ />
169
+ </g>
170
+ `;
171
+ return icon;
172
+ };
173
+
174
+ export const makeIconFromFactory = (pen: Pen, factory: ComponentBuilderFactory) => {
175
+ const toolThickness = pen.getThickness();
176
+
177
+ const nowTime = (new Date()).getTime();
178
+ const startPoint: StrokeDataPoint = {
179
+ pos: Vec2.of(10, 10),
180
+ width: toolThickness / 5,
181
+ color: pen.getColor(),
182
+ time: nowTime - 100,
183
+ };
184
+ const endPoint: StrokeDataPoint = {
185
+ pos: Vec2.of(90, 90),
186
+ width: toolThickness / 5,
187
+ color: pen.getColor(),
188
+ time: nowTime,
189
+ };
190
+
191
+ const viewport = new Viewport(new EventDispatcher());
192
+ const builder = factory(startPoint, viewport);
193
+ builder.addPoint(endPoint);
194
+
195
+ const icon = document.createElementNS(svgNamespace, 'svg');
196
+ icon.setAttribute('viewBox', '0 0 100 100');
197
+ viewport.updateScreenSize(Vec2.of(100, 100));
198
+
199
+ const renderer = new SVGRenderer(icon, viewport);
200
+ builder.preview(renderer);
201
+
202
+ return icon;
203
+ };