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 @@

js-draw
- API + API

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: +> +>
+> Bounding boxWhat'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(` + + test + test 2 + test 3 + +`); +``` + +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( + ` + + Testing... + Test 2... + Test 2... + + `, + { + 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: 'Test', + 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();