diff --git a/demo/arrows.html b/demo/arrows.html index 914f331..aa6aa63 100644 --- a/demo/arrows.html +++ b/demo/arrows.html @@ -25,11 +25,15 @@ position: absolute; inset: 0 0 0 0; } + + #box2 { + max-width: 10ch; + } - Hello World + Hello World diff --git a/demo/chains-of-thought/index.html b/demo/chains-of-thought/index.html index e85882f..4832094 100644 --- a/demo/chains-of-thought/index.html +++ b/demo/chains-of-thought/index.html @@ -59,10 +59,14 @@ border: solid 1px light-dark(rgb(118, 118, 118), rgb(133, 133, 133)); display: block; height: 100%; - padding: 0.5em 0.25em; position: relative; text-align: center; width: 100%; + width: 30ch; + + > [name='text'] { + padding: 0.5em 0.25em; + } > button[name='delete'] { font-size: 2rem; diff --git a/demo/chains-of-thought/main.ts b/demo/chains-of-thought/main.ts index 99fc521..8225c91 100644 --- a/demo/chains-of-thought/main.ts +++ b/demo/chains-of-thought/main.ts @@ -16,7 +16,7 @@ class SpatialThought extends HTMLElement { } #deleteButton = this.querySelector('button[name="delete"]') as HTMLButtonElement; - #text = this.querySelector('span[name="text"]') as HTMLSpanElement; + #text = this.querySelector('[name="text"]') as HTMLElement; #geometry = this.parentElement as SpatialGeometry; @@ -27,7 +27,7 @@ class SpatialThought extends HTMLElement { } get text() { - return this.#text.innerText; + return this.#text.innerHTML; } handleEvent(event: PointerEvent): void { @@ -72,9 +72,9 @@ function parseHTML(html: string): Element { } function renderThought({ id, x, y, text }: Thought) { - return html` - - ${text} + return html` + +
${text}
`; diff --git a/demo/perf.html b/demo/perf.html new file mode 100644 index 0000000..a682195 --- /dev/null +++ b/demo/perf.html @@ -0,0 +1,54 @@ + + + + + + Perf + + + + + + diff --git a/demo/vite.config.ts b/demo/vite.config.ts index 13ba4fd..74776d2 100644 --- a/demo/vite.config.ts +++ b/demo/vite.config.ts @@ -12,6 +12,9 @@ export default defineConfig({ acc[file.replace('.html', '')] = resolve(__dirname, file); return acc; }, {} as Record), + // input: { + // thoughts: resolve(__dirname, 'chains-of-thought/index.html'), + // }, }, modulePreload: { polyfill: false, diff --git a/src/canvas/spatial-geometry.ts b/src/canvas/spatial-geometry.ts index 56bca03..f1166c8 100644 --- a/src/canvas/spatial-geometry.ts +++ b/src/canvas/spatial-geometry.ts @@ -1,3 +1,48 @@ +export type ResizeObserverEntryCallback = (entry: ResizeObserverEntry) => void; + +class ResizeObserverManager { + #elementMap = new WeakMap>(); + #elementEntry = new WeakMap(); + + #vo = new ResizeObserver((entries) => { + for (const entry of entries) { + this.#elementEntry.set(entry.target, entry); + this.#elementMap.get(entry.target)?.forEach((callback) => callback(entry)); + } + }); + + observe(target: Element, callback: ResizeObserverEntryCallback): void { + let callbacks = this.#elementMap.get(target); + + if (callbacks === undefined) { + this.#vo.observe(target); + this.#elementMap.set(target, (callbacks = new Set())); + } else { + const entry = this.#elementEntry.get(target); + if (entry) { + callback(entry); + } + } + + callbacks.add(callback); + } + + unobserve(target: Element, callback: ResizeObserverEntryCallback): void { + let callbacks = this.#elementMap.get(target); + + if (callbacks === undefined) return; + + callbacks.delete(callback); + + if (callbacks.size === 0) { + this.#vo.unobserve(target); + this.#elementMap.delete(target); + } + } +} + +const resizeObserver = new ResizeObserverManager(); + export type Shape = 'rectangle' | 'circle' | 'triangle'; export type MoveEventDetail = { movementX: number; movementY: number }; @@ -24,6 +69,8 @@ export class RotateEvent extends CustomEvent { } } +export type Dimension = number | 'auto'; + const styles = new CSSStyleSheet(); styles.replaceSync(` :host { @@ -61,57 +108,49 @@ styles.replaceSync(` user-select: none; } -:host(:not(:focus-within)) [part^="resize"], :host(:not(:focus-within)) [part="rotate"] { - opacity: 0; -} - -[part^="resize"] { +[part="resize-nw"], +[part="resize-ne"], +[part="resize-se"], +[part="resize-sw"] { display: block; position: absolute; box-sizing: border-box; padding: 0; background: hsl(210, 20%, 98%); z-index: calc(infinity); + width: 13px; + aspect-ratio: 1; + transform: translate(-50%, -50%); + border: 1.5px solid hsl(214, 84%, 56%); + border-radius: 2px; +} - - &[part="resize-nw"], - &[part="resize-ne"], - &[part="resize-se"], - &[part="resize-sw"] { - width: 13px; - aspect-ratio: 1; - transform: translate(-50%, -50%); - border: 1.5px solid hsl(214, 84%, 56%); - border-radius: 2px; - } - - &[part="resize-nw"] { - top: 0; - left: 0; - } +[part="resize-nw"] { + top: 0; + left: 0; +} - &[part="resize-ne"] { - top: 0; - left: 100%; - } +[part="resize-ne"] { + top: 0; + left: 100%; +} - &[part="resize-se"] { - top: 100%; - left: 100%; - } +[part="resize-se"] { + top: 100%; + left: 100%; +} - &[part="resize-sw"] { - top: 100%; - left: 0; - } +[part="resize-sw"] { + top: 100%; + left: 0; +} - &[part="resize-nw"], &[part="resize-se"] { - cursor: var(--fc-nwse-resize, nwse-resize) - } +[part="resize-nw"], [part="resize-se"] { + cursor: var(--fc-nwse-resize, nwse-resize) +} - &[part="resize-ne"], &[part="resize-sw"] { - cursor: var(--fc-nesw-resize, nesw-resize) - } +[part="resize-ne"], [part="resize-sw"] { + cursor: var(--fc-nesw-resize, nesw-resize) } [part="rotate"] { @@ -129,7 +168,13 @@ styles.replaceSync(` left: 50%; translate: -50% -150%; cursor: url("data:image/svg+xml,") 16 16, pointer; -}`); +} + +:host(:not(:focus-within)) [part^="resize"], :host(:not(:focus-within)) [part="rotate"] { + opacity: 0; + cursor: default; +} +`); declare global { interface HTMLElementTagNameMap { @@ -147,6 +192,94 @@ export class SpatialGeometry extends HTMLElement { #internals = this.attachInternals(); + #type = (this.getAttribute('type') || 'rectangle') as Shape; + get type(): Shape { + return this.#type; + } + + set type(type: Shape) { + this.setAttribute('type', type); + } + + #previousX = 0; + #x = Number(this.getAttribute('x')) || 0; + get x() { + return this.#x; + } + + set x(x) { + this.#previousX = this.#x; + this.#x = x; + this.#requestUpdate('x'); + } + + #previousY = 0; + #y = Number(this.getAttribute('y')) || 0; + get y() { + return this.#y; + } + + set y(y) { + this.#previousY = this.#y; + this.#y = y; + this.#requestUpdate('y'); + } + + #autoContentRect = this.getBoundingClientRect(); + + #previousWidth: Dimension = 0; + #width: Dimension = 0; + get width(): number { + if (this.#width === 'auto') { + return this.#autoContentRect.width; + } + return this.#width; + } + + set width(width: Dimension) { + if (width === 'auto') { + resizeObserver.observe(this, this.#onResize); + } else if (this.#width === 'auto' && this.#height !== 'auto') { + resizeObserver.unobserve(this, this.#onResize); + } + this.#previousWidth = this.#width; + this.#width = width; + this.#requestUpdate('width'); + } + + #previousHeight: Dimension = 0; + #height: Dimension = 0; + get height(): number { + if (this.#height === 'auto') { + return this.#autoContentRect.height; + } + return this.#height; + } + + set height(height: Dimension) { + if (height === 'auto') { + resizeObserver.observe(this, this.#onResize); + } else if (this.#height === 'auto' && this.#width !== 'auto') { + resizeObserver.unobserve(this, this.#onResize); + } + + this.#previousHeight = this.#height; + this.#height = height; + this.#requestUpdate('height'); + } + + #previousRotate = 0; + #rotate = Number(this.getAttribute('rotate')) || 0; + get rotate(): number { + return this.#rotate; + } + + set rotate(rotate: number) { + this.#previousRotate = this.#rotate; + this.#rotate = rotate; + this.#requestUpdate('rotate'); + } + constructor() { super(); @@ -164,75 +297,9 @@ export class SpatialGeometry extends HTMLElement { `; - } - #type = (this.getAttribute('type') || 'rectangle') as Shape; - get type(): Shape { - return this.#type; - } - - set type(type: Shape) { - this.setAttribute('type', type); - } - - #previousX = 0; - #x = Number(this.getAttribute('x')) || 0; - get x(): number { - return this.#x; - } - - set x(x: number) { - this.#previousX = this.#x; - this.#x = x; - this.#requestUpdate('x'); - } - - #previousY = 0; - #y = Number(this.getAttribute('y')) || 0; - get y(): number { - return this.#y; - } - - set y(y: number) { - this.#previousY = this.#y; - this.#y = y; - this.#requestUpdate('y'); - } - - #previousWidth = 0; - #width = Number(this.getAttribute('width')) || 1; - get width(): number { - return this.#width; - } - - set width(width: number) { - this.#previousWidth = this.#width; - this.#width = width; - this.#requestUpdate('width'); - } - - #previousHeight = 0; - #height = Number(this.getAttribute('height')) || 1; - get height(): number { - return this.#height; - } - - set height(height: number) { - this.#previousHeight = this.#height; - this.#height = height; - this.#requestUpdate('height'); - } - - #previousRotate = 0; - #rotate = Number(this.getAttribute('rotate')) || 0; - get rotate(): number { - return this.#rotate; - } - - set rotate(rotate: number) { - this.#previousRotate = this.#rotate; - this.#rotate = rotate; - this.#requestUpdate('rotate'); + this.height = Number(this.getAttribute('height')) || 'auto'; + this.width = Number(this.getAttribute('width')) || 'auto'; } connectedCallback() { @@ -311,8 +378,8 @@ export class SpatialGeometry extends HTMLElement { } if (part === 'rotate') { - const centerX = (this.#x + this.#width) / 2; - const centerY = (this.#y + this.#height) / 2; + const centerX = (this.#x + this.width) / 2; + const centerY = (this.#y + this.height) / 2; var newAngle = ((Math.atan2(event.clientY - centerY, event.clientX - centerX) + Math.PI / 2) * 180) / Math.PI; this.rotate = newAngle; return; @@ -386,17 +453,17 @@ export class SpatialGeometry extends HTMLElement { // Although the change in resize isn't useful inside this component, the outside world might find it helpful to calculate acceleration and other physics const notCancelled = this.dispatchEvent( new ResizeEvent({ - movementX: this.#width - this.#previousWidth, - movementY: this.#height - this.#previousHeight, + movementX: this.width - (this.#previousWidth === 'auto' ? 0 : this.#previousWidth), + movementY: this.height - (this.#previousHeight === 'auto' ? 0 : this.#previousHeight), }) ); if (notCancelled) { if (updatedProperties.has('width')) { - this.style.width = `${this.#width}px`; + this.style.width = this.#width === 'auto' ? '' : `${this.#width}px`; } if (updatedProperties.has('height')) { - this.style.height = `${this.#height}px`; + this.style.height = this.#height === 'auto' ? '' : `${this.#height}px`; } } else { // TODO: Revert changes to position too @@ -418,4 +485,26 @@ export class SpatialGeometry extends HTMLElement { } } } + + #onResize = (entry: ResizeObserverEntry) => { + const previousRect = this.#autoContentRect; + this.#autoContentRect = entry.contentRect; + + const notCancelled = this.dispatchEvent( + new ResizeEvent({ + movementX: this.width - (this.#previousWidth === 'auto' ? previousRect.width : this.#previousWidth), + movementY: this.height - (this.#previousHeight === 'auto' ? previousRect.height : this.#previousHeight), + }) + ); + + if (!notCancelled) { + if (this.#height === 'auto') { + this.height = previousRect?.height || 0; + } + + if (this.#width === 'auto') { + this.width = previousRect?.width || 0; + } + } + }; }