Skip to content

Commit

Permalink
feat(editor): Simplify API for anchoring elements above the canvas (#91)
Browse files Browse the repository at this point in the history
* feat(editor): Simplify API for anchoring elements above the canvas

* Remove commented out code

* Add regression test

* Commenting

* Fix scroll correction logic

* Fix formatting
  • Loading branch information
personalizedrefrigerator authored Dec 4, 2024
1 parent a30a435 commit 6bac2a3
Show file tree
Hide file tree
Showing 6 changed files with 252 additions and 54 deletions.
66 changes: 66 additions & 0 deletions docs/doc-pages/guides/positioning-an-element-above-the-editor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
title: Positioning an element above the editor
category: Guides
---

# Positioning an element above the editor

The built-in `TextTool` tool renders a `<textarea>` above the editor at a particular location on the canvas. This guide demonstrates how to position custom HTML elements above the editor.

Related APIs:

- {@link js-draw!Editor.anchorElementToCanvas | Editor.anchorElementToCanvas}: Used to listen for changes in the viewport.
- {@link @js-draw/math!Mat33.translation | Mat33.translation}: Lets us specify where to put the element.

## Getting started: Creating the editor

Let's start by creating an editor with a toolbar:

```ts,runnable
import { Editor } from 'js-draw';
// Adds the editor to document.body:
const editor = new Editor(document.body); // 1
editor.addToolbar();
```

Running the above example should display an empty editor with the default toolbar.

## Creating an element to be positioned

We'll start by creating a `<button>` to position above the editor. Unlike the {@link js-draw!TextTool | TextTool}, we're using a `<button>` instead of a `<textarea>`:

```ts,runnable
---use-previous---
---visible---
const button = document.createElement('button');
button.textContent = 'Example!';
button.style.position = 'absolute';
```

## Attaching the element

Finally, the element can be attached to the editor using `anchorElementToCanvas`:

```ts,runnable
---use-previous---
---visible---
import { Mat33, Vec2 } from '@js-draw/math';
const positioning = Mat33.translation(Vec2.of(10, 20));
const anchor = editor.anchorElementToCanvas(button, positioning);
```

## Unattaching the element

Later, the element can be removed from the editor with `anchor.remove()`. Let's do this when the button is clicked:

```ts,runnable
---use-previous---
---visible---
import { Mat33, Vec2 } from '@js-draw/math';
button.onclick = () => {
anchor.remove();
};
```
31 changes: 31 additions & 0 deletions packages/js-draw/src/Editor.scss
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,37 @@
z-index: 5;
}

// See Editor.anchorElementToCanvas
.imageEditorContainer .anchored-element-overlay {
overflow: visible;
height: 0;

> .content-wrapper {
width: var(--editor-current-display-width-px);
height: var(--editor-current-display-height-px);
overflow: hidden;
// Display 'position: absolute' children relative to this.
position: relative;

// Disable pointer events: If the parent (or the container) has
// captured pointers and the container is removed, this prevents
// us from receiving the following events (e.g. in Firefox).
pointer-events: none;

> .content {
position: absolute;
left: var(--position-x);
top: var(--position-y);
transform: scale(var(--scale)) rotate(var(--rotation));
transform-origin: left top;
margin: 0;

// We *do* want pointer events for the positioned content.
pointer-events: all;
}
}
}

// TODO: Apply this change during a future major release.
// So as not to change the position of other overlays, all overlays should have
// 0 height.
Expand Down
68 changes: 68 additions & 0 deletions packages/js-draw/src/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1323,6 +1323,74 @@ export class Editor {
};
}

