diff --git a/docs/doc-pages/pages/guides.md b/docs/doc-pages/pages/guides.md index 4afde4a1..e8bdf987 100644 --- a/docs/doc-pages/pages/guides.md +++ b/docs/doc-pages/pages/guides.md @@ -4,8 +4,7 @@ category: Guides children: - ./guides/setup.md - ./guides/writing-a-theme.md - - ./guides/strokes.md - - ./guides/custom-components.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 00000000..a9d6d6ab --- /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 00000000..70bc2248 --- /dev/null +++ b/docs/doc-pages/pages/guides/components/custom-components.md @@ -0,0 +1,258 @@ +--- +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 custom `ImageStatus` component that shows information about the current content of the image. + +## 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 = 'image-info'; +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 + } + + protected 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 = 'image-info'; +class ImageInfoComponent 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. + protected 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 ImageInfoComponent(); + } + + 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 ImageInfoComponent(); +}); + +// Add the component +editor.dispatch(editor.image.addComponent(new ImageInfoComponent())); +``` + +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 `ImageInfoComponent`, 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 = 'image-info'; +class ImageInfoComponent 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()); + } + + protected 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 ImageInfoComponent(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 ImageInfoComponent(Mat33.identity); +}); + +// Add the component +const initialTransform = Mat33.identity; +editor.dispatch(editor.image.addComponent(new ImageInfoComponent(initialTransform))); +``` diff --git a/docs/doc-pages/pages/guides/strokes.md b/docs/doc-pages/pages/guides/components/strokes.md similarity index 94% rename from docs/doc-pages/pages/guides/strokes.md rename to docs/doc-pages/pages/guides/components/strokes.md index 1f69ffa5..a2c84f64 100644 --- a/docs/doc-pages/pages/guides/strokes.md +++ b/docs/doc-pages/pages/guides/components/strokes.md @@ -173,4 +173,7 @@ const eraseCommand = new Erase(components); editor.dispatch(eraseCommand); ``` -Try replacing {@link js-draw!Editor.dispatch | editor.dispatch(eraseCommand)} with `eraseCommand.apply(editor)`. What's the effect on the undo/redo behavior? +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/docs/doc-pages/pages/guides/custom-components.md b/docs/doc-pages/pages/guides/custom-components.md deleted file mode 100644 index 6ada290f..00000000 --- a/docs/doc-pages/pages/guides/custom-components.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -title: Custom components ---- - -# Custom components - -It's possible to create custom subclasses of {@link js-draw!AbstractComponent | AbstractComponent}. This guide shows how to create a custom `ImageStatus` component that shows information about the current content of the image. - -## 1. Setup - -```ts,runnable -import { Editor, AbstractComponent } from 'js-draw'; -const editor = new Editor(document.body); -``` - -## 2. Subclass `AbstractComponent` - -```ts,runnable ----use-previous--- ----visible--- -import { LineSegment2, Mat33, Rect2, Color4 } from '@js-draw/math'; -import AbstractRenderer from '../rendering/renderers/AbstractRenderer'; - -const componentId = 'image-info'; -export default class ImageInfoComponent extends AbstractComponent { - // The bounding box of the component -- REQUIRED - 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.drawRect(this.contentBBox, 3, { fill: 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. - protected intersects(line: LineSegment2) { - // For now, return true if the line intersects the bounding box. - const intersectionCount = this.contentBBox.intersectsLineSegment(line).length; - return intersectionCount > 0; - } - - protected applyTransformation(transformation: Mat33): void { - this.contentBBox = this.contentBBox.transformedBoundingBox(transformation); - } - - protected createClone(): AbstractComponent { - const clone = new ImageInfoComponent(); - // Set the clone's initial position to the same place as this. - // For now, we use contentBBox for position information. - clone.contentBBox = this.contentBBox; - return clone; - } - - public description(): string { - return 'some description here (for accessibility tools)'; - } - - 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 ImageInfoComponent(); -}); - -// Add the component -editor.dispatch(editor.image.addComponent(new ImageInfoComponent())); -```