js-draw 0.1.12 → 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.
Files changed (122) hide show
  1. package/.eslintrc.js +1 -0
  2. package/.firebaserc +5 -0
  3. package/.github/workflows/firebase-hosting-merge.yml +25 -0
  4. package/.github/workflows/firebase-hosting-pull-request.yml +22 -0
  5. package/.github/workflows/github-pages.yml +52 -0
  6. package/CHANGELOG.md +6 -0
  7. package/README.md +11 -6
  8. package/dist/bundle.js +1 -1
  9. package/dist/src/Color4.d.ts +19 -0
  10. package/dist/src/Color4.js +24 -3
  11. package/dist/src/Editor.d.ts +129 -2
  12. package/dist/src/Editor.js +94 -17
  13. package/dist/src/EditorImage.d.ts +7 -2
  14. package/dist/src/EditorImage.js +41 -25
  15. package/dist/src/EventDispatcher.d.ts +18 -0
  16. package/dist/src/EventDispatcher.js +19 -4
  17. package/dist/src/Pointer.js +3 -2
  18. package/dist/src/UndoRedoHistory.js +15 -2
  19. package/dist/src/Viewport.js +4 -1
  20. package/dist/src/bundle/bundled.d.ts +1 -2
  21. package/dist/src/bundle/bundled.js +1 -2
  22. package/dist/src/commands/Duplicate.d.ts +1 -1
  23. package/dist/src/commands/Duplicate.js +3 -4
  24. package/dist/src/commands/Erase.d.ts +1 -1
  25. package/dist/src/commands/Erase.js +6 -5
  26. package/dist/src/commands/SerializableCommand.d.ts +4 -5
  27. package/dist/src/commands/SerializableCommand.js +12 -4
  28. package/dist/src/commands/invertCommand.d.ts +4 -0
  29. package/dist/src/commands/invertCommand.js +44 -0
  30. package/dist/src/commands/lib.d.ts +6 -0
  31. package/dist/src/commands/lib.js +6 -0
  32. package/dist/src/commands/localization.d.ts +1 -0
  33. package/dist/src/commands/localization.js +1 -0
  34. package/dist/src/components/AbstractComponent.d.ts +13 -8
  35. package/dist/src/components/AbstractComponent.js +26 -15
  36. package/dist/src/components/SVGGlobalAttributesObject.d.ts +1 -1
  37. package/dist/src/components/SVGGlobalAttributesObject.js +7 -1
  38. package/dist/src/components/Stroke.d.ts +12 -2
  39. package/dist/src/components/Stroke.js +10 -7
  40. package/dist/src/components/Text.d.ts +2 -2
  41. package/dist/src/components/Text.js +6 -6
  42. package/dist/src/components/UnknownSVGObject.d.ts +1 -1
  43. package/dist/src/components/UnknownSVGObject.js +6 -1
  44. package/dist/src/components/lib.d.ts +4 -0
  45. package/dist/src/components/lib.js +4 -0
  46. package/dist/src/lib.d.ts +25 -0
  47. package/dist/src/lib.js +25 -0
  48. package/dist/src/math/Mat33.d.ts +47 -1
  49. package/dist/src/math/Mat33.js +48 -20
  50. package/dist/src/math/Path.js +3 -3
  51. package/dist/src/math/Rect2.d.ts +2 -2
  52. package/dist/src/math/Vec3.d.ts +62 -0
  53. package/dist/src/math/Vec3.js +62 -14
  54. package/dist/src/math/lib.d.ts +7 -0
  55. package/dist/src/math/lib.js +7 -0
  56. package/dist/src/math/rounding.js +1 -0
  57. package/dist/src/rendering/Display.d.ts +44 -0
  58. package/dist/src/rendering/Display.js +45 -6
  59. package/dist/src/rendering/caching/CacheRecord.d.ts +1 -0
  60. package/dist/src/rendering/caching/CacheRecord.js +3 -0
  61. package/dist/src/rendering/caching/CacheRecordManager.d.ts +4 -3
  62. package/dist/src/rendering/caching/CacheRecordManager.js +16 -4
  63. package/dist/src/rendering/caching/RenderingCache.d.ts +2 -3
  64. package/dist/src/rendering/caching/RenderingCache.js +9 -10
  65. package/dist/src/rendering/caching/types.d.ts +1 -3
  66. package/dist/src/rendering/renderers/CanvasRenderer.js +1 -1
  67. package/dist/src/toolbar/HTMLToolbar.js +1 -0
  68. package/dist/src/toolbar/makeColorInput.js +1 -1
  69. package/dist/src/toolbar/widgets/PenWidget.js +1 -0
  70. package/dist/src/tools/Pen.d.ts +1 -2
  71. package/dist/src/tools/Pen.js +8 -1
  72. package/dist/src/tools/PipetteTool.js +1 -0
  73. package/dist/src/tools/SelectionTool.js +45 -22
  74. package/dist/src/types.d.ts +17 -6
  75. package/dist/src/types.js +7 -5
  76. package/firebase.json +16 -0
  77. package/package.json +118 -101
  78. package/src/Color4.ts +23 -2
  79. package/src/Editor.ts +147 -25
  80. package/src/EditorImage.ts +45 -27
  81. package/src/EventDispatcher.ts +21 -6
  82. package/src/Pointer.ts +3 -2
  83. package/src/UndoRedoHistory.ts +18 -2
  84. package/src/Viewport.ts +5 -2
  85. package/src/bundle/bundled.ts +1 -2
  86. package/src/commands/Duplicate.ts +3 -4
  87. package/src/commands/Erase.ts +6 -5
  88. package/src/commands/SerializableCommand.ts +17 -9
  89. package/src/commands/invertCommand.ts +51 -0
  90. package/src/commands/lib.ts +14 -0
  91. package/src/commands/localization.ts +2 -0
  92. package/src/components/AbstractComponent.ts +31 -20
  93. package/src/components/SVGGlobalAttributesObject.ts +8 -1
  94. package/src/components/Stroke.test.ts +1 -1
  95. package/src/components/Stroke.ts +11 -7
  96. package/src/components/Text.ts +6 -7
  97. package/src/components/UnknownSVGObject.ts +7 -1
  98. package/src/components/lib.ts +9 -0
  99. package/src/lib.ts +28 -0
  100. package/src/math/Mat33.ts +48 -20
  101. package/src/math/Path.ts +3 -3
  102. package/src/math/Rect2.ts +2 -2
  103. package/src/math/Vec3.ts +62 -14
  104. package/src/math/lib.ts +15 -0
  105. package/src/math/rounding.ts +2 -0
  106. package/src/rendering/Display.ts +46 -6
  107. package/src/rendering/caching/CacheRecord.test.ts +1 -1
  108. package/src/rendering/caching/CacheRecord.ts +4 -0
  109. package/src/rendering/caching/CacheRecordManager.ts +33 -7
  110. package/src/rendering/caching/RenderingCache.ts +10 -15
  111. package/src/rendering/caching/types.ts +1 -6
  112. package/src/rendering/renderers/CanvasRenderer.ts +1 -1
  113. package/src/toolbar/HTMLToolbar.ts +1 -0
  114. package/src/toolbar/makeColorInput.ts +1 -1
  115. package/src/toolbar/widgets/PenWidget.ts +2 -0
  116. package/src/tools/PanZoom.ts +0 -1
  117. package/src/tools/Pen.ts +11 -2
  118. package/src/tools/PipetteTool.ts +2 -0
  119. package/src/tools/SelectionTool.ts +46 -18
  120. package/src/types.ts +19 -3
  121. package/tsconfig.json +4 -1
  122. package/typedoc.json +20 -0