/**
* Anchors the given `element` to the canvas with a given position/transformation in canvas space.
*/
public anchorElementToCanvas(
element: HTMLElement,
canvasTransform: Mat33 | ReactiveValue<Mat33>,
) {
if (canvasTransform instanceof Mat33) {
canvasTransform = ReactiveValue.fromImmutable(canvasTransform);
}

// The element hierarchy looks like this:
// overlay > contentWrapper > content
//
// Both contentWrapper and overlay are present to:
// 1. overlay: Positions the content at the top left of the viewport. The overlay
// has `height: 0` to allow other overlays to also be aligned with the viewport's
// top left.
// 2. contentWrapper: Has the same width/height as the editor's visible region and
// has `overflow: hidden`. This prevents the anchored element from being visible
// when not in the visible region of the canvas.

const overlay = document.createElement('div');
overlay.classList.add('anchored-element-overlay');

const contentWrapper = document.createElement('div');
contentWrapper.classList.add('content-wrapper');
element.classList.add('content');

// Updates CSS variables that specify the position/rotation/scale of the content.
const updateElementPositioning = () => {
const transform = canvasTransform.get();
const canvasRotation = transform.transformVec3(Vec2.unitX).angle();
const screenRotation = canvasRotation + this.viewport.getRotationAngle();
const screenTransform = this.viewport.canvasToScreenTransform.rightMul(canvasTransform.get());
overlay.style.setProperty('--full-transform', screenTransform.toCSSMatrix());

const translation = screenTransform.transformVec2(Vec2.zero);
overlay.style.setProperty('--position-x', `${translation.x}px`);
overlay.style.setProperty('--position-y', `${translation.y}px`);

overlay.style.setProperty('--rotation', `${(screenRotation * 180) / Math.PI}deg`);
overlay.style.setProperty('--scale', `${screenTransform.getScaleFactor()}`);
};
updateElementPositioning();

// The anchored element needs to be updated both when the user moves the canvas
// and when the anchored element's transform changes.
const updateListener = canvasTransform.onUpdate(updateElementPositioning);
const viewportListener = this.notifier.on(
EditorEventType.ViewportChanged,
updateElementPositioning,
);

contentWrapper.appendChild(element);
overlay.appendChild(contentWrapper);
overlay.classList.add('overlay', 'js-draw-editor-overlay');
this.renderingRegion.insertAdjacentElement('afterend', overlay);

return {
remove: () => {
overlay.remove();
updateListener.remove();
viewportListener.remove();
},
};
}

/**
* Creates a CSS stylesheet with `content` and applies it to the document
* (and thus, to this editor).
Expand Down
2 changes: 2 additions & 0 deletions packages/js-draw/src/Viewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ export class Viewport {
}
};

/** Converts from canvas to screen coordinates */
private transform: Mat33;
/** Converts from screen to canvas coordinates */
private inverseTransform: Mat33;
private screenRect: Rect2;

Expand Down
25 changes: 24 additions & 1 deletion packages/js-draw/src/tools/TextTool.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import Editor from '../Editor';
import createEditor from '../testing/createEditor';
import sendPenEvent from '../testing/sendPenEvent';
import TextTool from './TextTool';
import { InputEvtType } from '../inputEvents';
import { Vec2 } from '@js-draw/math';

const getTextTool = (editor: Editor) => {
return editor.toolController.getMatchingTools(TextTool)[0];
};

describe('TextTool', () => {
test.each([
Expand All @@ -12,7 +20,22 @@ describe('TextTool', () => {
const editor = createEditor({
text: { fonts: editorFontSetting },
});
const textTool = editor.toolController.getMatchingTools(TextTool)[0];
const textTool = getTextTool(editor);
expect(textTool.getTextStyle().fontFamily).toBe(editorFontSetting?.[0] ?? 'sans-serif');
});

test('should create an edit box when the editor is clicked', () => {
const editor = createEditor();
const textTool = getTextTool(editor);
textTool.setEnabled(true);
const getTextEditor = () => {
return editor.getRootElement().querySelector('.textEditorOverlay textarea');
};
expect(getTextEditor()).toBeFalsy();

sendPenEvent(editor, InputEvtType.PointerDownEvt, Vec2.zero);
sendPenEvent(editor, InputEvtType.PointerUpEvt, Vec2.zero);

expect(getTextEditor()).toBeTruthy();
});
});
Loading

0 comments on commit 6bac2a3

Please sign in to comment.