diff --git a/.github/ISSUE_TEMPLATE/translation-js-draw-de.yml b/.github/ISSUE_TEMPLATE/translation-js-draw-de.yml
index 391622970..e015a1026 100644
--- a/.github/ISSUE_TEMPLATE/translation-js-draw-de.yml
+++ b/.github/ISSUE_TEMPLATE/translation-js-draw-de.yml
@@ -305,6 +305,7 @@ body:
label: 'deleteSelection'
description: 'Translate `Delete selection`.'
placeholder: 'Delete selection'
+ value: 'Auswahl löschen'
render: 'shell'
validations:
required: false
@@ -314,6 +315,7 @@ body:
label: 'duplicateSelection'
description: 'Translate `Duplicate selection`.'
placeholder: 'Duplicate selection'
+ value: 'Auswahl duplizieren'
render: 'shell'
validations:
required: false
diff --git a/.github/ISSUE_TEMPLATE/translation-js-draw-es.yml b/.github/ISSUE_TEMPLATE/translation-js-draw-es.yml
index c08560b79..67c61bb5d 100644
--- a/.github/ISSUE_TEMPLATE/translation-js-draw-es.yml
+++ b/.github/ISSUE_TEMPLATE/translation-js-draw-es.yml
@@ -302,6 +302,7 @@ body:
label: 'deleteSelection'
description: 'Translate `Delete selection`.'
placeholder: 'Delete selection'
+ value: 'Borra la selección'
render: 'shell'
validations:
required: false
@@ -311,6 +312,7 @@ body:
label: 'duplicateSelection'
description: 'Translate `Duplicate selection`.'
placeholder: 'Duplicate selection'
+ value: 'Duplica la selección'
render: 'shell'
validations:
required: false
diff --git a/docs/demo/index.html b/docs/demo/index.html
index 9fc175a18..5900c189d 100644
--- a/docs/demo/index.html
+++ b/docs/demo/index.html
@@ -18,9 +18,7 @@
diff --git a/docs/doc-pages/inline-examples/adding-a-stroke.md b/docs/doc-pages/inline-examples/adding-a-stroke.md
index 3838a55bd..bbcd61693 100644
--- a/docs/doc-pages/inline-examples/adding-a-stroke.md
+++ b/docs/doc-pages/inline-examples/adding-a-stroke.md
@@ -1,13 +1,13 @@
```ts,runnable
import {
- Editor, EditorImage, Stroke, pathToRenderable.
- Path, Color4,
+ Editor, EditorImage, Stroke, Path, Color4,
} from 'js-draw';
const editor = new Editor(document.body);
-const stroke = new Stroke([
- pathToRenderable(Path.fromString('m0,0 l100,100 l0,-10 z'), { fill: Color4.red }),
-]);
-editor.dispatch(EditorImage.addElement(stroke));
+const stroke = Stroke.fromFilled(
+ Path.fromString('m0,0 l100,100 l0,-10 z'),
+ Color4.red,
+);
+editor.dispatch(EditorImage.addComponent(stroke));
```
diff --git a/docs/doc-pages/inline-examples/adding-an-image-and-data-urls.md b/docs/doc-pages/inline-examples/adding-an-image-and-data-urls.md
index 5e93ff613..a0815cbbe 100644
--- a/docs/doc-pages/inline-examples/adding-an-image-and-data-urls.md
+++ b/docs/doc-pages/inline-examples/adding-an-image-and-data-urls.md
@@ -14,7 +14,7 @@ const scaledByFactorOf100 = Mat33.scaling2D(100);
const transform = rotated45Degrees.rightMul(scaledByFactorOf100);
const imageComponent = await ImageComponent.fromImage(myHtmlImage, transform);
-await editor.dispatch(editor.image.addElement(imageComponent));
+await editor.dispatch(editor.image.addComponent(imageComponent));
//
// Make a new image from the editor itself (with editor.toDataURL)
diff --git a/docs/doc-pages/inline-examples/image-add-and-lookup.md b/docs/doc-pages/inline-examples/image-add-and-lookup.md
index 1f01f53f0..18d1f7804 100644
--- a/docs/doc-pages/inline-examples/image-add-and-lookup.md
+++ b/docs/doc-pages/inline-examples/image-add-and-lookup.md
@@ -15,7 +15,7 @@ function addStroke(path: Path, style: RenderingStyle) {
// Create a command that adds the stroke to the image
// (but don't apply it yet).
- const command = editor.image.addElement(stroke);
+ const command = editor.image.addComponent(stroke);
// Actually apply the command.
editor.dispatch(command);
}
@@ -50,7 +50,7 @@ addBoxAt(Vec2.of(20, 0), Color4.orange);
addBoxAt(Vec2.of(20, 20), Color4.blue);
// Get the components in a small rectangle near (0, 0)
-const components = editor.image.getElementsIntersectingRegion(
+const components = editor.image.getComponentsIntersecting(
new Rect2(0, 0, 5, 5), // a 5x5 square with top left (0, 0)
);
diff --git a/docs/doc-pages/pages/guides.md b/docs/doc-pages/pages/guides.md
index 634d84c5d..e8bdf9875 100644
--- a/docs/doc-pages/pages/guides.md
+++ b/docs/doc-pages/pages/guides.md
@@ -4,8 +4,9 @@ category: Guides
children:
- ./guides/setup.md
- ./guides/writing-a-theme.md
- - ./guides/customizing-tools.md
+ - ./guides/components.md
- ./guides/updating-the-view.md
+ - ./guides/customizing-tools.md
- ./guides/positioning-an-element-above-the-editor.md
---
diff --git a/docs/doc-pages/pages/guides/components.md b/docs/doc-pages/pages/guides/components.md
new file mode 100644
index 000000000..a9d6d6abc
--- /dev/null
+++ b/docs/doc-pages/pages/guides/components.md
@@ -0,0 +1,14 @@
+---
+title: Components
+category: Guides
+children:
+ - ./components/strokes.md
+ - ./components/custom-components.md
+---
+
+# Image components
+
+The guides in this section show:
+
+- How to change the content of an open editor (erase/duplicate/add strokes).
+- How to create custom components.
diff --git a/docs/doc-pages/pages/guides/components/custom-components.md b/docs/doc-pages/pages/guides/components/custom-components.md
new file mode 100644
index 000000000..3d8aed74e
--- /dev/null
+++ b/docs/doc-pages/pages/guides/components/custom-components.md
@@ -0,0 +1,759 @@
+---
+title: Custom components
+---
+
+# Custom components
+
+It's possible to create custom subclasses of {@link js-draw!AbstractComponent | AbstractComponent}. You might do this if, for example, none of the built-in components are sufficient to implement a particular feature.
+
+There are several steps to creating a custom component:
+
+1. Subclass `AbstractComponent`.
+2. Implement the `abstract` methods.
+ - `description`, `createClone`, `applyTransformation`, `intersects`, and `render`.
+3. (Optional) Make it possible to load/save the `AbstractComponent` to SVG.
+4. (Optional) Make it possible to serialize/deserialize the component.
+ - Component serialization and deserialization is used for collaborative editing.
+
+This guide shows how to create a simple custom component.
+
+## Setup
+
+```ts,runnable
+import { Editor } from 'js-draw';
+const editor = new Editor(document.body);
+editor.addToolbar();
+```
+
+## 1. Subclass `AbstractComponent`
+
+We'll start by subclassing `AbstractComponent`. There are a few methods we'll implement:
+
+```ts
+const componentId = 'example';
+class ImageStatusComponent extends AbstractComponent {
+ // The bounding box of the component -- REQUIRED
+ // The bounding box should be a rectangle that completely contains the
+ // component's content.
+ protected contentBBox: Rect2;
+
+ public constructor() {
+ super(componentId);
+ this.contentBBox = // TODO
+ }
+
+ public override render(canvas: AbstractRenderer): void {
+ // TODO: Render the component
+ }
+
+ public intersects(line: LineSegment2): boolean {
+ // TODO: Return true if the component intersects a line
+ }
+
+ protected applyTransformation(transformation: Mat33): void {
+ // TODO: Move/scale/rotate the component based on `transformation`
+ }
+
+ protected createClone(): AbstractComponent {
+ // TODO: Return a copy of the component.
+ }
+
+ public description(): string {
+ // TODO: Describe the component for accessibility tools
+ }
+
+ protected serializeToJSON(): string {
+ // TODO: Return a JSON string that represents the component state.
+ // Only used for collaborative editing.
+ }
+}
+```
+
+Above,
+
+- `componentId` is a unique identifier for this type of component. It's main use is to support collaborative editing.
+- `render` will draw the component to the screen.
+
+The most important of the above methods is arguably `render`.
+
+## 2. Making it render
+
+Let's get started by making the component render something.
+
+```ts,runnable
+---use-previous---
+---visible---
+import { LineSegment2, Mat33, Rect2, Color4 } from '@js-draw/math';
+import { AbstractRenderer, AbstractComponent } from 'js-draw';
+
+const componentId = 'example';
+class ExampleComponent extends AbstractComponent {
+ protected contentBBox: Rect2;
+
+ public constructor() {
+ super(componentId);
+
+ // For now, create a 50x50 bounding box centered at (0,0).
+ // We'll update this later:
+ this.contentBBox = new Rect2(0, 0, 50, 50);
+ }
+
+ public override render(canvas: AbstractRenderer, _visibleRect?: Rect2): void {
+ // Be sure that everything drawn between .startObject and .endObject is within contentBBox.
+ // Content outside contentBBox may not be drawn in all cases.
+ canvas.startObject(this.contentBBox);
+
+ // _visibleRect is the part of the image that's currently visible. We can
+ // ignore it for now.
+
+ canvas.fillRect(this.contentBBox, Color4.red);
+
+ // Ends the object and attaches any additional metadata attached by an image loader
+ // (e.g. if this object was created by SVGLoader).
+ canvas.endObject(this.getLoadSaveData());
+ }
+
+ // Must be implemented by all components, used for things like erasing and selection.
+ public intersects(line: LineSegment2) {
+ // TODO: For now, our component won't work correctly if the user tries to select it.
+ // We'll implement this later.
+ return false;
+ }
+
+ protected applyTransformation(transformation: Mat33): void {
+ // TODO: Eventually, this should move the component. We'll implement this later.
+ }
+
+ protected createClone(): AbstractComponent {
+ return new ExampleComponent();
+ }
+
+ public description(): string {
+ // This should be a brief description of the component's content (for
+ // accessibility tools)
+ return 'a red box';
+ }
+
+ protected serializeToJSON() {
+ return JSON.stringify({
+ // Some data to save (for collaborative editing)
+ });
+ }
+}
+
+// data: The data serialized by serlailzeToJSON
+AbstractComponent.registerComponent(componentId, data => {
+ // TODO: Deserialize the component from [data]. This is used if collaborative
+ // editing is enabled.
+ return new ExampleComponent();
+});
+
+// Add the component
+editor.dispatch(editor.image.addComponent(new ExampleComponent()));
+```
+
+Try clicking "run"! Notice that we now have a red box. Since `applyTransformation` and `intersects` aren't implemented, it doesn't work with the selection or eraser tools. We'll implement those methods next.
+
+## 3. Making it selectable
+
+To make it possible for a user to move and resize our `ExampleComponent`, we'll need a bit more state. In particular, we'll add:
+
+- A {@link @js-draw/math!Mat33 | Mat33} that stores the position/rotation of the component.
+ - See the {@link @js-draw/math!Mat33 | Mat33} documentation for more information.
+- Logic to update `contentBBox` when the component is changed. As a performance optimization, `js-draw` avoids drawing components that are completely offscreen. `js-draw` determines whether a component is onscreen using `contentBBox`.
+
+```ts,runnable
+import { Editor } from 'js-draw';
+const editor = new Editor(document.body);
+editor.addToolbar();
+---visible---
+import { LineSegment2, Mat33, Rect2, Color4 } from '@js-draw/math';
+import { AbstractRenderer, AbstractComponent } from 'js-draw';
+
+const componentId = 'example';
+class ExampleComponent extends AbstractComponent {
+ protected contentBBox: Rect2;
+
+ // NEW: Stores the scale/rotation/position. "Transform" is short for "transformation".
+ private transform: Mat33;
+ // NEW: Stores the untransformed bounding box of the component. If the component hasn't
+ // been moved/scaled yet, initialBBox should completely contain the component's content.
+ private initialBBox: Rect2;
+
+ public constructor(transform: Mat33) {
+ super(componentId);
+
+ this.transform = transform;
+ this.initialBBox = new Rect2(0, 0, 50, 50);
+ this.updateBoundingBox();
+ }
+
+ // NEW: Updates this.contentBBox. Should be called whenever this.transform changes.
+ private updateBoundingBox() {
+ this.contentBBox = this.initialBBox.transformedBoundingBox(
+ this.transform,
+ );
+ }
+
+ public override render(canvas: AbstractRenderer, _visibleRect?: Rect2): void {
+ canvas.startObject(this.contentBBox);
+
+ // Everything between .pushTransform and .popTransform will be scaled/rotated by this.transform.
+ // Try removing the .pushTransform and .popTransform lines!
+ canvas.pushTransform(this.transform);
+ canvas.fillRect(this.initialBBox, Color4.red);
+ canvas.popTransform();
+
+ // After the call to .popTransform, this.transform is no longer transforming the canvas.
+ // Try uncommenting the following line:
+ // canvas.fillRect(this.initialBBox, Color4.orange);
+ // What happens when the custom component is selected and moved?
+ // What happens to the orange rectangle when the red rectangle is moved offscreen?
+
+ canvas.endObject(this.getLoadSaveData());
+ }
+
+ public intersects(line: LineSegment2) {
+ // Our component is currently just a rectangle. As such (for some values of this.transform),
+ // we can use the Rect2.intersectsLineSegment method here:
+ const intersectionCount = this.contentBBox.intersectsLineSegment(line).length;
+ return intersectionCount > 0; // What happpens if you always return `true` here?
+ }
+
+ protected applyTransformation(transformUpdate: Mat33): void {
+ // `.rightMul`, "right matrix multiplication" combines two transformations.
+ // The transformation on the left is applied **after** the transformation on the right.
+ // As such, `transformUpdate.rightMul(this.transform)` means that `this.transform`
+ // will be applied **before** the `transformUpdate`.
+ this.transform = transformUpdate.rightMul(this.transform);
+ this.updateBoundingBox();
+ }
+
+ protected createClone(): AbstractComponent {
+ const clone = new ExampleComponent(this.transform);
+ return clone;
+ }
+
+ public description(): string {
+ return 'a red box';
+ }
+
+ protected serializeToJSON() {
+ return JSON.stringify({
+ // TODO: Some data to save (for collaborative editing)
+ });
+ }
+}
+
+// data: The data serialized by serlailzeToJSON
+AbstractComponent.registerComponent(componentId, data => {
+ // TODO: Deserialize the component from [data]. This is used if collaborative
+ // editing is enabled.
+ return new ExampleComponent(Mat33.identity);
+});
+
+// Add the component
+const initialTransform = Mat33.identity;
+editor.dispatch(editor.image.addComponent(new ExampleComponent(initialTransform)));
+```
+
+> [!NOTE]
+>
+> Above, `intersects` is implemented using `this.contentBBox.intersectsLineSegment`. This is incorrect if the component has been rotated. In this case, the bounding box is **not** the same as the rectangle that's drawn onscreen:
+>
+>
+>
+> A rotated red box ("what's drawn onscreen") is inside a white box labeled "bounding box"
+>
+>
+> A more correct implementation might be:
+>
+> ```ts
+> line = line.transformedBy(this.transform.inverse());
+> return this.initialBBox.intersectsLineSegment(line).length > 0;
+> ```
+>
+> This "untransforms" the line so that `initialBBox` represents our object, relative to the updated line.
+
+## 4. Loading and saving
+
+Currently, copy-pasting the `ExampleComponent` pastes a `StrokeComponent`. Let's fix that.
+
+`js-draw` copies components as SVG. As a result, to paste our components correctly, we need to add logic to load from SVG. This can be done by creating a {@link js-draw!SVGLoaderPlugin | SVGLoaderPlugin} and including it in the {@link js-draw!EditorSettings | EditorSettings} for a new editor.
+
+A `SVGLoaderPlugin` should contain a single `visit` method that will be called with each node in the image. A simple such plugin might look like this
+
+```ts,runnable
+import { Editor, SVGLoaderPlugin, Stroke } from 'js-draw';
+import { Color4 } from '@js-draw/math';
+
+let nextX = 0;
+const testPlugin: SVGLoaderPlugin = {
+ async visit(node, loader) {
+ if (node.tagName.toLowerCase() === 'text') {
+ const testComponent = Stroke.fromFilled(
+ `m${nextX},0 l50,0 l0,50 z`, Color4.red,
+ );
+ nextX += 100;
+ loader.addComponent(testComponent);
+ return true;
+ }
+ // Return false to do the default image loading
+ return false;
+ }
+};
+
+const editor = new Editor(document.body, {
+ svg: {
+ loaderPlugins: [ testPlugin ],
+ }
+});
+editor.addToolbar();
+
+// With the loader plugin, text objects are converted to red triangles.
+editor.loadFromSVG(`
+
+`);
+```
+
+The above example loads the `text` objects as triangles.
+
+Let's create a version that loads our custom component:
+
+```ts
+const plugin: SVGLoaderPlugin = {
+ async visit(node, loader) {
+ if (node.classList.contains('comp--example-component')) {
+ const transform = // TODO: Get transform from the `node`.
+ const customComponent = new ExampleComponent(transform);
+ loader.addComponent(customComponent);
+ return true;
+ }
+ // Return false to do the default image loading
+ return false;
+ },
+};
+```
+
+...and update our custom component to attach the correct information while saving:
+
+```ts
+class ExampleComponent extends AbstractComponent {
+ // ...
+
+ public override render(canvas: AbstractRenderer, _visibleRect?: Rect2): void {
+ canvas.startObject(this.contentBBox);
+
+ canvas.pushTransform(this.transform);
+ canvas.fillRect(this.initialBBox, Color4.red);
+ canvas.popTransform();
+
+ // The containerClassNames argument, when rendering to an SVG, wraps our component
+ // in a with the provided class names.
+ const containerClassNames = ['comp--example-component'];
+ canvas.endObject(this.getLoadSaveData(), containerClassNames);
+ }
+
+ // ...
+}
+```
+
+All together,
+
+```ts,runnable
+import { Editor } from 'js-draw';
+import { LineSegment2, Mat33, Rect2, Color4 } from '@js-draw/math';
+import { AbstractRenderer, AbstractComponent } from 'js-draw';
+
+const componentId = 'example';
+class ExampleComponent extends AbstractComponent {
+ protected contentBBox: Rect2;
+
+ private transform: Mat33;
+ private initialBBox: Rect2;
+
+ public constructor(transform: Mat33) {
+ super(componentId);
+
+ this.transform = transform;
+ this.initialBBox = new Rect2(0, 0, 50, 50);
+ this.updateBoundingBox();
+ }
+
+ private updateBoundingBox() {
+ this.contentBBox = this.initialBBox.transformedBoundingBox(
+ this.transform,
+ );
+ }
+
+ public override render(canvas: AbstractRenderer, _visibleRect?: Rect2): void {
+ canvas.startObject(this.contentBBox);
+
+ canvas.pushTransform(this.transform);
+ canvas.fillRect(this.initialBBox, Color4.red);
+ canvas.popTransform();
+
+ const containerClassNames = ['comp--example-component'];
+ canvas.endObject(this.getLoadSaveData(), containerClassNames);
+ }
+
+ public intersects(line: LineSegment2) {
+ // Our component is currently just a rectangle. As such (for some values of this.transform),
+ // we can use the Rect2.intersectsLineSegment method here:
+ const intersectionCount = this.contentBBox.intersectsLineSegment(line).length;
+ return intersectionCount > 0; // What happpens if you always return `true` here?
+ }
+
+ protected applyTransformation(transformUpdate: Mat33): void {
+ // `.rightMul`, "right matrix multiplication" combines two transformations.
+ // The transformation on the left is applied **after** the transformation on the right.
+ // As such, `transformUpdate.rightMul(this.transform)` means that `this.transform`
+ // will be applied **before** the `transformUpdate`.
+ this.transform = transformUpdate.rightMul(this.transform);
+ this.updateBoundingBox();
+ }
+
+ protected createClone(): AbstractComponent {
+ const clone = new ExampleComponent(this.transform);
+ return clone;
+ }
+
+ public description(): string {
+ return 'a red box';
+ }
+
+ protected serializeToJSON() {
+ return JSON.stringify({
+ // TODO: Some data to save (for collaborative editing)
+ });
+ }
+}
+
+AbstractComponent.registerComponent(componentId, data => {
+ // TODO:
+ return new ExampleComponent(Mat33.identity);
+});
+
+const plugin: SVGLoaderPlugin = {
+ async visit(node, loader) {
+ if (node.classList.contains('comp--example-component')) {
+ // TODO: Set the transformation matrix correctly -- get this information
+ // from the `node`. This isn't too important for copy/paste support.
+ const customComponent = new ExampleComponent(Mat33.identity);
+ loader.addComponent(customComponent);
+ return true;
+ }
+ // Return false to do the default image loading
+ return false;
+ },
+};
+
+const editor = new Editor(document.body, {
+ svg: {
+ loaderPlugins: [ plugin ],
+ },
+});
+editor.addToolbar();
+
+// Add the component
+const initialTransform = Mat33.identity;
+editor.dispatch(editor.image.addComponent(new ExampleComponent(initialTransform)));
+```
+
+## 5. Make it possible to serialize/deserialize for collaborative editing
+
+> [!NOTE]
+>
+> If you find collaborative editing bugs, please [report them](https://github.com/personalizedrefrigerator/js-draw/issues).
+
+Let's start by setting up two editors that sync commands:
+
+```ts,runnable
+import { Editor, invertCommand, SerializableCommand, EditorEventType } from 'js-draw';
+
+const editor1 = new Editor(document.body);
+// Store the toolbar in a variable -- we'll use it later
+const toolbar = editor1.addToolbar();
+
+const editor2 = new Editor(document.body);
+
+const applySerializedCommand = (serializedCommand: any, editor: Editor) => {
+ const command = SerializableCommand.deserialize(serializedCommand, editor);
+ command.apply(editor);
+};
+
+const applyCommandsToOtherEditor = (sourceEditor: Editor, otherEditor: Editor) => {
+ sourceEditor.notifier.on(EditorEventType.CommandDone, (evt) => {
+ // Type assertion.
+ if (evt.kind !== EditorEventType.CommandDone) {
+ throw new Error('Incorrect event type');
+ }
+
+ if (evt.command instanceof SerializableCommand) {
+ const serializedCommand = evt.command.serialize();
+ applySerializedCommand(serializedCommand, otherEditor);
+ } else {
+ console.log('Nonserializable command');
+ }
+ });
+ sourceEditor.notifier.on(EditorEventType.CommandUndone, (evt) => {
+ // Type assertion.
+ if (evt.kind !== EditorEventType.CommandUndone) {
+ throw new Error('Incorrect event type');
+ }
+
+ if (evt.command instanceof SerializableCommand) {
+ const serializedCommand = invertCommand(evt.command).serialize();
+ applySerializedCommand(serializedCommand, otherEditor);
+ } else {
+ console.log('Nonserializable command');
+ }
+ });
+};
+
+applyCommandsToOtherEditor(editor1, editor2);
+applyCommandsToOtherEditor(editor2, editor1);
+```
+
+Next, we'll take our component from before, except implement `serializeToJSON` and the deserialize callback in `registerComponent`:
+
+```ts,runnable
+---use-previous---
+import { Editor } from 'js-draw';
+import { LineSegment2, Mat33, Rect2, Color4 } from '@js-draw/math';
+import { AbstractRenderer, AbstractComponent } from 'js-draw';
+
+const componentId = 'example';
+class ExampleComponent extends AbstractComponent {
+ protected contentBBox: Rect2;
+
+ private transform: Mat33;
+ private initialBBox: Rect2;
+
+ public constructor(transform: Mat33) {
+ super(componentId);
+
+ this.transform = transform;
+ this.initialBBox = new Rect2(0, 0, 50, 50);
+ this.updateBoundingBox();
+ }
+
+ private updateBoundingBox() {
+ this.contentBBox = this.initialBBox.transformedBoundingBox(
+ this.transform,
+ );
+ }
+
+ public override render(canvas: AbstractRenderer, _visibleRect?: Rect2): void {
+ canvas.startObject(this.contentBBox);
+
+ canvas.pushTransform(this.transform);
+ canvas.fillRect(this.initialBBox, Color4.red);
+ canvas.popTransform();
+
+ const containerClassNames = ['comp--example-component'];
+ canvas.endObject(this.getLoadSaveData(), containerClassNames);
+ }
+
+ public intersects(line: LineSegment2) {
+ // Our component is currently just a rectangle. As such (for some values of this.transform),
+ // we can use the Rect2.intersectsLineSegment method here:
+ const intersectionCount = this.contentBBox.intersectsLineSegment(line).length;
+ return intersectionCount > 0; // What happpens if you always return `true` here?
+ }
+
+ protected applyTransformation(transformUpdate: Mat33): void {
+ // `.rightMul`, "right matrix multiplication" combines two transformations.
+ // The transformation on the left is applied **after** the transformation on the right.
+ // As such, `transformUpdate.rightMul(this.transform)` means that `this.transform`
+ // will be applied **before** the `transformUpdate`.
+ this.transform = transformUpdate.rightMul(this.transform);
+ this.updateBoundingBox();
+ }
+
+ protected createClone(): AbstractComponent {
+ const clone = new ExampleComponent(this.transform);
+ return clone;
+ }
+
+ public description(): string {
+ return 'a red box';
+ }
+
+---visible---
+ // ...other component logic...
+
+ protected serializeToJSON() {
+ return JSON.stringify({
+ // NEW: Save the transform matrix:
+ transform: this.transform.toArray(),
+ });
+ }
+}
+
+AbstractComponent.registerComponent(componentId, data => {
+ const transformArray = JSON.parse(data).transform;
+
+ // NEW: Validation
+ if (!Array.isArray(transformArray)) {
+ throw new Error('data.transform must be an array');
+ }
+ for (const entry of transformArray) {
+ if (!isFinite(entry)) {
+ throw new Error(`Non-finite entry in transform: ${entry}`);
+ }
+ }
+
+ // NEW: Create and return the component from the data
+ const transform = new Mat33(...transformArray);
+ return new ExampleComponent(transform);
+});
+
+// Make a button that adds the component
+function makeAddIcon() {
+ const container = document.createElement('div');
+ container.textContent = '+';
+ return container;
+}
+
+toolbar.addActionButton({
+ icon: makeAddIcon(),
+ label: 'Add test component',
+}, () => {
+ const initialTransform = Mat33.identity;
+ const component = new ExampleComponent(initialTransform);
+
+ // The addAndCenterComponents method automatically selects,
+ // centers, and adds the provided components to the editor.
+ //
+ // We could also add the component using
+ // editor.dispatch(editor.image.addComponent(component));
+ editor1.addAndCenterComponents([
+ component
+ ]);
+});
+```
+
+Above, clicking the "+" button should add the component to both editors.
+
+## More advanced rendering
+
+If you find that the {@link js-draw!AbstractRenderer | AbstractRenderer}'s built-in methods are insufficient, it's possible to directly access the [`RenderingContext2D`](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D) or [`SVGElement`](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement) used by the renderer. See {@link js-draw!CanvasRenderer.drawWithRawRenderingContext | drawWithRawRenderingContext} and {@link js-draw!SVGRenderer.drawWithSVGParent | drawWithSVGParent} for details.
+
+> [!NOTE]
+>
+> Where possible, try to use the `AbstractRenderer`-provided methods. Doing so can help keep your logic compatible with future renderer types.
+
+Let's see an example that has SVG-specific and Canvas-specific rendering logic:
+
+```ts,runnable
+import { Editor, CanvasRenderer, SVGRenderer } from 'js-draw';
+import { LineSegment2, Mat33, Rect2, Color4 } from '@js-draw/math';
+import { AbstractRenderer, AbstractComponent } from 'js-draw';
+
+const componentId = 'example';
+class ExampleComponent extends AbstractComponent {
+ protected contentBBox: Rect2;
+
+ private transform: Mat33;
+ private initialBBox: Rect2;
+
+ public constructor(transform: Mat33) {
+ super(componentId);
+
+ this.transform = transform;
+ this.initialBBox = new Rect2(0, 0, 50, 50);
+ this.updateBoundingBox();
+ }
+
+ private updateBoundingBox() {
+ this.contentBBox = this.initialBBox.transformedBoundingBox(
+ this.transform,
+ );
+ }
+
+ public override render(canvas: AbstractRenderer, _visibleRect?: Rect2): void {
+ canvas.startObject(this.contentBBox);
+
+ canvas.pushTransform(this.transform);
+ if (canvas instanceof CanvasRenderer) {
+ canvas.drawWithRawRenderingContext(ctx => {
+ ctx.strokeStyle = 'green';
+
+ // Draw a large number of rectangles
+ const rectSize = 20;
+ const maximumX = this.initialBBox.width - rectSize;
+ for (let x = 0; x < maximumX; x += 2) {
+ ctx.strokeRect(x, 0, rectSize, rectSize);
+ }
+ });
+ } else if (canvas instanceof SVGRenderer) {
+ canvas.drawWithSVGParent(parent => {
+ // Draw some text. Note that this can also
+ // be done with canvas.drawText
+ const text = document.createElementNS(
+ 'http://www.w3.org/2000/svg', 'text',
+ );
+
+ text.textContent = 'Test!';
+ text.setAttribute('x', '50');
+ text.setAttribute('y', '25');
+ text.style.fill = 'red';
+
+ parent.appendChild(text);
+ });
+ } else {
+ // Fallback for other renderers
+ canvas.fillRect(this.initialBBox, Color4.red);
+ }
+ canvas.popTransform();
+
+ const containerClassNames = ['comp--example-component'];
+ canvas.endObject(this.getLoadSaveData(), containerClassNames);
+ }
+
+ public intersects(line: LineSegment2) {
+ return false; // TODO (see above sections for implementation)
+ }
+
+ protected applyTransformation(transformUpdate: Mat33): void {
+ this.transform = transformUpdate.rightMul(this.transform);
+ this.updateBoundingBox();
+ }
+
+ protected createClone(): AbstractComponent {
+ const clone = new ExampleComponent(this.transform);
+ return clone;
+ }
+
+ public description(): string {
+ return 'a red box'; // TODO (see examples above)
+ }
+
+ protected serializeToJSON() {
+ return JSON.stringify({
+ // TODO: Some data to save (for collaborative editing)
+ });
+ }
+}
+
+AbstractComponent.registerComponent(componentId, data => {
+ // TODO: See above examples for how to implement this
+ // Needed for collaborative editing
+ throw new Error('Not implemented');
+});
+
+const editor = new Editor(document.body);
+editor.addToolbar();
+
+// Add the component
+const initialTransform = Mat33.identity;
+editor.dispatch(editor.image.addComponent(new ExampleComponent(initialTransform)));
+
+// Preview the SVG output
+document.body.appendChild(editor.toSVG());
+```
diff --git a/docs/doc-pages/pages/guides/components/strokes.md b/docs/doc-pages/pages/guides/components/strokes.md
new file mode 100644
index 000000000..a2c84f64d
--- /dev/null
+++ b/docs/doc-pages/pages/guides/components/strokes.md
@@ -0,0 +1,179 @@
+---
+title: Adding and modifying components
+---
+
+# Adding and modifying components
+
+Images in `js-draw` are made up of images, text, strokes, and other components. Each is represented by a subclass of {@link js-draw!AbstractComponent | AbstractComponent}.
+
+## Adding a stroke to the editor
+
+Let's see how to add a stroke to an editor. Here's a few of the APIs we'll use:
+
+- {@link js-draw!Editor.image | editor.image}: Data structure that stores information about the image currently shown in the editor.
+- {@link js-draw!Stroke | Stroke}: A component that renders as a stroke.
+- {@link js-draw!Editor.dispatch | editor.dispatch}: Applies commands in a way that can be undone/redone.
+
+```ts,runnable
+import {
+ Editor, EditorImage, Stroke, Path, Color4,
+} from 'js-draw';
+
+// 1.
+const editor = new Editor(document.body);
+editor.addToolbar();
+
+// Create path data that we'll use to make the stroke.
+const path = Path.fromString('m0,0 l0,40 l40,4 l0,-48 z');
+
+// 2.
+const stroke = Stroke.fromFilled(
+ path, Color4.red,
+);
+
+// 3.
+const command = editor.image.addComponent(stroke);
+
+// 4.
+editor.dispatch(command);
+```
+
+Above:
+
+1. A new `Editor` is created and added to the document.
+2. A stroke with a red fill is created. The shape of the stroke is determined by `path`. For information about how to specify paths, see [the MDN documentation on ``](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/path) elements.
+3. A {@link js-draw!Command | Command} is created that will add the component to the editor. The command doesn't do anything until `4.`.
+4. The command is applied to the editor.
+ - `editor.dispatch` adds the command to the undo history and announces it for accessibility tools. Try replacing `editor.dispatch(command)` with `command.apply(editor)`. What's the difference?
+
+## Adding a large number of strokes to the editor
+
+Next, we'll create a large number of strokes
+
+```ts,runnable
+import {
+ Editor, EditorImage, Stroke, Path, Color4, uniteCommands,
+} from 'js-draw';
+
+const editor = new Editor(document.body);
+editor.addToolbar();
+
+// 1.
+const commands = [];
+for (let x = 0; x < 100; x += 10) {
+ for (let y = 0; y < 100; y += 10) {
+ // 2. Try changing these!
+ const strokeWidth = 3;
+ const strokeColor = Color4.orange;
+
+ // 3.
+ const stroke = Stroke.fromStroked(
+ // A path that starts at (i,i) then moves three units to the right
+ `m${x},${y} l3,0`,
+ { width: strokeWidth, color: strokeColor },
+ );
+ const command = editor.image.addComponent(stroke);
+ commands.push(command);
+ }
+}
+
+// 4.
+const compoundCommand = uniteCommands(commands);
+editor.dispatch(compoundCommand);
+```
+
+Above:
+
+1. We create a list of commands.
+2. **Stroke style**: Try changing this:
+ - Replace `Color4.orange` with `Color4.ofRGBA(x / 100, 0, y / 100, 1)`.
+ - Replace `strokeWidth = 3` with `strokeWidth = 10`.
+3. The stroke is created from an [SVG-style path](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/path) and a style.
+4. The stroke commands are combined into one command and applied.
+ - Try moving the `editor.dispatch` into the `for` loop and dispatching the commands one-by-one. Does this change what the undo and redo buttons do? See {@link js-draw!uniteCommands | uniteCommands} for more information.
+
+## Finding strokes in an image
+
+Let's start with the previous example and see how to:
+
+1. Get all strokes in part of the editor.
+2. Change the color of the strokes in that region.
+
+```ts,runnable
+---use-previous---
+---visible---
+// This example starts by running the code from the previous example --
+// make sure the previous example compiles!
+import { Rect2 } from 'js-draw';
+
+// 1.
+const components = editor.image.getComponentsIntersecting(
+ new Rect2( // a 2D rectangle
+ 5, // x
+ 6, // y
+ 60, // width
+ 30, // height
+ )
+);
+
+// 2.
+const styleCommands = [];
+for (const component of components) {
+ // Only process Strokes -- there **are** other types of components.
+ if (!(component instanceof Stroke)) continue;
+
+ const command = component.updateStyle({ color: Color4.red });
+ styleCommands.push(command);
+}
+// 3.
+editor.dispatch(uniteCommands(styleCommands));
+```
+
+Above:
+
+1. **Strokes are found**: All components in a rectangle are stored in `components`.
+ - Try changing the bounds of the rectangle!
+2. **Commands are created**: Each command changes the color of a `Stroke`.
+3. **Changes are applied**: The color change is applied to the editor.
+
+Instead of `component.updateStyle`, we could have changed the component in some other way. For example, replacing the `component.updateStyle(...)` with `component.transformBy`,
+
+```ts,runnable
+---use-previous---
+---visible---
+// This example starts by running the code from the previous example --
+// make sure the previous example compiles!
+import { Mat33, Vec2 } from 'js-draw';
+
+const transformCommands = [];
+for (const component of components) {
+ const command = component.transformBy(
+ Mat33.translation(Vec2.of(45, 0))
+ );
+ transformCommands.push(command);
+}
+editor.dispatch(uniteCommands(transformCommands));
+```
+
+See {@link @js-draw/math!Mat33 | Mat33} for more transformation types.
+
+## Erasing strokes
+
+The {@link js-draw!Erase | Erase} command can be used to remove components from the image:
+
+```ts,runnable
+---use-previous---
+---visible---
+// This example starts by running the code from the previous example --
+// make sure the previous example compiles!
+import { Erase } from 'js-draw';
+
+// Deletes all components found in the previous steps
+const eraseCommand = new Erase(components);
+editor.dispatch(eraseCommand);
+```
+
+Things to try:
+
+- Try replacing {@link js-draw!Editor.dispatch | editor.dispatch(eraseCommand)} with `eraseCommand.apply(editor)`. What's the effect on the undo/redo behavior?
+- Try replacing the `Erase` command with a {@link js-draw!Duplicate | Duplicate} command, then repositioning the original strokes.
diff --git a/packages/js-draw/src/Editor.toSVG.test.ts b/packages/js-draw/src/Editor.toSVG.test.ts
index b7df8cf75..13576ec45 100644
--- a/packages/js-draw/src/Editor.toSVG.test.ts
+++ b/packages/js-draw/src/Editor.toSVG.test.ts
@@ -27,9 +27,9 @@ describe('Editor.toSVG', () => {
Mat33.identity,
textStyle,
);
- editor.dispatch(EditorImage.addElement(text));
+ editor.dispatch(EditorImage.addComponent(text));
- const matches = editor.image.getElementsIntersectingRegion(new Rect2(4, -100, 100, 100));
+ const matches = editor.image.getComponentsIntersecting(new Rect2(4, -100, 100, 100));
expect(matches).toHaveLength(1);
expect(text).not.toBeNull();
@@ -169,7 +169,7 @@ describe('Editor.toSVG', () => {
// Both paths should exist.
expect(
editor.image
- .getElementsIntersectingRegion(new Rect2(-10, -10, 100, 100))
+ .getComponentsIntersecting(new Rect2(-10, -10, 100, 100))
.filter((elem) => elem instanceof StrokeComponent),
).toHaveLength(2);
@@ -227,7 +227,7 @@ describe('Editor.toSVG', () => {
// All paths should exist.
expect(
editor.image
- .getElementsIntersectingRegion(new Rect2(-10, -10, 100, 100))
+ .getComponentsIntersecting(new Rect2(-10, -10, 100, 100))
.filter((elem) => elem instanceof StrokeComponent),
).toHaveLength(3);
@@ -293,7 +293,7 @@ describe('Editor.toSVG', () => {
expectGroupParentsToBeOriginal();
const nudgePathNear = async (pos: Vec2) => {
- const targetElems = editor.image.getElementsIntersectingRegion(Rect2.bboxOf([pos], 5));
+ const targetElems = editor.image.getComponentsIntersecting(Rect2.bboxOf([pos], 5));
expect(targetElems).toHaveLength(1);
diff --git a/packages/js-draw/src/Editor.ts b/packages/js-draw/src/Editor.ts
index 84452f0de..79125238a 100644
--- a/packages/js-draw/src/Editor.ts
+++ b/packages/js-draw/src/Editor.ts
@@ -16,7 +16,7 @@ import Viewport from './Viewport';
import EventDispatcher from './EventDispatcher';
import { Point2, Vec2, Vec3, Color4, Mat33, Rect2 } from '@js-draw/math';
import Display, { RenderingMode } from './rendering/Display';
-import SVGLoader from './SVGLoader/SVGLoader';
+import SVGLoader, { SVGLoaderPlugin } from './SVGLoader/SVGLoader';
import Pointer from './Pointer';
import { EditorLocalization } from './localization';
import getLocalizationTable from './localizations/getLocalizationTable';
@@ -175,6 +175,11 @@ export interface EditorSettings {
/** Called to write data to the clipboard. Keys in `data` are MIME types. Values are the data associated with that type. */
write(data: Map | string>): void | Promise;
} | null;
+
+ svg: {
+ /** Plugins that create custom components while loading with {@link Editor.loadFromSVG}. */
+ loaderPlugins?: SVGLoaderPlugin[];
+ } | null;
}
/**
@@ -233,10 +238,10 @@ export class Editor {
* const stroke = new Stroke([
* pathToRenderable(Path.fromString('M0,0 L100,100 L300,30 z'), { fill: Color4.red }),
* ]);
- * const addElementCommand = editor.image.addElement(stroke);
+ * const addComponentCommand = editor.image.addComponent(stroke);
*
* // Add the stroke to the editor
- * editor.dispatch(addElementCommand);
+ * editor.dispatch(addComponentCommand);
* ```
*/
public readonly image: EditorImage;
@@ -369,8 +374,11 @@ export class Editor {
image: {
showImagePicker: settings.image?.showImagePicker ?? undefined,
},
+ svg: {
+ loaderPlugins: settings.svg?.loaderPlugins ?? [],
+ },
clipboardApi: settings.clipboardApi ?? null,
- };
+ } satisfies EditorSettings;
// Validate settings
if (this.settings.minZoom > this.settings.maxZoom) {
@@ -1513,7 +1521,7 @@ export class Editor {
const commands: Command[] = [];
for (const component of components) {
// To allow deserialization, we need to add first, then transform.
- commands.push(EditorImage.addElement(component));
+ commands.push(EditorImage.addComponent(component));
commands.push(component.transformBy(transfm));
}
@@ -1635,7 +1643,7 @@ export class Editor {
await loader.start(
async (component) => {
- await this.dispatchNoAnnounce(EditorImage.addElement(component));
+ await this.dispatchNoAnnounce(EditorImage.addComponent(component));
},
(countProcessed: number, totalToProcess: number) => {
if (countProcessed % 500 === 0) {
@@ -1728,7 +1736,7 @@ export class Editor {
if (backgroundType !== BackgroundType.None) {
const newBackground = new BackgroundComponent(backgroundType, backgroundColor);
- commands.push(EditorImage.addElement(newBackground));
+ commands.push(EditorImage.addComponent(newBackground));
}
if (fillsScreen !== originalFillsScreen) {
commands.push(this.image.setAutoresizeEnabled(fillsScreen));
@@ -1761,7 +1769,7 @@ export class Editor {
? BackgroundType.None
: BackgroundType.SolidColor;
background = new BackgroundComponent(backgroundType, color);
- return this.image.addElement(background);
+ return this.image.addComponent(background);
} else {
return background.updateStyle({ color });
}
@@ -1815,7 +1823,10 @@ export class Editor {
* ```
*/
public async loadFromSVG(svgData: string, sanitize: boolean = false) {
- const loader = SVGLoader.fromString(svgData, sanitize);
+ const loader = SVGLoader.fromString(svgData, {
+ sanitize,
+ plugins: this.getCurrentSettings().svg?.loaderPlugins,
+ });
await this.loadFrom(loader);
}
diff --git a/packages/js-draw/src/SVGLoader/SVGLoader.plugins.test.ts b/packages/js-draw/src/SVGLoader/SVGLoader.plugins.test.ts
new file mode 100644
index 000000000..28c7b659e
--- /dev/null
+++ b/packages/js-draw/src/SVGLoader/SVGLoader.plugins.test.ts
@@ -0,0 +1,41 @@
+import { Color4 } from '@js-draw/math';
+import { Stroke } from '../lib';
+import SVGLoader, { SVGLoaderPlugin } from './SVGLoader';
+
+describe('SVGLoader.plugins', () => {
+ test('should support custom plugin callbacks', async () => {
+ let visitCount = 0;
+ let skipCount = 0;
+ const plugin: SVGLoaderPlugin = {
+ async visit(node, control) {
+ if (node.hasAttribute('data-test')) {
+ control.addComponent(Stroke.fromFilled('m0,0 l10,10 l-10,0 z', Color4.red));
+ visitCount++;
+ return true;
+ } else {
+ skipCount++;
+ }
+ return false;
+ },
+ };
+
+ const loader = SVGLoader.fromString(
+ `
+
+ `,
+ {
+ plugins: [plugin],
+ },
+ );
+ const onAddListener = jest.fn();
+ const onProgressListener = jest.fn();
+ await loader.start(onAddListener, onProgressListener);
+
+ expect(visitCount).toBe(2);
+ expect(skipCount).toBeGreaterThanOrEqual(1);
+ });
+});
diff --git a/packages/js-draw/src/SVGLoader/SVGLoader.test.ts b/packages/js-draw/src/SVGLoader/SVGLoader.test.ts
index 0e485bfe4..7d56923c3 100644
--- a/packages/js-draw/src/SVGLoader/SVGLoader.test.ts
+++ b/packages/js-draw/src/SVGLoader/SVGLoader.test.ts
@@ -26,7 +26,7 @@ describe('SVGLoader', () => {
),
);
const elems = editor.image
- .getElementsIntersectingRegion(new Rect2(-1000, -1000, 10000, 10000))
+ .getComponentsIntersecting(new Rect2(-1000, -1000, 10000, 10000))
.filter((elem) => elem instanceof TextComponent);
expect(elems).toHaveLength(5);
const topLefts = elems.map((elem) => elem.getBBox().topLeft);
diff --git a/packages/js-draw/src/SVGLoader/SVGLoader.ts b/packages/js-draw/src/SVGLoader/SVGLoader.ts
index eb9ce8979..b20a13bb2 100644
--- a/packages/js-draw/src/SVGLoader/SVGLoader.ts
+++ b/packages/js-draw/src/SVGLoader/SVGLoader.ts
@@ -49,6 +49,27 @@ export type SVGLoaderUnknownAttribute = [string, string];
// [key, value, priority]
export type SVGLoaderUnknownStyleAttribute = { key: string; value: string; priority?: string };
+export interface SVGLoaderControl {
+ /** Call this to add a component to the editor. */
+ addComponent: ComponentAddedListener;
+}
+
+/**
+ * Loads custom components from an SVG image.
+ * @see SVGLoader.fromString
+ */
+export interface SVGLoaderPlugin {
+ /**
+ * Called when the {@link SVGLoader} encounters a `node`.
+ *
+ * Call `loader.addComponent` to add new components to the image.
+ *
+ * Returning `true` prevents the {@link SVGLoader} from doing further
+ * processing on the node.
+ */
+ visit(node: Element, loader: SVGLoaderControl): Promise;
+}
+
// @internal
export enum SVGLoaderLoadMethod {
IFrame = 'iframe',
@@ -56,8 +77,12 @@ export enum SVGLoaderLoadMethod {
}
export interface SVGLoaderOptions {
+ // Note: Although `sanitize` is intended to prevent unknown object types
+ // from being stored in the image, it's possible for such objects to be
+ // added through {@link SVGLoaderOptions.plugins}.
sanitize?: boolean;
disableUnknownObjectWarnings?: boolean;
+ plugins?: SVGLoaderPlugin[];
// @internal
loadMethod?: SVGLoaderLoadMethod;
@@ -78,12 +103,14 @@ export default class SVGLoader implements ImageLoader {
// Options
private readonly storeUnknown: boolean;
private readonly disableUnknownObjectWarnings: boolean;
+ private readonly plugins: SVGLoaderPlugin[];
private constructor(
private source: Element,
private onFinish: OnFinishListener | null,
options: SVGLoaderOptions,
) {
+ this.plugins = options.plugins ?? [];
this.storeUnknown = !(options.sanitize ?? false);
this.disableUnknownObjectWarnings = !!options.disableUnknownObjectWarnings;
}
@@ -577,61 +604,85 @@ export default class SVGLoader implements ImageLoader {
this.totalToProcess += node.childElementCount;
let visitChildren = true;
- switch (node.tagName.toLowerCase()) {
- case 'g':
- if (node.classList.contains(imageBackgroundCSSClassName)) {
- await this.addBackground(node as SVGElement);
+ const visitPlugin = async () => {
+ for (const plugin of this.plugins) {
+ const processed = await plugin.visit(node, {
+ addComponent: (component) => {
+ return this.onAddComponent?.(component);
+ },
+ });
+
+ if (processed) {
visitChildren = false;
- } else {
- await this.startGroup(node as SVGGElement);
- }
- // Otherwise, continue -- visit the node's children.
- break;
- case 'path':
- if (node.classList.contains(imageBackgroundCSSClassName)) {
- await this.addBackground(node as SVGElement);
- } else {
- await this.addPath(node as SVGPathElement);
+ return true;
}
- break;
- case 'text':
- await this.addText(node as SVGTextElement);
- visitChildren = false;
- break;
- case 'image':
- await this.addImage(node as SVGImageElement);
-
- // Images should not have children.
- visitChildren = false;
- break;
- case 'svg':
- this.updateViewBox(node as SVGSVGElement);
- this.updateSVGAttrs(node as SVGSVGElement);
- break;
- case 'style':
- // Keeping unnecessary style sheets can cause the browser to keep all
- // SVG elements *referenced* by the style sheet in some browsers.
- //
- // Only keep the style sheet if it won't be discarded on save.
- if (node.getAttribute('id') !== renderedStylesheetId) {
- await this.addUnknownNode(node as SVGStyleElement);
- }
- break;
- default:
- if (!this.disableUnknownObjectWarnings) {
- console.warn('Unknown SVG element,', node, node.tagName);
- if (!(node instanceof SVGElement)) {
- console.warn(
- 'Element',
- node,
- 'is not an SVGElement!',
- this.storeUnknown ? 'Continuing anyway.' : 'Skipping.',
- );
+ }
+ return false;
+ };
+
+ const visitBuiltIn = async () => {
+ switch (node.tagName.toLowerCase()) {
+ case 'g':
+ if (node.classList.contains(imageBackgroundCSSClassName)) {
+ await this.addBackground(node as SVGElement);
+ visitChildren = false;
+ } else {
+ await this.startGroup(node as SVGGElement);
}
- }
+ // Otherwise, continue -- visit the node's children.
+ break;
+ case 'path':
+ if (node.classList.contains(imageBackgroundCSSClassName)) {
+ await this.addBackground(node as SVGElement);
+ } else {
+ await this.addPath(node as SVGPathElement);
+ }
+ break;
+ case 'text':
+ await this.addText(node as SVGTextElement);
+ visitChildren = false;
+ break;
+ case 'image':
+ await this.addImage(node as SVGImageElement);
- await this.addUnknownNode(node as SVGElement);
- return;
+ // Images should not have children.
+ visitChildren = false;
+ break;
+ case 'svg':
+ this.updateViewBox(node as SVGSVGElement);
+ this.updateSVGAttrs(node as SVGSVGElement);
+ break;
+ case 'style':
+ // Keeping unnecessary style sheets can cause the browser to keep all
+ // SVG elements *referenced* by the style sheet in some browsers.
+ //
+ // Only keep the style sheet if it won't be discarded on save.
+ if (node.getAttribute('id') !== renderedStylesheetId) {
+ await this.addUnknownNode(node as SVGStyleElement);
+ }
+ break;
+ default:
+ if (!this.disableUnknownObjectWarnings) {
+ console.warn('Unknown SVG element,', node, node.tagName);
+ if (!(node instanceof SVGElement)) {
+ console.warn(
+ 'Element',
+ node,
+ 'is not an SVGElement!',
+ this.storeUnknown ? 'Continuing anyway.' : 'Skipping.',
+ );
+ }
+ }
+
+ await this.addUnknownNode(node as SVGElement);
+ return;
+ }
+ };
+
+ if (await visitPlugin()) {
+ visitChildren = false;
+ } else {
+ await visitBuiltIn();
}
if (visitChildren) {
@@ -788,18 +839,22 @@ export default class SVGLoader implements ImageLoader {
// Handle options
let sanitize;
let disableUnknownObjectWarnings;
+ let plugins;
if (typeof options === 'boolean') {
sanitize = options;
disableUnknownObjectWarnings = false;
+ plugins = [];
} else {
sanitize = options.sanitize ?? false;
disableUnknownObjectWarnings = options.disableUnknownObjectWarnings ?? false;
+ plugins = options.plugins;
}
return new SVGLoader(svgElem, cleanUp, {
sanitize,
disableUnknownObjectWarnings,
+ plugins,
});
}
}
diff --git a/packages/js-draw/src/UndoRedoHistory.test.ts b/packages/js-draw/src/UndoRedoHistory.test.ts
index 2b9f27791..6a8ddf3c5 100644
--- a/packages/js-draw/src/UndoRedoHistory.test.ts
+++ b/packages/js-draw/src/UndoRedoHistory.test.ts
@@ -8,7 +8,7 @@ describe('UndoRedoHistory', () => {
const stroke = new Stroke([
pathToRenderable(Path.fromString('m0,0 10,10'), { fill: Color4.red }),
]);
- editor.dispatch(EditorImage.addElement(stroke));
+ editor.dispatch(EditorImage.addComponent(stroke));
for (let i = 0; i < editor.history['maxUndoRedoStackSize'] + 10; i++) {
editor.dispatch(stroke.transformBy(Mat33.translation(Vec2.of(1, 1))));
diff --git a/packages/js-draw/src/commands/Duplicate.ts b/packages/js-draw/src/commands/Duplicate.ts
index 0c06d66a3..3432bee1f 100644
--- a/packages/js-draw/src/commands/Duplicate.ts
+++ b/packages/js-draw/src/commands/Duplicate.ts
@@ -16,7 +16,7 @@ import SerializableCommand from './SerializableCommand';
*
* // Find all elements intersecting the rectangle with top left (0,0) and
* // (width,height)=(100,100).
- * const elems = editor.image.getElementsIntersectingRegion(
+ * const elems = editor.image.getComponentsIntersecting(
* new Rect2(0, 0, 100, 100)
* );
*
@@ -27,7 +27,7 @@ import SerializableCommand from './SerializableCommand';
* editor.dispatch(duplicateElems);
* ```
*
- * @see {@link Editor.dispatch} {@link EditorImage.getElementsIntersectingRegion}
+ * @see {@link Editor.dispatch} {@link EditorImage.getComponentsIntersecting}
*/
export default class Duplicate extends SerializableCommand {
private duplicates: AbstractComponent[];
diff --git a/packages/js-draw/src/commands/Erase.ts b/packages/js-draw/src/commands/Erase.ts
index f4b7e6ba9..fe2cc9eb2 100644
--- a/packages/js-draw/src/commands/Erase.ts
+++ b/packages/js-draw/src/commands/Erase.ts
@@ -34,7 +34,7 @@ import SerializableCommand from './SerializableCommand';
*
* // Find all elements intersecting the rectangle with top left (-10,-30) and
* // (width,height)=(50,100).
- * const elems = editor.image.getElementsIntersectingRegion(
+ * const elems = editor.image.getComponentsIntersecting(
* new Rect2(-10, -30, 50, 100)
* );
*
@@ -74,7 +74,7 @@ export default class Erase extends SerializableCommand {
public unapply(editor: Editor) {
for (const part of this.toRemove) {
if (!editor.image.findParent(part)) {
- EditorImage.addElement(part).apply(editor);
+ EditorImage.addComponent(part).apply(editor);
}
}
diff --git a/packages/js-draw/src/commands/invertCommand.test.ts b/packages/js-draw/src/commands/invertCommand.test.ts
index 7b26f2614..8b43a8a3f 100644
--- a/packages/js-draw/src/commands/invertCommand.test.ts
+++ b/packages/js-draw/src/commands/invertCommand.test.ts
@@ -34,7 +34,7 @@ describe('invertCommand', () => {
const testComponent = new Stroke([
pathToRenderable(Path.fromString('m0,0 l10,10'), { fill: Color4.red }),
]);
- editor.image.addElement(testComponent).apply(editor);
+ editor.image.addComponent(testComponent).apply(editor);
const testCommand = new Erase([testComponent]);
const inverted = invertCommand(testCommand);
diff --git a/packages/js-draw/src/commands/localization.ts b/packages/js-draw/src/commands/localization.ts
index 8c95beba4..40fd63800 100644
--- a/packages/js-draw/src/commands/localization.ts
+++ b/packages/js-draw/src/commands/localization.ts
@@ -16,7 +16,7 @@ export interface CommandLocalization {
resizeOutputCommand: (newSize: Rect2) => string;
enabledAutoresizeOutputCommand: string;
disabledAutoresizeOutputCommand: string;
- addElementAction: (elemDescription: string) => string;
+ addComponentAction: (elemDescription: string) => string;
eraseAction: (elemDescription: string, numElems: number) => string;
duplicateAction: (elemDescription: string, count: number) => string;
inverseOf: (actionDescription: string) => string;
@@ -33,7 +33,7 @@ export const defaultCommandLocalization: CommandLocalization = {
resizeOutputCommand: (newSize: Rect2) => `Resized image to ${newSize.w}x${newSize.h}`,
enabledAutoresizeOutputCommand: 'Enabled output autoresize',
disabledAutoresizeOutputCommand: 'Disabled output autoresize',
- addElementAction: (componentDescription: string) => `Added ${componentDescription}`,
+ addComponentAction: (componentDescription: string) => `Added ${componentDescription}`,
eraseAction: (componentDescription: string, numElems: number) =>
`Erased ${numElems} ${componentDescription}`,
duplicateAction: (componentDescription: string, numElems: number) =>
diff --git a/packages/js-draw/src/commands/uniteCommands.test.ts b/packages/js-draw/src/commands/uniteCommands.test.ts
index b9e57834c..59e712a3c 100644
--- a/packages/js-draw/src/commands/uniteCommands.test.ts
+++ b/packages/js-draw/src/commands/uniteCommands.test.ts
@@ -18,7 +18,7 @@ describe('uniteCommands', () => {
pathToRenderable(Path.fromString('m0,0 l10,10 h-2 z'), { fill: Color4.red }),
]);
const union = uniteCommands([
- EditorImage.addElement(stroke),
+ EditorImage.addComponent(stroke),
stroke.transformBy(Mat33.translation(Vec2.of(1, 10))),
]);
const deserialized = SerializableCommand.deserialize(union.serialize(), editor);
@@ -36,7 +36,7 @@ describe('uniteCommands', () => {
const commands = [];
for (let i = 0; i < 1000; i++) {
- commands.push(editor.image.addElement(new StrokeComponent([])));
+ commands.push(editor.image.addComponent(new StrokeComponent([])));
}
// Should generate a short description
@@ -47,7 +47,7 @@ describe('uniteCommands', () => {
it('should be possible to override the default uniteCommands description', () => {
const editor = createEditor();
- const command = uniteCommands([EditorImage.addElement(new StrokeComponent([]))], {
+ const command = uniteCommands([EditorImage.addComponent(new StrokeComponent([]))], {
description: 'Foo',
});
expect(command.description(editor, editor.localization)).toBe('Foo');
@@ -56,7 +56,7 @@ describe('uniteCommands', () => {
it('should serialize and deserialize command descriptions', () => {
const editor = createEditor();
const command = uniteCommands(
- [EditorImage.addElement(new StrokeComponent([])), editor.setBackgroundColor(Color4.red)],
+ [EditorImage.addComponent(new StrokeComponent([])), editor.setBackgroundColor(Color4.red)],
{ description: 'Bar' },
);
@@ -70,7 +70,7 @@ describe('uniteCommands', () => {
it('should ignore applyChunkSize when fewer than that many commands are present', () => {
const editor = createEditor();
const command = uniteCommands(
- [EditorImage.addElement(new StrokeComponent([])), editor.setBackgroundColor(Color4.red)],
+ [EditorImage.addComponent(new StrokeComponent([])), editor.setBackgroundColor(Color4.red)],
{ applyChunkSize: 10 },
);
diff --git a/packages/js-draw/src/components/AbstractComponent.transformBy.test.ts b/packages/js-draw/src/components/AbstractComponent.transformBy.test.ts
index 3ac3a1599..33b520631 100644
--- a/packages/js-draw/src/components/AbstractComponent.transformBy.test.ts
+++ b/packages/js-draw/src/components/AbstractComponent.transformBy.test.ts
@@ -9,7 +9,7 @@ describe('AbstractComponent.transformBy', () => {
const component = new Stroke([
pathToRenderable(Path.fromRect(Rect2.unitSquare), { fill: Color4.red }),
]);
- EditorImage.addElement(component).apply(editor);
+ EditorImage.addComponent(component).apply(editor);
const origZIndex = component.getZIndex();
diff --git a/packages/js-draw/src/components/AbstractComponent.ts b/packages/js-draw/src/components/AbstractComponent.ts
index 28566ca53..58750109f 100644
--- a/packages/js-draw/src/components/AbstractComponent.ts
+++ b/packages/js-draw/src/components/AbstractComponent.ts
@@ -350,7 +350,7 @@ export default abstract class AbstractComponent {
// Add the element back to the document.
if (hadParent) {
- EditorImage.addElement(this.component).apply(editor);
+ EditorImage.addComponent(this.component).apply(editor);
}
}
diff --git a/packages/js-draw/src/components/BackgroundComponent.test.ts b/packages/js-draw/src/components/BackgroundComponent.test.ts
index fb227d990..69d5f2224 100644
--- a/packages/js-draw/src/components/BackgroundComponent.test.ts
+++ b/packages/js-draw/src/components/BackgroundComponent.test.ts
@@ -10,7 +10,7 @@ describe('ImageBackground', () => {
it('should render to fill exported SVG', () => {
const editor = createEditor();
const background = new BackgroundComponent(BackgroundType.SolidColor, Color4.green);
- editor.image.addElement(background).apply(editor);
+ editor.image.addComponent(background).apply(editor);
const expectedImportExportRect = new Rect2(-10, 10, 15, 20);
editor.setImportExportRect(expectedImportExportRect).apply(editor);
@@ -56,7 +56,7 @@ describe('ImageBackground', () => {
// Add a background
const background = new BackgroundComponent(BackgroundType.SolidColor, Color4.green);
- editor.dispatch(editor.image.addElement(background));
+ editor.dispatch(editor.image.addComponent(background));
// Render to SVG and increase the output size
const asSVG = editor.toSVG({ minDimension: 50 });
diff --git a/packages/js-draw/src/components/Stroke.test.ts b/packages/js-draw/src/components/Stroke.test.ts
index f331e8d89..8bc467a88 100644
--- a/packages/js-draw/src/components/Stroke.test.ts
+++ b/packages/js-draw/src/components/Stroke.test.ts
@@ -109,7 +109,7 @@ describe('Stroke', () => {
expect(stroke.getStyle().color).objEq(Color4.fromHex('#0f0'));
const editor = createEditor();
- EditorImage.addElement(stroke).apply(editor);
+ EditorImage.addComponent(stroke).apply(editor);
// Re-rendering should render with the new color
const renderer = new DummyRenderer(editor.viewport);
@@ -186,4 +186,35 @@ describe('Stroke', () => {
expect(stroke.getExactBBox()).objEq(new Rect2(-1, -1, 2, 2));
expect(stroke.getBBox()).objEq(new Rect2(-1, -1, 2, 2));
});
+
+ it.each(['m0,0 l11,10', Path.fromString('m3,2 l3,4 l5,6 m4,2')])(
+ '.fromStroked should create strokes with transparent fill (path %s)',
+ (path) => {
+ expect(Stroke.fromStroked(path, { width: 4, color: Color4.red }).serialize()).toMatchObject({
+ data: [
+ {
+ style: {
+ fill: Color4.transparent.toString(),
+ stroke: { width: 4, color: Color4.red.toString() },
+ },
+ path: path.toString(),
+ },
+ ],
+ });
+ },
+ );
+
+ it.each(['m0,0 l11,10', Path.fromString('m3,2 l3,4 l5,6 m4,2')])(
+ '.fromFilled should create strokes with no stroke (path %s)',
+ (path) => {
+ expect(Stroke.fromFilled(path, Color4.blue).serialize()).toMatchObject({
+ data: [
+ {
+ style: { fill: Color4.blue.toString(), stroke: undefined },
+ path: path.toString(),
+ },
+ ],
+ });
+ },
+ );
});
diff --git a/packages/js-draw/src/components/Stroke.ts b/packages/js-draw/src/components/Stroke.ts
index b5465d507..9c825f759 100644
--- a/packages/js-draw/src/components/Stroke.ts
+++ b/packages/js-draw/src/components/Stroke.ts
@@ -9,10 +9,15 @@ import {
PathIntersectionResult,
comparePathIndices,
stepPathIndexBy,
+ Color4,
} from '@js-draw/math';
import Editor from '../Editor';
import AbstractRenderer from '../rendering/renderers/AbstractRenderer';
-import RenderingStyle, { styleFromJSON, styleToJSON } from '../rendering/RenderingStyle';
+import RenderingStyle, {
+ StrokeStyle,
+ styleFromJSON,
+ styleToJSON,
+} from '../rendering/RenderingStyle';
import AbstractComponent from './AbstractComponent';
import { ImageComponentLocalization } from './localization';
import RestyleableComponent, {
@@ -113,6 +118,39 @@ export default class Stroke extends AbstractComponent implements RestyleableComp
this.contentBBox ??= Rect2.empty;
}
+ /**
+ * Creates a new `Stroke` from a {@link Path} and `style`. Strokes created
+ * with this method have transparent fill.
+ *
+ * Example:
+ * ```ts,runnable
+ * import { Editor, Stroke, Color4 } from 'js-draw';
+ * const editor = new Editor(document.body);
+ * ---visible---
+ * const stroke = Stroke.fromStroked('m0,0 l10,10', { width: 10, color: Color4.red });
+ * editor.dispatch(editor.image.addComponent(stroke));
+ * ```
+ * Notice that `path` can be a string that specifies an SVG path
+ *
+ * @see fromFilled
+ */
+ public static fromStroked(path: Path | string, style: StrokeStyle) {
+ if (typeof path === 'string') {
+ path = Path.fromString(path);
+ }
+
+ return new Stroke([pathToRenderable(path, { fill: Color4.transparent, stroke: style })]);
+ }
+
+ /** @see fromStroked */
+ public static fromFilled(path: Path | string, fill: Color4) {
+ if (typeof path === 'string') {
+ path = Path.fromString(path);
+ }
+
+ return new Stroke([pathToRenderable(path, { fill })]);
+ }
+
public getStyle(): ComponentStyle {
if (this.parts.length === 0) {
return {};
diff --git a/packages/js-draw/src/components/TextComponent.test.ts b/packages/js-draw/src/components/TextComponent.test.ts
index 7eff94621..a247a9f2f 100644
--- a/packages/js-draw/src/components/TextComponent.test.ts
+++ b/packages/js-draw/src/components/TextComponent.test.ts
@@ -60,7 +60,7 @@ describe('TextComponent', () => {
// Should queue a re-render after restyling.
const editor = createEditor();
- EditorImage.addElement(text).apply(editor);
+ EditorImage.addComponent(text).apply(editor);
editor.rerender();
expect(editor.isRerenderQueued()).toBe(false);
diff --git a/packages/js-draw/src/components/TextComponent.ts b/packages/js-draw/src/components/TextComponent.ts
index ff0b4698f..da7ea3784 100644
--- a/packages/js-draw/src/components/TextComponent.ts
+++ b/packages/js-draw/src/components/TextComponent.ts
@@ -60,7 +60,7 @@ const defaultTextStyle: TextRenderingStyle = {
* };
*
* editor.dispatch(
- * editor.image.addElement(new TextComponent(['Hello, world'], positioning1, style)),
+ * editor.image.addComponent(new TextComponent(['Hello, world'], positioning1, style)),
* );
*
*
@@ -71,7 +71,7 @@ const defaultTextStyle: TextRenderingStyle = {
* // is placed directly after 'Test'.
* const positioning2 = Mat33.translation(Vec2.of(10, 50));
* editor.dispatch(
- * editor.image.addElement(
+ * editor.image.addComponent(
* new TextComponent([ new TextComponent(['Test'], positioning1, style), '[Test]' ], positioning2, style)
* ),
* );
diff --git a/packages/js-draw/src/image/EditorImage.test.ts b/packages/js-draw/src/image/EditorImage.test.ts
index eda90f905..78585cd51 100644
--- a/packages/js-draw/src/image/EditorImage.test.ts
+++ b/packages/js-draw/src/image/EditorImage.test.ts
@@ -62,7 +62,7 @@ describe('EditorImage', () => {
},
]);
const testFill: RenderingStyle = { fill: Color4.black };
- const addTestStrokeCommand = EditorImage.addElement(testStroke);
+ const addTestStrokeCommand = EditorImage.addComponent(testStroke);
beforeEach(() => {
EditorImage.setDebugMode(true);
@@ -110,7 +110,7 @@ describe('EditorImage', () => {
expect(!leftmostStroke.getBBox().intersects(rightmostStroke.getBBox()));
- EditorImage.addElement(leftmostStroke).apply(editor);
+ EditorImage.addComponent(leftmostStroke).apply(editor);
// The first node should be at the image's root.
let firstParent = image.findParent(leftmostStroke);
@@ -118,7 +118,7 @@ describe('EditorImage', () => {
expect(firstParent?.getParent()).toBe(null);
expect(firstParent?.getBBox()?.corners).toMatchObject(leftmostStroke.getBBox()?.corners);
- EditorImage.addElement(rightmostStroke).apply(editor);
+ EditorImage.addComponent(rightmostStroke).apply(editor);
firstParent = image.findParent(leftmostStroke);
const secondParent = image.findParent(rightmostStroke);
@@ -168,7 +168,7 @@ describe('EditorImage', () => {
const originalRect = getScreenRect();
- await editor.dispatch(image.addElement(testStroke));
+ await editor.dispatch(image.addComponent(testStroke));
expect(image.getAutoresizeEnabled()).toBe(false);
@@ -196,7 +196,7 @@ describe('EditorImage', () => {
const testStroke2 = testStroke.clone();
await editor.dispatch(
uniteCommands([
- editor.image.addElement(testStroke2),
+ editor.image.addComponent(testStroke2),
testStroke2.transformBy(Mat33.translation(Vec2.of(100, -10))),
]),
);
@@ -223,7 +223,7 @@ describe('EditorImage', () => {
const stroke3 = new Stroke([
pathToRenderable(Path.fromRect(new Rect2(5, -11, 53, 53)), { fill: Color4.red }),
]);
- await editor.dispatch(EditorImage.addElement(stroke3));
+ await editor.dispatch(EditorImage.addComponent(stroke3));
// After adding multiple new strokes, should have correct top-left corner
// (tests non-zero case).
@@ -232,7 +232,7 @@ describe('EditorImage', () => {
const stroke = new Stroke([
pathToRenderable(Path.fromString(`m${x},${y} l1,0 l0,1`), { fill: Color4.red }),
]);
- await editor.dispatch(EditorImage.addElement(stroke));
+ await editor.dispatch(EditorImage.addComponent(stroke));
}
}
@@ -332,7 +332,7 @@ describe('EditorImage', () => {
const editor = createEditor();
const image = editor.image;
- const addElementCommand = image.addElement(testComponent);
+ const addElementCommand = image.addComponent(testComponent);
expect(renderMock).not.toHaveBeenCalled();
expect(addToImageMock).not.toHaveBeenCalled();
@@ -349,14 +349,12 @@ describe('EditorImage', () => {
// should return the element.
if (positioning === ComponentSizingMode.FillScreen) {
expect(
- image
- .getElementsIntersectingRegion(new Rect2(50, 50, 1, 1), true)
- .includes(testComponent),
+ image.getComponentsIntersecting(new Rect2(50, 50, 1, 1), true).includes(testComponent),
).toBe(true);
}
// Querying the component's own bounding box should also return results.
- const elements = image.getElementsIntersectingRegion(
+ const elements = image.getComponentsIntersecting(
// Grow the check region if an empty bbox
bbox.maxDimension === 0 ? bbox.grownBy(1) : bbox,
diff --git a/packages/js-draw/src/image/EditorImage.ts b/packages/js-draw/src/image/EditorImage.ts
index a1ee718bf..a4a851d95 100644
--- a/packages/js-draw/src/image/EditorImage.ts
+++ b/packages/js-draw/src/image/EditorImage.ts
@@ -45,9 +45,9 @@ let debugMode = false;
*
* Here's how to do a few common operations:
* - **Get all components in a {@link @js-draw/math!Rect2 | Rect2}**:
- * {@link EditorImage.getElementsIntersectingRegion}.
+ * {@link EditorImage.getComponentsIntersecting}.
* - **Draw an `EditorImage` onto a canvas/SVG**: {@link EditorImage.render}.
- * - **Adding a new component**: {@link EditorImage.addElement}.
+ * - **Adding a new component**: {@link EditorImage.addComponent}.
*
* **Example**:
* [[include:doc-pages/inline-examples/image-add-and-lookup.md]]
@@ -115,7 +115,7 @@ export default class EditorImage {
if (parent) {
parent.remove();
- this.addElementDirectly(elem);
+ this.addComponentDirectly(elem);
}
}
@@ -184,7 +184,7 @@ export default class EditorImage {
* @returns all elements in the image, sorted by z-index (low to high).
*
* This can be slow for large images. If you only need all elemenst in part of the image,
- * consider using {@link getElementsIntersectingRegion} instead.
+ * consider using {@link getComponentsIntersecting} instead.
*
* **Note**: The result does not include background elements. See {@link getBackgroundComponents}.
*/
@@ -200,12 +200,17 @@ export default class EditorImage {
return this.componentCount;
}
+ /** @deprecated @see getComponentsIntersecting */
+ public getElementsIntersectingRegion(region: Rect2, includeBackground: boolean = false) {
+ return this.getComponentsIntersecting(region, includeBackground);
+ }
+
/**
* @returns a list of `AbstractComponent`s intersecting `region`, sorted by increasing z-index.
*
* Components in the background layer are only included if `includeBackground` is `true`.
*/
- public getElementsIntersectingRegion(
+ public getComponentsIntersecting(
region: Rect2,
includeBackground: boolean = false,
): AbstractComponent[] {
@@ -244,7 +249,7 @@ export default class EditorImage {
return this.componentsById[id] ?? null;
}
- private addElementDirectly(elem: AbstractComponent): ImageNode {
+ private addComponentDirectly(elem: AbstractComponent): ImageNode {
// Because onAddToImage can affect the element's bounding box,
// this needs to be called before parentTree.addLeaf.
elem.onAddToImage(this);
@@ -280,20 +285,33 @@ export default class EditorImage {
*
* [[include:doc-pages/inline-examples/adding-a-stroke.md]]
*/
- public static addElement(
+ public static addComponent(
elem: AbstractComponent,
applyByFlattening: boolean = false,
): SerializableCommand {
- return new EditorImage.AddElementCommand(elem, applyByFlattening);
+ return new EditorImage.AddComponentCommand(elem, applyByFlattening);
}
- /** @see EditorImage.addElement */
+ /** @see EditorImage.addComponent */
+ public addComponent(component: AbstractComponent, applyByFlattening?: boolean) {
+ return EditorImage.addComponent(component, applyByFlattening);
+ }
+
+ /** Alias for {@link addComponent}. @deprecated Prefer `.addComponent` */
public addElement(elem: AbstractComponent, applyByFlattening?: boolean) {
- return EditorImage.addElement(elem, applyByFlattening);
+ return this.addComponent(elem, applyByFlattening);
+ }
+
+ /** Alias for {@link addComponent}. @deprecated Prefer `.addComponent`. */
+ public static addElement(
+ elem: AbstractComponent,
+ applyByFlattening: boolean = false,
+ ): SerializableCommand {
+ return this.addComponent(elem, applyByFlattening);
}
// A Command that can access private [EditorImage] functionality
- private static AddElementCommand = class extends SerializableCommand {
+ private static AddComponentCommand = class extends SerializableCommand {
private serializedElem: any = null;
// If [applyByFlattening], then the rendered content of this element
@@ -318,7 +336,7 @@ export default class EditorImage {
}
public apply(editor: Editor) {
- editor.image.addElementDirectly(this.element);
+ editor.image.addComponentDirectly(this.element);
if (!this.applyByFlattening) {
editor.queueRerender();
@@ -334,7 +352,7 @@ export default class EditorImage {
}
public description(_editor: Editor, localization: EditorLocalization) {
- return localization.addElementAction(this.element.description(localization));
+ return localization.addComponentAction(this.element.description(localization));
}
protected serializeToJSON() {
@@ -348,7 +366,7 @@ export default class EditorImage {
const id = json.elemData.id;
const foundElem = editor.image.lookupElement(id);
const elem = foundElem ?? AbstractComponent.deserialize(json.elemData);
- const result = new EditorImage.AddElementCommand(elem);
+ const result = new EditorImage.AddComponentCommand(elem);
result.serializedElem = json.elemData;
return result;
});
diff --git a/packages/js-draw/src/lib.ts b/packages/js-draw/src/lib.ts
index a4d6c1cdd..a59a1012b 100644
--- a/packages/js-draw/src/lib.ts
+++ b/packages/js-draw/src/lib.ts
@@ -25,7 +25,7 @@ export {
} from './localizations/getLocalizationTable';
export * from './localization';
-export { default as SVGLoader } from './SVGLoader/SVGLoader';
+export { default as SVGLoader, SVGLoaderPlugin } from './SVGLoader/SVGLoader';
export { default as Viewport } from './Viewport';
export * from '@js-draw/math';
export * from './components/lib';
diff --git a/packages/js-draw/src/localizations/de.ts b/packages/js-draw/src/localizations/de.ts
index 7cc5441a8..6152b3c79 100644
--- a/packages/js-draw/src/localizations/de.ts
+++ b/packages/js-draw/src/localizations/de.ts
@@ -85,7 +85,7 @@ const localization: EditorLocalization = {
transformedElements: (elemCount, action) =>
`${elemCount} Element${1 === elemCount ? '' : 'e'} transformiert (${action})`,
resizeOutputCommand: (newSize) => `Bildgröße auf ${newSize.w}x${newSize.h} geändert`,
- addElementAction: (componentDescription) => `${componentDescription} hinzugefügt`,
+ addComponentAction: (componentDescription) => `${componentDescription} hinzugefügt`,
eraseAction: (elemDescription, countErased) => `${countErased} ${elemDescription} gelöscht`,
duplicateAction: (elemDescription, countErased) => `${countErased} ${elemDescription} dupliziert`,
inverseOf: (actionDescription) => `${actionDescription} umgekehrt`,
diff --git a/packages/js-draw/src/rendering/RenderingStyle.ts b/packages/js-draw/src/rendering/RenderingStyle.ts
index 6e35e082f..d01394c7b 100644
--- a/packages/js-draw/src/rendering/RenderingStyle.ts
+++ b/packages/js-draw/src/rendering/RenderingStyle.ts
@@ -1,13 +1,15 @@
import { Color4 } from '@js-draw/math';
-interface RenderingStyle {
- readonly fill: Color4;
- readonly stroke?: {
- readonly color: Color4;
+export interface StrokeStyle {
+ readonly color: Color4;
- /** Note: The stroke `width` is twice the stroke radius. */
- readonly width: number;
- };
+ /** Note: The stroke `width` is twice the stroke radius. */
+ readonly width: number;
+}
+
+export interface RenderingStyle {
+ readonly fill: Color4;
+ readonly stroke?: StrokeStyle;
}
export default RenderingStyle;
diff --git a/packages/js-draw/src/rendering/caching/RenderingCache.test.ts b/packages/js-draw/src/rendering/caching/RenderingCache.test.ts
index 81d350dfe..3641da999 100644
--- a/packages/js-draw/src/rendering/caching/RenderingCache.test.ts
+++ b/packages/js-draw/src/rendering/caching/RenderingCache.test.ts
@@ -27,7 +27,7 @@ describe('RenderingCache', () => {
editor.image.renderWithCache(screenRenderer, cache, editor.viewport);
expect(lastRenderer).toBeNull();
- editor.dispatch(EditorImage.addElement(testStroke));
+ editor.dispatch(EditorImage.addComponent(testStroke));
editor.image.renderWithCache(screenRenderer, cache, editor.viewport);
expect(allocdRenderers).toBeGreaterThanOrEqual(1);
diff --git a/packages/js-draw/src/rendering/lib.ts b/packages/js-draw/src/rendering/lib.ts
index 7daf97bcd..5324c3dff 100644
--- a/packages/js-draw/src/rendering/lib.ts
+++ b/packages/js-draw/src/rendering/lib.ts
@@ -4,7 +4,7 @@ export { default as SVGRenderer } from './renderers/SVGRenderer';
export { default as CanvasRenderer } from './renderers/CanvasRenderer';
export { default as Display, RenderingMode } from './Display';
export { default as TextRenderingStyle } from './TextRenderingStyle';
-export { default as RenderingStyle } from './RenderingStyle';
+export { default as RenderingStyle, StrokeStyle as StrokeRenerdingStyle } from './RenderingStyle';
export {
pathToRenderable,
pathFromRenderable,
diff --git a/packages/js-draw/src/rendering/renderers/AbstractRenderer.ts b/packages/js-draw/src/rendering/renderers/AbstractRenderer.ts
index 1ab67ea8a..942a3495a 100644
--- a/packages/js-draw/src/rendering/renderers/AbstractRenderer.ts
+++ b/packages/js-draw/src/rendering/renderers/AbstractRenderer.ts
@@ -204,6 +204,9 @@ export default abstract class AbstractRenderer {
}
public pushTransform(transform: Mat33) {
+ // Draw all pending paths that used the previous transform (if any).
+ this.flushPath();
+
this.transformStack.push(this.selfTransform);
this.setTransform(this.getCanvasToScreenTransform().rightMul(transform));
}
@@ -213,6 +216,9 @@ export default abstract class AbstractRenderer {
throw new Error('Unable to pop more transforms than have been pushed!');
}
+ // Draw all pending paths that used the old transform (if any):
+ this.flushPath();
+
this.setTransform(this.transformStack.pop() ?? null);
}
diff --git a/packages/js-draw/src/rendering/renderers/CanvasRenderer.ts b/packages/js-draw/src/rendering/renderers/CanvasRenderer.ts
index 301bd3d3c..f89aaa597 100644
--- a/packages/js-draw/src/rendering/renderers/CanvasRenderer.ts
+++ b/packages/js-draw/src/rendering/renderers/CanvasRenderer.ts
@@ -269,6 +269,21 @@ export default class CanvasRenderer extends AbstractRenderer {
}
}
+ /**
+ * Returns a reference to the underlying `CanvasRenderingContext2D`.
+ * This can be used to render custom content not supported by {@link AbstractRenderer}.
+ * However, such content won't support {@link SVGRenderer} or {@link TextOnlyRenderer}
+ * by default.
+ *
+ * Use with caution.
+ */
+ public drawWithRawRenderingContext(callback: (ctx: CanvasRenderingContext2D) => void) {
+ this.ctx.save();
+ this.transformBy(this.getCanvasToScreenTransform());
+ callback(this.ctx);
+ this.ctx.restore();
+ }
+
// @internal
public drawPoints(...points: Point2[]) {
const pointRadius = 10;
diff --git a/packages/js-draw/src/rendering/renderers/SVGRenderer.ts b/packages/js-draw/src/rendering/renderers/SVGRenderer.ts
index 51286559f..dabd736b1 100644
--- a/packages/js-draw/src/rendering/renderers/SVGRenderer.ts
+++ b/packages/js-draw/src/rendering/renderers/SVGRenderer.ts
@@ -34,6 +34,10 @@ type FromViewportOptions = {
useViewBoxForPositioning?: boolean;
};
+type DrawWithSVGParentContext = {
+ sanitize: boolean;
+};
+
/**
* Renders onto an `SVGElement`.
*
@@ -432,7 +436,12 @@ export default class SVGRenderer extends AbstractRenderer {
});
}
- // Renders a **copy** of the given element.
+ /**
+ * Adds a **copy** of the given element directly to the container
+ * SVG element, **without applying transforms**.
+ *
+ * If `sanitize` is enabled, this does nothing.
+ */
public drawSVGElem(elem: SVGElement) {
if (this.sanitize) {
return;
@@ -451,6 +460,24 @@ export default class SVGRenderer extends AbstractRenderer {
this.objectElems?.push(elemToDraw);
}
+ /**
+ * Allows rendering directly to the underlying SVG element. Rendered
+ * content is added to a `` element that's passed as `parent` to `callback`.
+ *
+ * **Note**: Unlike {@link drawSVGElem}, this method can be used even if `sanitize` is `true`.
+ * In this case, it's the responsibility of `callback` to ensure that everything added
+ * to `parent` is safe to render.
+ */
+ public drawWithSVGParent(
+ callback: (parent: SVGGElement, context: DrawWithSVGParentContext) => void,
+ ) {
+ const parent = document.createElementNS(svgNameSpace, 'g');
+ this.transformFrom(Mat33.identity, parent, true);
+ callback(parent, { sanitize: this.sanitize });
+ this.elem.appendChild(parent);
+ this.objectElems?.push(parent);
+ }
+
public isTooSmallToRender(_rect: Rect2): boolean {
return false;
}
diff --git a/packages/js-draw/src/rendering/renderers/TextOnlyRenderer.test.ts b/packages/js-draw/src/rendering/renderers/TextOnlyRenderer.test.ts
index 28d869674..21fbdeeae 100644
--- a/packages/js-draw/src/rendering/renderers/TextOnlyRenderer.test.ts
+++ b/packages/js-draw/src/rendering/renderers/TextOnlyRenderer.test.ts
@@ -16,7 +16,7 @@ describe('TextOnlyRenderer', () => {
base64Url: '',
label: 'Testing...',
});
- editor.dispatch(editor.image.addElement(image));
+ editor.dispatch(editor.image.addComponent(image));
const textRenderer = new TextOnlyRenderer(editor.viewport, editor.localization);
editor.image.render(textRenderer, editor.viewport);
diff --git a/packages/js-draw/src/toolbar/widgets/DocumentPropertiesWidget.ts b/packages/js-draw/src/toolbar/widgets/DocumentPropertiesWidget.ts
index 7d4d998e0..b9f021ab7 100644
--- a/packages/js-draw/src/toolbar/widgets/DocumentPropertiesWidget.ts
+++ b/packages/js-draw/src/toolbar/widgets/DocumentPropertiesWidget.ts
@@ -82,7 +82,7 @@ export default class DocumentPropertiesWidget extends BaseWidget {
private setBackgroundType(backgroundType: BackgroundType): SerializableCommand {
const prevBackgroundColor = this.editor.estimateBackgroundColor();
const newBackground = new BackgroundComponent(backgroundType, prevBackgroundColor);
- const addBackgroundCommand = this.editor.image.addElement(newBackground);
+ const addBackgroundCommand = this.editor.image.addComponent(newBackground);
return uniteCommands([this.removeBackgroundComponents(), addBackgroundCommand]);
}
diff --git a/packages/js-draw/src/toolbar/widgets/InsertImageWidget/InsertImageWidget.test.ts b/packages/js-draw/src/toolbar/widgets/InsertImageWidget/InsertImageWidget.test.ts
index d06b43b50..70449d844 100644
--- a/packages/js-draw/src/toolbar/widgets/InsertImageWidget/InsertImageWidget.test.ts
+++ b/packages/js-draw/src/toolbar/widgets/InsertImageWidget/InsertImageWidget.test.ts
@@ -28,7 +28,7 @@ describe('InsertImageWidget/index', () => {
image: imageElem,
base64Url: '',
});
- editor.dispatch(editor.image.addElement(imageComponent));
+ editor.dispatch(editor.image.addComponent(imageComponent));
const selectionTool = editor.toolController.getMatchingTools(SelectionTool)[0];
selectionTool.setSelection([imageComponent]);
diff --git a/packages/js-draw/src/toolbar/widgets/InsertImageWidget/InsertImageWidget.ts b/packages/js-draw/src/toolbar/widgets/InsertImageWidget/InsertImageWidget.ts
index 025f15ac7..51a4e5a0e 100644
--- a/packages/js-draw/src/toolbar/widgets/InsertImageWidget/InsertImageWidget.ts
+++ b/packages/js-draw/src/toolbar/widgets/InsertImageWidget/InsertImageWidget.ts
@@ -366,7 +366,7 @@ export default class InsertImageWidget extends BaseWidget {
const commands: Command[] = [];
for (const component of newComponents) {
commands.push(
- EditorImage.addElement(component),
+ EditorImage.addComponent(component),
component.transformBy(originalTransform.rightMul(widthAdjustTransform)),
component.setZIndex(editingImage.getZIndex()),
);
diff --git a/packages/js-draw/src/tools/Eraser.test.ts b/packages/js-draw/src/tools/Eraser.test.ts
index 9eff47ed9..17b8b906c 100644
--- a/packages/js-draw/src/tools/Eraser.test.ts
+++ b/packages/js-draw/src/tools/Eraser.test.ts
@@ -95,7 +95,7 @@ describe('Eraser', () => {
// Add to the image
expect(editor.image.getAllElements()).toHaveLength(0);
- editor.dispatch(EditorImage.addElement(unerasableObj));
+ editor.dispatch(EditorImage.addComponent(unerasableObj));
expect(editor.image.getAllElements()).toHaveLength(1);
const eraser = selectEraser(editor);
diff --git a/packages/js-draw/src/tools/Eraser.ts b/packages/js-draw/src/tools/Eraser.ts
index d8a6192bd..8400dc4b6 100644
--- a/packages/js-draw/src/tools/Eraser.ts
+++ b/packages/js-draw/src/tools/Eraser.ts
@@ -191,7 +191,7 @@ export default class Eraser extends BaseTool {
const region = Rect2.union(line.bbox, eraserRect);
const intersectingElems = this.editor.image
- .getElementsIntersectingRegion(region)
+ .getComponentsIntersecting(region)
.filter((component) => {
return component.intersects(line) || component.intersectsRect(eraserRect);
});
@@ -239,7 +239,7 @@ export default class Eraser extends BaseTool {
}
const eraseCommand = new Erase(toErase);
- const newAddCommands = toAdd.map((elem) => EditorImage.addElement(elem));
+ const newAddCommands = toAdd.map((elem) => EditorImage.addComponent(elem));
eraseCommand.apply(this.editor);
newAddCommands.forEach((command) => command.apply(this.editor));
@@ -309,7 +309,7 @@ export default class Eraser extends BaseTool {
}
}
- commands.push(...[...this.toAdd].map((a) => EditorImage.addElement(a)));
+ commands.push(...[...this.toAdd].map((a) => EditorImage.addComponent(a)));
this.addCommands = [];
}
diff --git a/packages/js-draw/src/tools/FindTool.test.ts b/packages/js-draw/src/tools/FindTool.test.ts
index 9570e1a5c..525aab3cb 100644
--- a/packages/js-draw/src/tools/FindTool.test.ts
+++ b/packages/js-draw/src/tools/FindTool.test.ts
@@ -38,7 +38,7 @@ describe('FindTool', () => {
// Add some text to the image
const style = { size: 12, fontFamily: 'serif', renderingStyle: { fill: Color4.red } };
const text = TextComponent.fromLines(['test'], Mat33.scaling2D(0.01), style);
- void editor.image.addElement(text).apply(editor);
+ void editor.image.addComponent(text).apply(editor);
// Should focus the search input
const searchInput = document.querySelector(':focus')!;
diff --git a/packages/js-draw/src/tools/PasteHandler.test.ts b/packages/js-draw/src/tools/PasteHandler.test.ts
index a11f89b1b..a3cb8daf8 100644
--- a/packages/js-draw/src/tools/PasteHandler.test.ts
+++ b/packages/js-draw/src/tools/PasteHandler.test.ts
@@ -2,9 +2,14 @@ import { InputEvtType, PasteEvent } from '../inputEvents';
import TextComponent from '../components/TextComponent';
import createEditor from '../testing/createEditor';
import PasteHandler from './PasteHandler';
+import { EditorSettings } from '../Editor';
+import Stroke from '../components/Stroke';
+import { Color4 } from '@js-draw/math';
+import { SVGLoaderPlugin } from '../SVGLoader/SVGLoader';
+import { EditorImage } from '../lib';
-const createTestEditor = () => {
- const editor = createEditor();
+const createTestEditor = (settigs?: Partial) => {
+ const editor = createEditor(settigs);
const pasteTool = editor.toolController.getMatchingTools(PasteHandler)[0];
return {
editor,
@@ -17,6 +22,13 @@ const createTestEditor = () => {
};
};
+const textFromImage = (image: EditorImage) => {
+ return image
+ .getAllElements()
+ .filter((elem) => elem instanceof TextComponent)
+ .map((elem) => elem.getText());
+};
+
describe('PasteHandler', () => {
test('should interpret non-SVG text/plain data as a text component', async () => {
const { editor, testPaste } = createTestEditor();
@@ -27,10 +39,7 @@ describe('PasteHandler', () => {
mime: 'text/plain',
});
- const allText = editor.image
- .getAllElements()
- .filter((elem) => elem instanceof TextComponent)
- .map((elem) => elem.getText());
+ const allText = textFromImage(editor.image);
expect(allText).toEqual(['Test text/plain']);
});
@@ -43,10 +52,33 @@ describe('PasteHandler', () => {
mime: 'text/plain',
});
- const allText = editor.image
- .getAllElements()
- .filter((elem) => elem instanceof TextComponent)
- .map((elem) => elem.getText());
+ const allText = textFromImage(editor.image);
expect(allText).toEqual(['Test']);
});
+
+ test('should allow processing pasted data with SVG loader plugins', async () => {
+ const plugin: SVGLoaderPlugin = {
+ async visit(node, loader) {
+ loader.addComponent(Stroke.fromFilled('m0,0 l10,10 z', Color4.red));
+ return true;
+ },
+ };
+
+ const { editor, testPaste } = createTestEditor({
+ svg: {
+ loaderPlugins: [plugin],
+ },
+ });
+
+ await testPaste({
+ kind: InputEvtType.PasteEvent,
+ data: '',
+ mime: 'text/plain',
+ });
+
+ const allText = textFromImage(editor.image);
+ // Should have added all components as strokes instead of text.
+ expect(allText).toEqual([]);
+ expect(editor.image.getAllElements().filter((comp) => comp instanceof Stroke)).toHaveLength(1);
+ });
});
diff --git a/packages/js-draw/src/tools/PasteHandler.ts b/packages/js-draw/src/tools/PasteHandler.ts
index 18084af2f..743249f0b 100644
--- a/packages/js-draw/src/tools/PasteHandler.ts
+++ b/packages/js-draw/src/tools/PasteHandler.ts
@@ -86,7 +86,10 @@ export default class PasteHandler extends BaseTool {
private async doSVGPaste(data: string) {
this.editor.showLoadingWarning(0);
try {
- const loader = SVGLoader.fromString(data, true);
+ const loader = SVGLoader.fromString(data, {
+ sanitize: true,
+ plugins: this.editor.getCurrentSettings().svg?.loaderPlugins ?? [],
+ });
const components: AbstractComponent[] = [];
await loader.start(
diff --git a/packages/js-draw/src/tools/Pen.ts b/packages/js-draw/src/tools/Pen.ts
index 3142da4ad..c3bc8a6ca 100644
--- a/packages/js-draw/src/tools/Pen.ts
+++ b/packages/js-draw/src/tools/Pen.ts
@@ -274,7 +274,7 @@ export default class Pen extends BaseTool {
}
const canFlatten = true;
- const action = EditorImage.addElement(stroke, canFlatten);
+ const action = EditorImage.addComponent(stroke, canFlatten);
this.editor.dispatch(action);
} else {
console.warn('Pen: Not adding empty stroke', stroke, 'to the canvas.');
diff --git a/packages/js-draw/src/tools/SelectionTool/Selection.ts b/packages/js-draw/src/tools/SelectionTool/Selection.ts
index 8e1286c42..1519d6205 100644
--- a/packages/js-draw/src/tools/SelectionTool/Selection.ts
+++ b/packages/js-draw/src/tools/SelectionTool/Selection.ts
@@ -230,7 +230,7 @@ export default class Selection {
const selectedBottommostZIndex = this.selectedElems[0].getZIndex();
- const visibleObjects = this.editor.image.getElementsIntersectingRegion(this.region);
+ const visibleObjects = this.editor.image.getComponentsIntersecting(this.region);
const topMostVisibleZIndex =
visibleObjects[visibleObjects.length - 1]?.getZIndex() ?? selectedBottommostZIndex;
const deltaZIndex = topMostVisibleZIndex + 1 - selectedBottommostZIndex;
@@ -272,7 +272,7 @@ export default class Selection {
/** Sends all selected elements to the bottom of the visible image. */
public sendToBack() {
- const visibleObjects = this.editor.image.getElementsIntersectingRegion(
+ const visibleObjects = this.editor.image.getComponentsIntersecting(
this.editor.viewport.visibleRect,
);
@@ -561,7 +561,7 @@ export default class Selection {
// If we're making things visible and the selected object wasn't previously
// visible,
else if (!parent && this.removedFromImage[elem.getId()]) {
- EditorImage.addElement(elem).apply(this.editor);
+ EditorImage.addComponent(elem).apply(this.editor);
this.removedFromImage[elem.getId()] = false;
delete this.removedFromImage[elem.getId()];
@@ -758,7 +758,7 @@ export default class Selection {
// With the transformation applied, create the duplicates
command = uniteCommands(
this.selectedElems.map((elem) => {
- return EditorImage.addElement(elem.clone());
+ return EditorImage.addComponent(elem.clone());
}),
);
diff --git a/packages/js-draw/src/tools/SelectionTool/SelectionBuilders/LassoSelectionBuilder.ts b/packages/js-draw/src/tools/SelectionTool/SelectionBuilders/LassoSelectionBuilder.ts
index f919de097..3c2d83965 100644
--- a/packages/js-draw/src/tools/SelectionTool/SelectionBuilders/LassoSelectionBuilder.ts
+++ b/packages/js-draw/src/tools/SelectionTool/SelectionBuilders/LassoSelectionBuilder.ts
@@ -47,7 +47,7 @@ export default class LassoSelectionBuilder extends SelectionBuilder {
public resolveInternal(image: EditorImage) {
const path = this.previewPath();
const lines = path.polylineApproximation();
- const candidates = image.getElementsIntersectingRegion(path.bbox);
+ const candidates = image.getComponentsIntersecting(path.bbox);
const componentIsInSelection = (component: AbstractComponent) => {
if (path.closedContainsRect(component.getExactBBox())) {
diff --git a/packages/js-draw/src/tools/SelectionTool/SelectionBuilders/RectSelectionBuilder.ts b/packages/js-draw/src/tools/SelectionTool/SelectionBuilders/RectSelectionBuilder.ts
index 0f446bb49..977cf6587 100644
--- a/packages/js-draw/src/tools/SelectionTool/SelectionBuilders/RectSelectionBuilder.ts
+++ b/packages/js-draw/src/tools/SelectionTool/SelectionBuilders/RectSelectionBuilder.ts
@@ -23,7 +23,7 @@ export default class RectSelectionBuilder extends SelectionBuilder {
}
public resolveInternal(image: EditorImage) {
- return image.getElementsIntersectingRegion(this.rect).filter((element) => {
+ return image.getComponentsIntersecting(this.rect).filter((element) => {
// Filter out the case where the selection rectangle is completely contained
// within the element (and does not intersect it).
// This is useful, for example, if a very large stroke is used as the background
diff --git a/packages/js-draw/src/tools/SelectionTool/SelectionBuilders/SelectionBuilder.ts b/packages/js-draw/src/tools/SelectionTool/SelectionBuilders/SelectionBuilder.ts
index 9649711b2..e81dbca9d 100644
--- a/packages/js-draw/src/tools/SelectionTool/SelectionBuilders/SelectionBuilder.ts
+++ b/packages/js-draw/src/tools/SelectionTool/SelectionBuilders/SelectionBuilder.ts
@@ -37,7 +37,7 @@ export default abstract class SelectionBuilder {
const searchRegionSize = viewport.visibleRect.maxDimension / 200;
const minSizeBox = path.bbox.grownBy(searchRegionSize);
- components = image.getElementsIntersectingRegion(minSizeBox).filter((component) => {
+ components = image.getComponentsIntersecting(minSizeBox).filter((component) => {
return minSizeBox.containsRect(component.getBBox()) || component.intersectsRect(minSizeBox);
});
components = filterComponents(components);
diff --git a/packages/js-draw/src/tools/SelectionTool/SelectionTool.selecting.test.ts b/packages/js-draw/src/tools/SelectionTool/SelectionTool.selecting.test.ts
index 7742903af..185b47ba2 100644
--- a/packages/js-draw/src/tools/SelectionTool/SelectionTool.selecting.test.ts
+++ b/packages/js-draw/src/tools/SelectionTool/SelectionTool.selecting.test.ts
@@ -765,7 +765,7 @@ describe('SelectionTool.selecting', () => {
}),
]);
- await editor.dispatch(editor.image.addElement(testStroke));
+ await editor.dispatch(editor.image.addComponent(testStroke));
const selectionTool = editor.toolController.getMatchingTools(SelectionTool)[0];
selectionTool.setEnabled(true);
diff --git a/packages/js-draw/src/tools/SelectionTool/SelectionTool.test.ts b/packages/js-draw/src/tools/SelectionTool/SelectionTool.test.ts
index e2b10960d..7650e0855 100644
--- a/packages/js-draw/src/tools/SelectionTool/SelectionTool.test.ts
+++ b/packages/js-draw/src/tools/SelectionTool/SelectionTool.test.ts
@@ -23,7 +23,7 @@ const createSquareStroke = (size: number = 1) => {
fill: Color4.blue,
}),
]);
- const addTestStrokeCommand = EditorImage.addElement(testStroke);
+ const addTestStrokeCommand = EditorImage.addComponent(testStroke);
return { testStroke, addTestStrokeCommand };
};
diff --git a/packages/js-draw/src/tools/SoundUITool.ts b/packages/js-draw/src/tools/SoundUITool.ts
index e6be36c5b..610ca544b 100644
--- a/packages/js-draw/src/tools/SoundUITool.ts
+++ b/packages/js-draw/src/tools/SoundUITool.ts
@@ -208,7 +208,7 @@ export default class SoundUITool extends BaseTool {
const pointerMotionLine = new LineSegment2(this.lastPointerPos, current.canvasPos);
const collisions = this.editor.image
- .getElementsIntersectingRegion(pointerMotionLine.bbox)
+ .getComponentsIntersecting(pointerMotionLine.bbox)
.filter((component) => component.intersects(pointerMotionLine));
this.lastPointerPos = current.canvasPos;
diff --git a/packages/js-draw/src/tools/TextTool.ts b/packages/js-draw/src/tools/TextTool.ts
index 024fd402d..f89e61650 100644
--- a/packages/js-draw/src/tools/TextTool.ts
+++ b/packages/js-draw/src/tools/TextTool.ts
@@ -143,7 +143,7 @@ export default class TextTool extends BaseTool {
this.textStyle,
);
- const action = EditorImage.addElement(textComponent);
+ const action = EditorImage.addComponent(textComponent);
if (this.removeExistingCommand) {
// Unapply so that `removeExistingCommand` can be added to the undo stack.
this.removeExistingCommand.unapply(this.editor);
@@ -278,7 +278,7 @@ export default class TextTool extends BaseTool {
canvasPos.minus(halfTestRegionSize),
canvasPos.plus(halfTestRegionSize),
);
- const targetNodes = this.editor.image.getElementsIntersectingRegion(testRegion);
+ const targetNodes = this.editor.image.getComponentsIntersecting(testRegion);
let targetTextNodes = targetNodes.filter((node) => node instanceof TextComponent);
// Don't try to edit text nodes that contain the viewport (this allows us
diff --git a/packages/js-draw/src/tools/UndoRedoShortcut.test.ts b/packages/js-draw/src/tools/UndoRedoShortcut.test.ts
index 4e80ee206..a55959723 100644
--- a/packages/js-draw/src/tools/UndoRedoShortcut.test.ts
+++ b/packages/js-draw/src/tools/UndoRedoShortcut.test.ts
@@ -11,7 +11,7 @@ describe('UndoRedoShortcut', () => {
const testStroke = new Stroke([
pathToRenderable(Path.fromString('M0,0L10,10'), { fill: Color4.red }),
]);
- const addTestStrokeCommand = EditorImage.addElement(testStroke);
+ const addTestStrokeCommand = EditorImage.addComponent(testStroke);
it('ctrl+z should undo', () => {
const editor = createEditor();