@@ -1,6 +1,5 @@
1
1
  import Editor from './Editor';
2
2
  import AbstractRenderer from './rendering/renderers/AbstractRenderer';
3
- import Command from './commands/Command';
4
3
  import Viewport from './Viewport';
5
4
  import AbstractComponent from './components/AbstractComponent';
6
5
  import Rect2 from './math/Rect2';
@@ -8,6 +7,7 @@ import { EditorLocalization } from './localization';
8
7
  import RenderingCache from './rendering/caching/RenderingCache';
9
8
  import SerializableCommand from './commands/SerializableCommand';
10
9
 
10
+ // @internal
11
11
  export const sortLeavesByZIndex = (leaves: Array<ImageNode>) => {
12
12
  leaves.sort((a, b) => a.getContent()!.getZIndex() - b.getContent()!.getZIndex());
13
13
  };
@@ -17,6 +17,7 @@ export default class EditorImage {
17
17
  private root: ImageNode;
18
18
  private componentsById: Record<string, AbstractComponent>;
19
19
 
20
+ // @internal
20
21
  public constructor() {
21
22
  this.root = new ImageNode();
22
23
  this.componentsById = {};
@@ -33,15 +34,17 @@ export default class EditorImage {
33
34
  return null;
34
35
  }
35
36
 
37
+ /** @internal */
36
38
  public renderWithCache(screenRenderer: AbstractRenderer, cache: RenderingCache, viewport: Viewport) {
37
39
  cache.render(screenRenderer, this.root, viewport);
38
40
  }
39
41
 
42
+ /** @internal */
40
43
  public render(renderer: AbstractRenderer, viewport: Viewport) {
41
44
  this.root.render(renderer, viewport.visibleRect);
42
45
  }
43
46
 
44
- // Renders all nodes, even ones not within the viewport
47
+ /** Renders all nodes, even ones not within the viewport. @internal */
45
48
  public renderAll(renderer: AbstractRenderer) {
46
49
  const leaves = this.root.getLeaves();
47
50
  sortLeavesByZIndex(leaves);
@@ -58,6 +61,7 @@ export default class EditorImage {
58
61
  return leaves.map(leaf => leaf.getContent()!);
59
62
  }
60
63
 
64
+ /** @internal */
61
65
  public onDestroyElement(elem: AbstractComponent) {
62
66
  delete this.componentsById[elem.getId()];
63
67
  }
@@ -71,7 +75,7 @@ export default class EditorImage {
71
75
  return this.root.addLeaf(elem);
72
76
  }
73
77
 
74
- public static addElement(elem: AbstractComponent, applyByFlattening: boolean = false): Command {
78
+ public static addElement(elem: AbstractComponent, applyByFlattening: boolean = false): SerializableCommand {
75
79
  return new EditorImage.AddElementCommand(elem, applyByFlattening);
76
80
  }
77
81
 
@@ -108,20 +112,21 @@ export default class EditorImage {
108
112
  editor.queueRerender();
109
113
  }
110
114
 
111
- public description(editor: Editor, localization: EditorLocalization) {
115
+ public description(_editor: Editor, localization: EditorLocalization) {
112
116
  return localization.addElementAction(this.element.description(localization));
113
117
  }
114
118
 
115
- protected serializeToString() {
116
- return JSON.stringify({
119
+ protected serializeToJSON() {
120
+ return {
117
121
  elemData: this.element.serialize(),
118
- });
122
+ };
119
123
  }
120
124
 
121
125
  static {
122
- SerializableCommand.register('add-element', (data: string, _editor: Editor) => {
123
- const json = JSON.parse(data);
124
- const elem = AbstractComponent.deserialize(json.elemData);
126
+ SerializableCommand.register('add-element', (json: any, editor: Editor) => {
127
+ const id = json.elemData.id;
128
+ const foundElem = editor.image.lookupElement(id);
129
+ const elem = foundElem ?? AbstractComponent.deserialize(json.elemData);
125
130
  return new EditorImage.AddElementCommand(elem);
126
131
  });
127
132
  }
@@ -130,7 +135,7 @@ export default class EditorImage {
130
135
 
131
136
  type TooSmallToRenderCheck = (rect: Rect2)=> boolean;
132
137
 
133
- // TODO: Assign leaf nodes to CacheNodes. When leaf nodes are modified, the corresponding CacheNodes can be updated.
138
+ /** Part of the Editor's image. @internal */
134
139
  export class ImageNode {
135
140
  private content: AbstractComponent|null;
136
141
  private bbox: Rect2;
@@ -182,19 +187,29 @@ export class ImageNode {
182
187
  // Returns a list of `ImageNode`s with content (and thus no children).
183
188
  public getLeavesIntersectingRegion(region: Rect2, isTooSmall?: TooSmallToRenderCheck): ImageNode[] {
184
189
  const result: ImageNode[] = [];
190
+ let current: ImageNode|undefined;
191
+ const workList: ImageNode[] = [];
185
192
 
186
- // Don't render if too small
187
- if (isTooSmall?.(this.bbox)) {
188
- return [];
189
- }
193
+ workList.push(this);
194
+ const toNext = () => {
195
+ current = undefined;
190
196
 
191
- if (this.content !== null && this.getBBox().intersects(region)) {
192
- result.push(this);
193
- }
197
+ const next = workList.pop();
198
+ if (next && !isTooSmall?.(next.bbox)) {
199
+ current = next;
200
+
201
+ if (current.content !== null && current.getBBox().intersection(region)) {
202
+ result.push(current);
203
+ }
194
204
 
195
- const children = this.getChildrenIntersectingRegion(region);
196
- for (const child of children) {
197
- result.push(...child.getLeavesIntersectingRegion(region, isTooSmall));
205
+ workList.push(
206
+ ...current.getChildrenIntersectingRegion(region)
207
+ );
208
+ }
209
+ };
210
+
211
+ while (workList.length > 0) {
212
+ toNext();
198
213
  }
199
214
 
200
215
  return result;
@@ -239,15 +254,18 @@ export class ImageNode {
239
254
  // share a parent.
240
255
  const leafBBox = leaf.getBBox();
241
256
  if (leafBBox.containsRect(this.getBBox())) {
242
- // Create a node for this' children and for the new content..
243
257
  const nodeForNewLeaf = new ImageNode(this);
244
- const nodeForChildren = new ImageNode(this);
245
258
 
246
- nodeForChildren.children = this.children;
247
- this.children = [nodeForNewLeaf, nodeForChildren];
248
- nodeForChildren.recomputeBBox(true);
249
- nodeForChildren.updateParents();
259
+ if (this.children.length < this.targetChildCount) {
260
+ this.children.push(nodeForNewLeaf);
261
+ } else {
262
+ const nodeForChildren = new ImageNode(this);
250
263
 
264
+ nodeForChildren.children = this.children;
265
+ this.children = [nodeForNewLeaf, nodeForChildren];
266
+ nodeForChildren.recomputeBBox(true);
267
+ nodeForChildren.updateParents();
268
+ }
251
269
  return nodeForNewLeaf.addLeaf(leaf);
252
270
  }
253
271
 
@@ -1,13 +1,28 @@
1
- // Code shared with Joplin
1
+ /**
2
+ * Handles notifying listeners of events.
3
+ *
4
+ * `EventKeyType` is used to distinguish events (e.g. a `ClickEvent` vs a `TouchEvent`)
5
+ * while `EventMessageType` is the type of the data sent with an event (can be `void`).
6
+ *
7
+ * @example
8
+ * ```
9
+ * const dispatcher = new EventDispatcher<'event1'|'event2'|'event3', void>();
10
+ * dispatcher.on('event1', () => {
11
+ * console.log('Event 1 triggered.');
12
+ * });
13
+ * dispatcher.dispatch('event1');
14
+ * ```
15
+ *
16
+ * @packageDocumentation
17
+ */
18
+
19
+ // Code shared with Joplin (js-draw was originally intended to be part of Joplin).
2
20
 
3
21
  type Listener<Value> = (data: Value)=> void;
4
22
  type CallbackHandler<EventType> = (data: EventType)=> void;
5
23
 
6
- // EventKeyType is used to distinguish events (e.g. a 'ClickEvent' vs a 'TouchEvent')
7
- // while EventMessageType is the type of the data sent with an event (can be `void`)
24
+ // { @inheritDoc EventDispatcher! }
8
25
  export default class EventDispatcher<EventKeyType extends string|symbol|number, EventMessageType> {
9
- // Partial marks all fields as optional. To initialize with an empty object, this is required.
10
- // See https://stackoverflow.com/a/64526384
11
26
  private listeners: Partial<Record<EventKeyType, Array<Listener<EventMessageType>>>>;
12
27
  public constructor() {
13
28
  this.listeners = {};
@@ -38,7 +53,7 @@ export default class EventDispatcher<EventKeyType extends string|symbol|number,
38
53
  };
39
54
  }
40
55
 
41
- // Equivalent to calling .remove() on the object returned by .on
56
+ /** Removes an event listener. This is equivalent to calling `.remove()` on the object returned by `.on`. */
42
57
  public off(eventName: EventKeyType, callback: CallbackHandler<EventMessageType>) {
43
58
  const listeners = this.listeners[eventName];
44
59
  if (!listeners) return;
package/src/Pointer.ts CHANGED
@@ -11,7 +11,7 @@ export enum PointerDevice {
11
11
  }
12
12
 
13
13
  // Provides a snapshot containing information about a pointer. A Pointer
14
- // object is immutable --- it will not be updated when the pointer's information changes.
14
+ // object is immutable it will not be updated when the pointer's information changes.
15
15
  export default class Pointer {
16
16
  private constructor(
17
17
  // The (x, y) position of the pointer relative to the top-left corner
@@ -31,11 +31,12 @@ export default class Pointer {
31
31
  // Unique ID for the pointer
32
32
  public readonly id: number,
33
33
 
34
- // Numeric timestamp (milliseconds, as from (new Date).getTime())
34
+ // Numeric timestamp (milliseconds, as from `(new Date).getTime()`)
35
35
  public readonly timeStamp: number,
36
36
  ) {
37
37
  }
38
38
 
39
+ // Creates a Pointer from a DOM event.
39
40
  public static ofEvent(evt: PointerEvent, isDown: boolean, viewport: Viewport): Pointer {
40
41
  const screenPos = Vec2.of(evt.offsetX, evt.offsetY);
41
42
 
@@ -9,6 +9,7 @@ class UndoRedoHistory {
9
9
  private undoStack: Command[];
10
10
  private redoStack: Command[];
11
11
 
12
+ // @internal
12
13
  public constructor(
13
14
  private readonly editor: Editor,
14
15
  private announceRedoCallback: AnnounceRedoCallback,
@@ -37,7 +38,12 @@ class UndoRedoHistory {
37
38
  elem.onDrop(this.editor);
38
39
  }
39
40
  this.redoStack = [];
41
+
40
42
  this.fireUpdateEvent();
43
+ this.editor.notifier.dispatch(EditorEventType.CommandDone, {
44
+ kind: EditorEventType.CommandDone,
45
+ command,
46
+ });
41
47
  }
42
48
 
43
49
  // Remove the last command from this' undo stack and apply it.
@@ -47,8 +53,13 @@ class UndoRedoHistory {
47
53
  this.redoStack.push(command);
48
54
  command.unapply(this.editor);
49
55
  this.announceUndoCallback(command);
56
+
57
+ this.fireUpdateEvent();
58
+ this.editor.notifier.dispatch(EditorEventType.CommandUndone, {
59
+ kind: EditorEventType.CommandUndone,
60
+ command,
61
+ });
50
62
  }
51
- this.fireUpdateEvent();
52
63
  }
53
64
 
54
65
  public redo() {
@@ -57,8 +68,13 @@ class UndoRedoHistory {
57
68
  this.undoStack.push(command);
58
69
  command.apply(this.editor);
59
70
  this.announceRedoCallback(command);
71
+
72
+ this.fireUpdateEvent();
73
+ this.editor.notifier.dispatch(EditorEventType.CommandDone, {
74
+ kind: EditorEventType.CommandDone,
75
+ command,
76
+ });
60
77
  }
61
- this.fireUpdateEvent();
62
78
  }
63
79
 
64
80
  public get undoStackSize(): number {
package/src/Viewport.ts CHANGED
@@ -81,11 +81,13 @@ export class Viewport {
81
81
  private inverseTransform: Mat33;
82
82
  private screenRect: Rect2;
83
83
 
84
+ // @internal
84
85
  public constructor(private notifier: EditorNotifier) {
85
86
  this.resetTransform(Mat33.identity);
86
87
  this.screenRect = Rect2.empty;
87
88
  }
88
89
 
90
+ // @internal
89
91
  public updateScreenSize(screenSize: Vec2) {
90
92
  this.screenRect = this.screenRect.resizedTo(screenSize);
91
93
  }
@@ -107,7 +109,7 @@ export class Viewport {
107
109
  return new Viewport.ViewportTransform(transform);
108
110
  }
109
111
 
110
- // Updates the transformation directly. Using transformBy is preferred.
112
+ // Updates the transformation directly. Using `transformBy` is preferred.
111
113
  // [newTransform] should map from canvas coordinates to screen coordinates.
112
114
  public resetTransform(newTransform: Mat33 = Mat33.identity) {
113
115
  const oldTransform = this.transform;
@@ -138,6 +140,7 @@ export class Viewport {
138
140
  return this.transform.transformVec3(Vec3.unitX).magnitude();
139
141
  }
140
142
 
143
+ // Returns the size of one screen pixel in canvas units.
141
144
  public getSizeOfPixelOnCanvas(): number {
142
145
  return 1/this.getScaleFactor();
143
146
  }
@@ -147,7 +150,7 @@ export class Viewport {
147
150
  return this.transform.transformVec3(Vec3.unitX).angle();
148
151
  }
149
152
 
150
- // Rounds the given [point] to a multiple of 10 such that it is within [tolerance] of
153
+ // Rounds the given `point` to a multiple of 10 such that it is within `tolerance` of
151
154
  // its original location. This is useful for preparing data for base-10 conversion.
152
155
  public static roundPoint<T extends Point2|number>(
153
156
  point: T, tolerance: number,
@@ -2,7 +2,6 @@
2
2
 
3
3
  import '../styles';
4
4
  import Editor from '../Editor';
5
- import getLocalizationTable from '../localizations/getLocalizationTable';
5
+ export * from '../lib';
6
6
 
7
7
  export default Editor;
8
- export { Editor, getLocalizationTable };
@@ -35,13 +35,12 @@ export default class Duplicate extends SerializableCommand {
35
35
  );
36
36
  }
37
37
 
38
- protected serializeToString(): string {
39
- return JSON.stringify(this.toDuplicate.map(elem => elem.getId()));
38
+ protected serializeToJSON() {
39
+ return this.toDuplicate.map(elem => elem.getId());
40
40
  }
41
41
 
42
42
  static {
43
- SerializableCommand.register('duplicate', (data: string, editor: Editor) => {
44
- const json = JSON.parse(data);
43
+ SerializableCommand.register('duplicate', (json: any, editor: Editor) => {
45
44
  const elems = json.map((id: string) => editor.image.lookupElement(id));
46
45
  return new Duplicate(elems);
47
46
  });
@@ -58,15 +58,16 @@ export default class Erase extends SerializableCommand {
58
58
  return localizationTable.eraseAction(description, this.toRemove.length);
59
59
  }
60
60
 
61
- protected serializeToString() {
61
+ protected serializeToJSON() {
62
62
  const elemIds = this.toRemove.map(elem => elem.getId());
63
- return JSON.stringify(elemIds);
63
+ return elemIds;
64
64
  }
65
65
 
66
66
  static {
67
- SerializableCommand.register('erase', (data: string, editor: Editor) => {
68
- const json = JSON.parse(data);
69
- const elems = json.map((elemId: string) => editor.image.lookupElement(elemId));
67
+ SerializableCommand.register('erase', (json: any, editor) => {
68
+ const elems = json
69
+ .map((elemId: string) => editor.image.lookupElement(elemId))
70
+ .filter((elem: AbstractComponent|null) => elem !== null);
70
71
  return new Erase(elems);
71
72
  });
72
73
  }
@@ -1,7 +1,7 @@
1
1
  import Editor from '../Editor';
2
2
  import Command from './Command';
3
3
 
4
- type DeserializationCallback = (data: string, editor: Editor) => SerializableCommand;
4
+ export type DeserializationCallback = (data: Record<string, any>|any[], editor: Editor) => SerializableCommand;
5
5
 
6
6
  export default abstract class SerializableCommand extends Command {
7
7
  public constructor(private commandTypeId: string) {
@@ -14,27 +14,35 @@ export default abstract class SerializableCommand extends Command {
14
14
  }
15
15
  }
16
16
 
17
- protected abstract serializeToString(): string;
17
+ protected abstract serializeToJSON(): string|Record<string, any>|any[];
18
18
  private static deserializationCallbacks: Record<string, DeserializationCallback> = {};
19
19
 
20
- public serialize(): string {
21
- return JSON.stringify({
22
- data: this.serializeToString(),
20
+ // Convert this command to an object that can be passed to `JSON.stringify`.
21
+ //
22
+ // Do not rely on the stability of the optupt of this function — it can change
23
+ // form without a major version increase.
24
+ public serialize(): Record<string|symbol, any> {
25
+ return {
26
+ data: this.serializeToJSON(),
23
27
  commandType: this.commandTypeId,
24
- });
28
+ };
25
29
  }
26
30
 
27
- public static deserialize(data: string, editor: Editor): SerializableCommand {
28
- const json = JSON.parse(data);
31
+ // Convert a `string` containing JSON data (or the output of `JSON.parse`) into a
32
+ // `Command`.
33
+ public static deserialize(data: string|Record<string, any>, editor: Editor): SerializableCommand {
34
+ const json = typeof data === 'string' ? JSON.parse(data) : data;
29
35
  const commandType = json.commandType as string;
30
36
 
31
37
  if (!(commandType in SerializableCommand.deserializationCallbacks)) {
32
38
  throw new Error(`Unrecognised command type ${commandType}!`);
33
39
  }
34
40
 
35
- return SerializableCommand.deserializationCallbacks[commandType](json.data as string, editor);
41
+ return SerializableCommand.deserializationCallbacks[commandType](json.data, editor);
36
42
  }
37
43
 
44
+ // Register a deserialization callback. This must be called at least once for every subclass of
45
+ // `SerializableCommand`.
38
46
  public static register(commandTypeId: string, deserialize: DeserializationCallback) {
39
47
  SerializableCommand.deserializationCallbacks[commandTypeId] = deserialize;
40
48
  }
@@ -0,0 +1,51 @@
1
+ import Editor from '../Editor';
2
+ import { EditorLocalization } from '../localization';
3
+ import Command from './Command';
4
+ import SerializableCommand from './SerializableCommand';
5
+
6
+ // Returns a command taht does the opposite of the given command --- `result.apply()` calls
7
+ // `command.unapply()` and `result.unapply()` calls `command.apply()`.
8
+ const invertCommand = <T extends Command> (command: T): T extends SerializableCommand ? SerializableCommand : Command => {
9
+ if (command instanceof SerializableCommand) {
10
+ // SerializableCommand that does the inverse of [command]
11
+ return new class extends SerializableCommand {
12
+ protected serializeToJSON() {
13
+ return command.serialize();
14
+ }
15
+ public apply(editor: Editor): void {
16
+ command.unapply(editor);
17
+ }
18
+ public unapply(editor: Editor): void {
19
+ command.unapply(editor);
20
+ }
21
+ public description(editor: Editor, localizationTable: EditorLocalization): string {
22
+ return localizationTable.inverseOf(command.description(editor, localizationTable));
23
+ }
24
+ }('inverse');
25
+ } else {
26
+ // Command that does the inverse of [command].
27
+ const result = new class extends Command {
28
+ public apply(editor: Editor) {
29
+ command.unapply(editor);
30
+ }
31
+
32
+ public unapply(editor: Editor) {
33
+ command.apply(editor);
34
+ }
35
+
36
+ public description(editor: Editor, localizationTable: EditorLocalization) {
37
+ return localizationTable.inverseOf(command.description(editor, localizationTable));
38
+ }
39
+ };
40
+
41
+ // We know that T does not extend SerializableCommand, and thus returning a Command
42
+ // is appropriate.
43
+ return result as any;
44
+ }
45
+ };
46
+
47
+ SerializableCommand.register('inverse', (data, editor) => {
48
+ return invertCommand(SerializableCommand.deserialize(data, editor));
49
+ });
50
+
51
+ export default invertCommand;
@@ -0,0 +1,14 @@
1
+ import Command from './Command';
2
+ import Duplicate from './Duplicate';
3
+ import Erase from './Erase';
4
+ import invertCommand from './invertCommand';
5
+ import SerializableCommand from './SerializableCommand';
6
+
7
+ export {
8
+ Command,
9
+ Duplicate,
10
+ Erase,
11
+ SerializableCommand,
12
+
13
+ invertCommand,
14
+ };
@@ -17,6 +17,7 @@ export interface CommandLocalization {
17
17
  addElementAction: (elemDescription: string) => string;
18
18
  eraseAction: (elemDescription: string, numElems: number) => string;
19
19
  duplicateAction: (elemDescription: string, count: number)=> string;
20
+ inverseOf: (actionDescription: string)=> string;
20
21
 
21
22
  selectedElements: (count: number)=>string;
22
23
  }
@@ -28,6 +29,7 @@ export const defaultCommandLocalization: CommandLocalization = {
28
29
  addElementAction: (componentDescription: string) => `Added ${componentDescription}`,
29
30
  eraseAction: (componentDescription: string, numElems: number) => `Erased ${numElems} ${componentDescription}`,
30
31
  duplicateAction: (componentDescription: string, numElems: number) => `Duplicated ${numElems} ${componentDescription}`,
32
+ inverseOf: (actionDescription: string) => `Inverse of ${actionDescription}`,
31
33
  elements: 'Elements',
32
34
  erasedNoElements: 'Erased nothing',
33
35
  duplicatedNoElements: 'Duplicated nothing',
@@ -1,4 +1,3 @@
1
- import Command from '../commands/Command';
2
1
  import SerializableCommand from '../commands/SerializableCommand';
3
2
  import Editor from '../Editor';
4
3
  import EditorImage from '../EditorImage';
@@ -9,9 +8,9 @@ import { EditorLocalization } from '../localization';
9
8
  import AbstractRenderer from '../rendering/renderers/AbstractRenderer';
10
9
  import { ImageComponentLocalization } from './localization';
11
10
 
12
- type LoadSaveData = (string[]|Record<symbol, string|number>);
11
+ export type LoadSaveData = (string[]|Record<symbol, string|number>);
13
12
  export type LoadSaveDataTable = Record<string, Array<LoadSaveData>>;
14
- type DeserializeCallback = (data: string)=>AbstractComponent;
13
+ export type DeserializeCallback = (data: string)=>AbstractComponent;
15
14
  type ComponentId = string;
16
15
 
17
16
  export default abstract class AbstractComponent {
@@ -38,6 +37,8 @@ export default abstract class AbstractComponent {
38
37
  }
39
38
  }
40
39
 
40
+ // Returns a unique ID for this element.
41
+ // @see { @link EditorImage!default.lookupElement }
41
42
  public getId() {
42
43
  return this.id;
43
44
  }
@@ -77,14 +78,14 @@ export default abstract class AbstractComponent {
77
78
  public abstract intersects(lineSegment: LineSegment2): boolean;
78
79
 
79
80
  // Return null iff this object cannot be safely serialized/deserialized.
80
- protected abstract serializeToString(): string|null;
81
+ protected abstract serializeToJSON(): any[]|Record<string, any>|number|string|null;
81
82
 
82
83
  // Private helper for transformBy: Apply the given transformation to all points of this.
83
84
  protected abstract applyTransformation(affineTransfm: Mat33): void;
84
85
 
85
86
  // Returns a command that, when applied, transforms this by [affineTransfm] and
86
87
  // updates the editor.
87
- public transformBy(affineTransfm: Mat33): Command {
88
+ public transformBy(affineTransfm: Mat33): SerializableCommand {
88
89
  return new AbstractComponent.TransformElementCommand(affineTransfm, this);
89
90
  }
90
91
 
@@ -133,8 +134,7 @@ export default abstract class AbstractComponent {
133
134
  }
134
135
 
135
136
  static {
136
- SerializableCommand.register('transform-element', (data: string, editor: Editor) => {
137
- const json = JSON.parse(data);
137
+ SerializableCommand.register('transform-element', (json: any, editor: Editor) => {
138
138
  const elem = editor.image.lookupElement(json.id);
139
139
 
140
140
  if (!elem) {
@@ -154,11 +154,11 @@ export default abstract class AbstractComponent {
154
154
  });
155
155
  }
156
156
 
157
- protected serializeToString(): string {
158
- return JSON.stringify({
157
+ protected serializeToJSON() {
158
+ return {
159
159
  id: this.component.getId(),
160
160
  transfm: this.affineTransfm.toArray(),
161
- });
161
+ };
162
162
  }
163
163
  };
164
164
 
@@ -178,26 +178,33 @@ export default abstract class AbstractComponent {
178
178
  return clone;
179
179
  }
180
180
 
181
+ // Convert the component to an object that can be passed to
182
+ // `JSON.stringify`.
183
+ //
184
+ // Do not rely on the output of this function to take a particular form —
185
+ // this function's output can change form without a major version increase.
181
186
  public serialize() {
182
- const data = this.serializeToString();
187
+ const data = this.serializeToJSON();
183
188
 
184
189
  if (data === null) {
185
190
  throw new Error(`${this} cannot be serialized.`);
186
191
  }
187
192
 
188
- return JSON.stringify({
193
+ return {
189
194
  name: this.componentKind,
190
195
  zIndex: this.zIndex,
191
196
  id: this.id,
192
197
  loadSaveData: this.loadSaveData,
193
198
  data,
194
- });
199
+ };
195
200
  }
196
201
 
197
- // Returns true if [data] is not deserializable. May return false even if [data]
202
+ // Returns true if `data` is not deserializable. May return false even if [data]
198
203
  // is not deserializable.
199
- private static isNotDeserializable(data: string) {
200
- const json = JSON.parse(data);
204
+ private static isNotDeserializable(json: any|string) {
205
+ if (typeof json === 'string') {
206
+ json = JSON.parse(json);
207
+ }
201
208
 
202
209
  if (typeof json !== 'object') {
203
210
  return true;
@@ -214,12 +221,16 @@ export default abstract class AbstractComponent {
214
221
  return false;
215
222
  }
216
223
 
217
- public static deserialize(data: string): AbstractComponent {
218
- if (AbstractComponent.isNotDeserializable(data)) {
219
- throw new Error(`Element with data ${data} cannot be deserialized.`);
224
+ // Convert a string or an object produced by `JSON.parse` into an `AbstractComponent`.
225
+ public static deserialize(json: string|any): AbstractComponent {
226
+ if (typeof json === 'string') {
227
+ json = JSON.parse(json);
228
+ }
229
+
230
+ if (AbstractComponent.isNotDeserializable(json)) {
231
+ throw new Error(`Element with data ${json} cannot be deserialized.`);
220
232
  }
221
233
 
222
- const json = JSON.parse(data);
223
234
  const instance = this.deserializationCallbacks[json.name]!(json.data);
224
235
  instance.zIndex = json.zIndex;
225
236
  instance.id = json.id;
@@ -1,3 +1,10 @@
1
+ //
2
+ // Used by `SVGLoader`s to store unrecognised global attributes
3
+ // (e.g. unrecognised XML namespace declarations).
4
+ // @internal
5
+ // @packageDocumentation
6
+ //
7
+
1
8
  import LineSegment2 from '../math/LineSegment2';
2
9
  import Mat33 from '../math/Mat33';
3
10
  import Rect2 from '../math/Rect2';
@@ -44,7 +51,7 @@ export default class SVGGlobalAttributesObject extends AbstractComponent {
44
51
  return localization.svgObject;
45
52
  }
46
53
 
47
- protected serializeToString(): string | null {
54
+ protected serializeToJSON(): string | null {
48
55
  return JSON.stringify(this.attrs);
49
56
  }
50
57
 
@@ -58,7 +58,7 @@ describe('Stroke', () => {
58
58
  });
59
59
 
60
60
  it('strokes should deserialize from JSON data', () => {
61
- const deserialized = Stroke.deserializeFromString(`[
61
+ const deserialized = Stroke.deserializeFromJSON(`[
62
62
  {
63
63
  "style": { "fill": "#f00" },
64
64
  "path": "m0,0 l10,10z"