diff --git a/companion/lib/Controls/ControlTypes/Button/Layered.ts b/companion/lib/Controls/ControlTypes/Button/Layered.ts index 190d83bdd8..1b3e3addbc 100644 --- a/companion/lib/Controls/ControlTypes/Button/Layered.ts +++ b/companion/lib/Controls/ControlTypes/Button/Layered.ts @@ -236,6 +236,10 @@ export class ControlButtonLayered return this.drawing.updateOption(id, key, newVal) } + layeredStyleUpdateOptions(id: string, values: Record>): boolean { + return this.drawing.updateOptions(id, values) + } + layeredStyleUpdateFromLegacyProperties(diff: Partial): boolean { return this.drawing.updateFromLegacyProperties(diff, this.options.canModifyStyleInApis) } diff --git a/companion/lib/Controls/ControlTypes/Button/LayeredButtonStyleEditor.ts b/companion/lib/Controls/ControlTypes/Button/LayeredButtonStyleEditor.ts index 3607349348..8cc907fab4 100644 --- a/companion/lib/Controls/ControlTypes/Button/LayeredButtonStyleEditor.ts +++ b/companion/lib/Controls/ControlTypes/Button/LayeredButtonStyleEditor.ts @@ -204,9 +204,10 @@ export class LayeredButtonStyleEditor extends LayeredButtonDrawer { return true } - updateOption(id: string, key: string, newVal: ExpressionOrValue): boolean { - // Ignore fixed/structural properties, to avoid corrupting the layer model - if ( + // Fixed/structural properties, which must never be reassigned via the generic option setters as it + // would corrupt the layer model + static #isStructuralKey(key: string): boolean { + return ( key === 'id' || key === 'type' || key === 'name' || @@ -215,14 +216,29 @@ export class LayeredButtonStyleEditor extends LayeredButtonDrawer { key === 'connectionId' || key === 'elementId' ) - return false + } + + updateOption(id: string, key: string, newVal: ExpressionOrValue): boolean { + return this.updateOptions(id, { [key]: newVal }) + } + + /** + * Apply several option changes to an element as a single commit. Callers changing more than one key at + * once (eg a drag that moves and resizes) should use this rather than repeated {@link updateOption}, so + * the element is never persisted or redrawn in a partially-updated state. + */ + updateOptions(id: string, values: Record>): boolean { + const entries = Object.entries(values).filter(([key]) => !LayeredButtonStyleEditor.#isStructuralKey(key)) + if (entries.length === 0) return false const currentElementLocation = this.#findElementIndexAndParent(this.drawElementsList, null, id) if (!currentElementLocation) return false const entry = currentElementLocation.element as any - entry[key] = newVal + for (const [key, newVal] of entries) { + entry[key] = newVal + } this.elementConversionCache.queueInvalidate(id) this.#host.commitChange(true) diff --git a/companion/lib/Controls/ControlTypes/Button/Preset.ts b/companion/lib/Controls/ControlTypes/Button/Preset.ts index 230d51afb6..983141ea49 100644 --- a/companion/lib/Controls/ControlTypes/Button/Preset.ts +++ b/companion/lib/Controls/ControlTypes/Button/Preset.ts @@ -305,6 +305,13 @@ export class ControlButtonPreset throw new Error('ControlButtonPreset does not support mutations') } + /** + * Update several options on an element from the layered style + */ + layeredStyleUpdateOptions(_id: string, _values: Record>): boolean { + throw new Error('ControlButtonPreset does not support mutations') + } + /** * Update the style from legacy properties */ diff --git a/companion/lib/Controls/IControlFragments.ts b/companion/lib/Controls/IControlFragments.ts index 0c2c8c5f7e..ef82ff6366 100644 --- a/companion/lib/Controls/IControlFragments.ts +++ b/companion/lib/Controls/IControlFragments.ts @@ -80,6 +80,14 @@ export interface ControlWithLayeredStyle extends ControlBase { */ layeredStyleUpdateOption(id: string, key: string, value: ExpressionOrValue): boolean + /** + * Update several options on an element from the layered style, as a single commit + * @param id Element id to update + * @param values New ExpressionOrValue for each option key + * @returns true if any changes were made + */ + layeredStyleUpdateOptions(id: string, values: Record>): boolean + /** * Update the style from legacy properties * Future: Once the old button style is removed, this should be reworked to utilise the new style system better diff --git a/companion/lib/Controls/StylesTrpcRouter.ts b/companion/lib/Controls/StylesTrpcRouter.ts index 516ad70183..9d5f3852e0 100644 --- a/companion/lib/Controls/StylesTrpcRouter.ts +++ b/companion/lib/Controls/StylesTrpcRouter.ts @@ -126,5 +126,22 @@ export function createStylesTrpcRouter(controlsMap: Map return control.layeredStyleUpdateOption(input.elementId, input.key, input.value) }), + + updateOptions: publicProcedure + .input( + z.object({ + controlId: z.string(), + elementId: z.string(), + values: z.record(z.string(), ExpressionOrJsonValueSchema), + }) + ) + .mutation(async ({ input }) => { + const control = controlsMap.get(input.controlId) + if (!control) return false + + if (!control.supportsLayeredStyle) throw new Error(`Control "${input.controlId}" does not support layer styles`) + + return control.layeredStyleUpdateOptions(input.elementId, input.values) + }), }) } diff --git a/companion/lib/Instance/Connection/Thread/Entrypoint.ts b/companion/lib/Instance/Connection/Thread/Entrypoint.ts index d868de09fb..e9116361ed 100644 --- a/companion/lib/Instance/Connection/Thread/Entrypoint.ts +++ b/companion/lib/Instance/Connection/Thread/Entrypoint.ts @@ -202,7 +202,22 @@ const ipcWrapper = new IpcWrapper( // The 'ipc' channel is retained only for the disconnect signal below. const channel = new FramedChannel(dataSocket, (msg) => ipcWrapper.receivedMessage(msg as any)) -process.on('disconnect', () => process.exit()) +// Safety net: if the parent dies/crashes without sending 'destroy', the IPC channel +// closes. Since we now ignore signals, this is the only thing that reaps us — attempt a +// best-effort surface reset, then exit. Hard-bounded so a hung destroy still exits. +let disconnecting = false +process.on('disconnect', () => { + if (disconnecting) return + disconnecting = true + const forceExit = setTimeout(() => process.exit(1), 2000) + forceExit.unref?.() + Promise.resolve() + .then(async () => { + if (instance && instanceInitialized) await instance.destroy() + }) + .catch(() => {}) + .finally(() => process.exit(1)) +}) registerLoggingSink((source, level, message) => { ipcWrapper.sendWithNoCb('log-message', { diff --git a/companion/lib/Instance/Surface/Thread/Entrypoint.ts b/companion/lib/Instance/Surface/Thread/Entrypoint.ts index 3179870d55..46dff7a265 100644 --- a/companion/lib/Instance/Surface/Thread/Entrypoint.ts +++ b/companion/lib/Instance/Surface/Thread/Entrypoint.ts @@ -199,7 +199,22 @@ const ipcWrapper = new IpcWrapper ipcWrapper.receivedMessage(msg as any)) -process.on('disconnect', () => process.exit()) +// Safety net: if the parent dies/crashes without sending 'destroy', the IPC channel +// closes. Since we now ignore signals, this is the only thing that reaps us — attempt a +// best-effort surface reset, then exit. Hard-bounded so a hung destroy still exits. +let disconnecting = false +process.on('disconnect', () => { + if (disconnecting) return + disconnecting = true + const forceExit = setTimeout(() => process.exit(1), 2000) + forceExit.unref?.() + Promise.resolve() + .then(async () => { + if (plugin && pluginInitialized) await plugin.destroy() + }) + .catch(() => {}) + .finally(() => process.exit(1)) +}) registerLoggingSink((source, level, message) => { ipcWrapper.sendWithNoCb('log-message', { diff --git a/companion/test/Graphics/LayeredRenderer.test.ts b/companion/test/Graphics/LayeredRenderer.test.ts index 23ade158be..65ca0ff3b8 100644 --- a/companion/test/Graphics/LayeredRenderer.test.ts +++ b/companion/test/Graphics/LayeredRenderer.test.ts @@ -2,6 +2,7 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { Canvas, GlobalFonts } from '@napi-rs/canvas' import { beforeAll, describe, expect, test } from 'vitest' +import type { ElementGeometry } from '@companion-app/shared/Graphics/Geometry.js' import { GraphicsLayeredButtonRenderer } from '@companion-app/shared/Graphics/LayeredRenderer.js' import type { RendererButtonStyle } from '@companion-app/shared/Model/Render.js' import type { @@ -1768,4 +1769,110 @@ describe('GraphicsLayeredButtonRenderer', () => { expect(cssTransparent.equals(numericTransparent)).toBe(true) }) }) + + describe('element geometry', () => { + // A 72x58 image with 2px padding and no decoration leaves a 68x54 content area at (2, 2) + async function drawGeometry(elements: SomeButtonGraphicsDrawElement[]): Promise { + const img = Image.create(72, 58, 1, null) + return GraphicsLayeredButtonRenderer.draw( + img, + makeStyle({ decoration: ButtonGraphicsDecorationType.None, elements }), + new Set(), + null, + DEFAULT_PADDING + ) + } + + function rectOf(entry: ElementGeometry) { + const { x, y, width, height } = entry.bounds + return { x, y, width, height } + } + + test('resolves a top-level element against the content bounds', async () => { + const geometry = await drawGeometry([makeBoxElement({ id: 'a', x: 0.25, y: 0.5, width: 0.5, height: 0.25 })]) + + expect(geometry).toHaveLength(1) + expect(rectOf(geometry[0])).toEqual({ x: 2 + 17, y: 2 + 27, width: 34, height: 13.5 }) + expect(geometry[0].rotations).toEqual([]) + }) + + test('emits parents before their children, in draw order', async () => { + const geometry = await drawGeometry([ + makeBoxElement({ id: 'under' }), + makeGroupElement([makeBoxElement({ id: 'child' })], { id: 'g' }), + ]) + + expect(geometry.map((entry) => entry.id)).toEqual(['under', 'g', 'child']) + }) + + test('composes a child against its group, not the content bounds', async () => { + // Group occupies the right half; the child fills the left half of that + const geometry = await drawGeometry([ + makeGroupElement([makeBoxElement({ id: 'child', width: 0.5 })], { id: 'g', x: 0.5, width: 0.5 }), + ]) + + expect(rectOf(geometry[1])).toEqual({ x: 2 + 34, y: 2, width: 17, height: 54 }) + }) + + test('squareCoords records the pre-square group bounds but gives children the square space', async () => { + const geometry = await drawGeometry([ + makeGroupElement([makeBoxElement({ id: 'child' })], { id: 'g', squareCoords: true }), + ]) + + expect(rectOf(geometry[0])).toEqual({ x: 2, y: 2, width: 68, height: 54 }) + // The square is 54x54, centred horizontally within the 68-wide group + expect(rectOf(geometry[1])).toEqual({ x: 2 + 7, y: 2, width: 54, height: 54 }) + }) + + test('a rotated element records a rotation about its own bounds', async () => { + const geometry = await drawGeometry([makeBoxElement({ id: 'a', rotation: 30 })]) + + expect(geometry[0].rotations).toEqual([{ pivot: geometry[0].bounds, angle: 30 }]) + }) + + test('nested rotations accumulate outermost-first, pivoting about the square group space', async () => { + const geometry = await drawGeometry([ + makeGroupElement([makeBoxElement({ id: 'child', rotation: 40 })], { + id: 'g', + rotation: 25, + squareCoords: true, + }), + ]) + + const [groupEntry, childEntry] = geometry + // The group rotates about the square space it hands its children, not its own wider bounds + const groupPivot = groupEntry.rotations[0].pivot + expect({ x: groupPivot.x, y: groupPivot.y, width: groupPivot.width, height: groupPivot.height }).toEqual({ + x: 2 + 7, + y: 2, + width: 54, + height: 54, + }) + + expect(childEntry.rotations).toEqual([ + { pivot: groupPivot, angle: 25 }, + { pivot: childEntry.bounds, angle: 40 }, + ]) + }) + + test('hidden and disabled elements still report their geometry', async () => { + const img = Image.create(72, 58, 1, null) + const geometry = await GraphicsLayeredButtonRenderer.draw( + img, + makeStyle({ + decoration: ButtonGraphicsDecorationType.None, + elements: [ + makeBoxElement({ id: 'hidden' }), + makeBoxElement({ id: 'off', enabled: false }), + makeBoxElement({ id: 'shown' }), + ], + }), + new Set(['hidden']), + null, + DEFAULT_PADDING + ) + + expect(geometry.map((entry) => entry.id)).toEqual(['hidden', 'off', 'shown']) + }) + }) }) diff --git a/shared-lib/lib/Graphics/Geometry.ts b/shared-lib/lib/Graphics/Geometry.ts index 55360f6acb..190501f5e2 100644 --- a/shared-lib/lib/Graphics/Geometry.ts +++ b/shared-lib/lib/Graphics/Geometry.ts @@ -11,31 +11,101 @@ export interface MarkerRotation { } /** - * The selected element's bounds plus the rotation transforms applied to it (outermost first), used - * to draw the selection marker lines in the element's rotated frame. + * An element's bounds plus the rotation transforms applied to it (outermost first), used to draw the + * selection marker lines in the element's rotated frame. */ export interface SelectedElementMarker { bounds: DrawBounds rotations: MarkerRotation[] } +/** One drawn element's resolved geometry, as emitted by the renderer. */ +export interface ElementGeometry extends SelectedElementMarker { + id: string +} + /** A line segment as its two endpoints: [x1, y1, x2, y2] */ export type MarkerLine = [number, number, number, number] /** - * Build a selection marker for `bounds`, wrapping any `inner` rotations with an outer rotation about - * `pivot`'s center. A zero angle adds nothing. Rotations stay ordered outermost-first. + * Extend a parent's rotation chain with a rotation about `pivot`'s center. A zero angle adds nothing. + * Rotations stay ordered outermost-first. */ -export function buildSelectionMarker( - bounds: DrawBounds, - pivot: DrawBounds, - angle: number, - inner: MarkerRotation[] = [] -): SelectedElementMarker { - return { - bounds, - rotations: angle ? [{ pivot, angle }, ...inner] : inner, +export function appendRotation(parent: MarkerRotation[], pivot: DrawBounds, angle: number): MarkerRotation[] { + return angle ? [...parent, { pivot, angle }] : parent +} + +/** + * Rotate a point about each pivot centre. Rotations are outermost-first, applied innermost-first to + * match how the nested canvas transforms compose. + */ +export function rotatePointThroughRotations( + rotations: readonly MarkerRotation[], + x: number, + y: number +): [number, number] { + for (let i = rotations.length - 1; i >= 0; i--) { + ;[x, y] = rotateAbout(rotations[i], x, y, 1) + } + return [x, y] +} + +/** The exact inverse of {@link rotatePointThroughRotations}: outermost-first, with negated angles. */ +export function inverseRotatePointThroughRotations( + rotations: readonly MarkerRotation[], + x: number, + y: number +): [number, number] { + for (let i = 0; i < rotations.length; i++) { + ;[x, y] = rotateAbout(rotations[i], x, y, -1) } + return [x, y] +} + +function rotateAbout({ pivot, angle }: MarkerRotation, x: number, y: number, sign: 1 | -1): [number, number] { + if (!angle) return [x, y] + + const cx = pivot.x + pivot.width / 2 + const cy = pivot.y + pivot.height / 2 + const rad = (sign * angle * Math.PI) / 180 + const cos = Math.cos(rad) + const sin = Math.sin(rad) + const dx = x - cx + const dy = y - cy + + return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos] +} + +/** An element's rotated frame expressed as a single rotation of its box about its own centre */ +export interface MarkerTransform { + centerX: number + centerY: number + width: number + height: number + /** Net rotation in degrees */ + angle: number +} + +/** + * Collapse a marker's rotation chain into a single rotation about the element's own centre. + * + * Every transform in the chain is a pure rotation about a point, so the element stays a rigid, + * unscaled rectangle: a `width` x `height` box centred on the rotated centre and rotated by the summed + * angle is exact at any nesting depth. That makes it directly expressible as a CSS transform. + */ +export function resolveMarkerTransform(marker: SelectedElementMarker): MarkerTransform { + const { bounds, rotations } = marker + + const [centerX, centerY] = rotatePointThroughRotations( + rotations, + bounds.x + bounds.width / 2, + bounds.y + bounds.height / 2 + ) + + let angle = 0 + for (const rotation of rotations) angle += rotation.angle + + return { centerX, centerY, width: bounds.width, height: bounds.height, angle } } /** @@ -98,25 +168,6 @@ export function computeSelectionMarkerLines( let totalAngle = 0 for (const { angle } of rotations) totalAngle += angle - // Rotate a point about each pivot centre. Rotations are outermost-first, applied innermost-first - // to match how the nested canvas transforms compose. - const rotatePoint = (x: number, y: number): [number, number] => { - for (let i = rotations.length - 1; i >= 0; i--) { - const { pivot, angle } = rotations[i] - if (!angle) continue - const cx = pivot.x + pivot.width / 2 - const cy = pivot.y + pivot.height / 2 - const rad = (angle * Math.PI) / 180 - const cos = Math.cos(rad) - const sin = Math.sin(rad) - const dx = x - cx - const dy = y - cy - x = cx + dx * cos - dy * sin - y = cy + dx * sin + dy * cos - } - return [x, y] - } - // The four edges, each as a midpoint on the edge plus its unrotated direction in degrees. `outset` // pushes each edge outward (away from the centre) so the line sits just outside the element instead // of straddling its edge. @@ -129,7 +180,7 @@ export function computeSelectionMarkerLines( const lines: MarkerLine[] = [] for (const edge of edges) { - const [mx, my] = rotatePoint(edge.x, edge.y) + const [mx, my] = rotatePointThroughRotations(rotations, edge.x, edge.y) const rad = ((edge.direction + totalAngle) * Math.PI) / 180 const ends = clipLineToRect(mx, my, Math.cos(rad), Math.sin(rad), width, height) if (ends) lines.push(ends) diff --git a/shared-lib/lib/Graphics/LayeredRenderer.ts b/shared-lib/lib/Graphics/LayeredRenderer.ts index 7870bce7dc..0a6d6ac868 100644 --- a/shared-lib/lib/Graphics/LayeredRenderer.ts +++ b/shared-lib/lib/Graphics/LayeredRenderer.ts @@ -13,7 +13,13 @@ import { ButtonGraphicsDecorationType } from '../Model/StyleModel.js' import { assertNever } from '../Util.js' import { ButtonDecorationRenderer } from './ButtonDecorationRenderer.js' import { buildGaugeColorModel, type GaugeColorRun, type GaugeRGBA } from './GaugeColorModel.js' -import { buildSelectionMarker, computeSelectionMarkerLines, type SelectedElementMarker } from './Geometry.js' +import { + appendRotation, + computeSelectionMarkerLines, + type ElementGeometry, + type MarkerRotation, + type SelectedElementMarker, +} from './Geometry.js' import type { ImageBase, LineStyle } from './ImageBase.js' import { DrawBounds, parseColor, parseColorAlpha, rgbRev } from './Util.js' @@ -25,38 +31,60 @@ import { DrawBounds, parseColor, parseColorAlpha, rgbRev } from './Util.js' const TEXT_OUTLINE_FACTOR = 1 / 16 export class GraphicsLayeredButtonRenderer { + static #computeTopBarBounds(outerBounds: DrawBounds): DrawBounds { + return new DrawBounds( + outerBounds.x, + outerBounds.y, + outerBounds.width, + Math.max(ButtonDecorationRenderer.DEFAULT_HEIGHT, Math.floor(0.2 * outerBounds.height)) + ) + } + + /** + * Compute the bounds of the top-level content area (ie the space that root elements' x/y/width/height + * fractions are relative to). Exposed so callers (eg the editor's selection overlay) can map between + * pixel coordinates and the fractional coordinate space without duplicating this layout math. + */ + static computeContentBounds(outerBounds: DrawBounds, decoration: ButtonGraphicsDecorationType): DrawBounds { + const topBarBounds = this.#computeTopBarBounds(outerBounds) + const topBarHeight = decoration === ButtonGraphicsDecorationType.TopBar ? topBarBounds.height : 0 + + return new DrawBounds( + outerBounds.x, + outerBounds.y + topBarHeight, + outerBounds.width, + outerBounds.height - topBarHeight + ) + } + static async draw( img: ImageBase, drawStyle: RendererButtonStyle, elementsToHide: ReadonlySet, selectedElementId: string | null, paddingPx: { x: number; y: number } - ): Promise { + ): Promise { const backgroundElement = drawStyle.elements[0]?.type === 'canvas' ? drawStyle.elements[0] : undefined - const drawWidth = img.width - paddingPx.x * 2 - const drawHeight = img.height - paddingPx.y * 2 - // Read the resolved `decoration`, not the raw one off the canvas const decoration = drawStyle.decoration - const showTopBar = decoration === ButtonGraphicsDecorationType.TopBar - const topBarBounds = new DrawBounds( + const outerBounds = new DrawBounds( paddingPx.x, paddingPx.y, - drawWidth, - Math.max(ButtonDecorationRenderer.DEFAULT_HEIGHT, Math.floor(0.2 * drawHeight)) + img.width - paddingPx.x * 2, + img.height - paddingPx.y * 2 ) - const topBarHeight = showTopBar ? topBarBounds.height : 0 - const drawBounds = new DrawBounds(paddingPx.x, paddingPx.y + topBarHeight, drawWidth, drawHeight - topBarHeight) + const topBarBounds = this.#computeTopBarBounds(outerBounds) + const drawBounds = this.computeContentBounds(outerBounds, decoration) this.#drawBackgroundElement(img, drawBounds, backgroundElement) // Clip element drawing to the button rectangle, so that only the markers draw outside the bounds - const clipBounds = - paddingPx.x > 0 || paddingPx.y > 0 ? new DrawBounds(paddingPx.x, paddingPx.y, drawWidth, drawHeight) : null - const selectedMarker = await img.usingClip(clipBounds, async () => - this.#drawElements(img, drawStyle.elements, elementsToHide, selectedElementId, drawBounds, false) + const clipBounds = paddingPx.x > 0 || paddingPx.y > 0 ? outerBounds : null + const elementGeometry: ElementGeometry[] = [] + await img.usingClip(clipBounds, async () => + this.#drawElements(img, drawStyle.elements, elementsToHide, drawBounds, false, [], elementGeometry) ) switch (decoration) { @@ -83,22 +111,31 @@ export class GraphicsLayeredButtonRenderer { } // Draw a border around the selected element, do this last so it's on top + const selectedMarker = selectedElementId + ? elementGeometry.find((entry) => entry.id === selectedElementId) + : undefined if (selectedMarker) this.#drawBoundsLines(img, selectedMarker) + + return elementGeometry } /** - * Draw the elements to the image - * Returns the selected element bounds, or null if no element was selected or the selected element was not found + * Draw the elements to the image, collecting each one's resolved geometry into `out`. + * + * Geometry is collected for every element, including hidden and disabled ones and the internal + * children of references - it describes the layout, and it is up to the caller (the editor) to decide + * which elements it cares about. Parents are pushed before their children, so a reverse scan of `out` + * finds the top-most element at a point. */ static async #drawElements( img: ImageBase, elements: SomeButtonGraphicsDrawElement[], elementsToHide: ReadonlySet, - selectedElementId: string | null, drawBounds: DrawBounds, - skipDrawParent: boolean - ): Promise { - let selectedMarker: SelectedElementMarker | null = null + skipDrawParent: boolean, + parentRotations: MarkerRotation[], + out: ElementGeometry[] + ): Promise { for (const element of elements) { // Skip the background element, it's handled separately if (element.type === 'canvas') continue @@ -123,26 +160,22 @@ export class GraphicsLayeredButtonRenderer { ) } + // The pivot is the post-square box, matching what `usingRotation` is given below + const childRotations = appendRotation(parentRotations, groupBounds, element.rotation) + out.push({ id: element.id, bounds: elementBounds, rotations: childRotations }) + elementBounds = null // Already recorded, with the correct pivot + await img.usingTemporaryLayer(element.opacity, async (img) => { await img.usingRotation(groupBounds, element.rotation, async () => { - // Propagate the selected child, prefixing this group's rotation so the marker - // is drawn in the same rotated frame the child was drawn in - const childMarker = await this.#drawElements( + await this.#drawElements( img, element.children, elementsToHide, - selectedElementId, groupBounds, - skipDraw + skipDraw, + childRotations, + out ) - if (childMarker) { - selectedMarker = buildSelectionMarker( - childMarker.bounds, - groupBounds, - element.rotation, - childMarker.rotations - ) - } }) }) break @@ -151,18 +184,19 @@ export class GraphicsLayeredButtonRenderer { // Compute the reference's own bounds first so rotation pivots about its centre, not the container's const referenceBounds = drawBounds.compose(element.x, element.y, element.width, element.height) - elementBounds = referenceBounds + const childRotations = appendRotation(parentRotations, referenceBounds, element.rotation) + out.push({ id: element.id, bounds: referenceBounds, rotations: childRotations }) + await img.usingTemporaryLayer(element.opacity, async (img) => { await img.usingRotation(referenceBounds, element.rotation, async () => { - // Note: children of a reference element cannot be individually selected, - // so the return value (selected child bounds) is intentionally discarded. await this.#drawElements( img, element.children, elementsToHide, - selectedElementId, referenceBounds, - skipDraw + skipDraw, + childRotations, + out ) }) }) @@ -194,14 +228,17 @@ export class GraphicsLayeredButtonRenderer { // TODO - log/report error where? Or should this abandon the render and do a placeholder? } - // Capture the selected element's bounds and rotation, to draw the marker later - if (element.id === selectedElementId && elementBounds) { + // Groups and references record themselves above, so their pivot can be the box they actually + // rotated about. Everything else rotates about its own bounds. + if (elementBounds) { const rotation = 'rotation' in element ? element.rotation : 0 - selectedMarker = buildSelectionMarker(elementBounds, elementBounds, rotation) + out.push({ + id: element.id, + bounds: elementBounds, + rotations: appendRotation(parentRotations, elementBounds, rotation), + }) } } - - return selectedMarker } static #drawBackgroundElement( diff --git a/shared-lib/lib/Graphics/__tests__/Geometry.test.ts b/shared-lib/lib/Graphics/__tests__/Geometry.test.ts index 50ca1b947a..8a68a6f11a 100644 --- a/shared-lib/lib/Graphics/__tests__/Geometry.test.ts +++ b/shared-lib/lib/Graphics/__tests__/Geometry.test.ts @@ -1,8 +1,11 @@ import { describe, expect, test } from 'vitest' import { - buildSelectionMarker, + appendRotation, clipLineToRect, computeSelectionMarkerLines, + inverseRotatePointThroughRotations, + resolveMarkerTransform, + rotatePointThroughRotations, type MarkerLine, type SelectedElementMarker, } from '../Geometry.js' @@ -46,35 +49,96 @@ describe('clipLineToRect', () => { }) }) -describe('buildSelectionMarker', () => { - const bounds = new DrawBounds(0, 0, 4, 4) - const pivot = new DrawBounds(0, 0, 10, 10) +describe('appendRotation', () => { + const outerPivot = new DrawBounds(0, 0, 10, 10) + const innerPivot = new DrawBounds(0, 0, 4, 4) - test('a zero angle adds no rotation', () => { - expect(buildSelectionMarker(bounds, pivot, 0)).toEqual({ bounds, rotations: [] }) + test('a zero angle adds nothing', () => { + expect(appendRotation([], innerPivot, 0)).toEqual([]) }) - test('a non-zero angle prepends a rotation about the pivot', () => { - expect(buildSelectionMarker(bounds, pivot, 30)).toEqual({ - bounds, - rotations: [{ pivot, angle: 30 }], + test('a non-zero angle appends a rotation about the pivot', () => { + expect(appendRotation([], innerPivot, 30)).toEqual([{ pivot: innerPivot, angle: 30 }]) + }) + + test('the parent chain stays outermost-first', () => { + const parent = appendRotation([], outerPivot, 30) + expect(appendRotation(parent, innerPivot, 10)).toEqual([ + { pivot: outerPivot, angle: 30 }, + { pivot: innerPivot, angle: 10 }, + ]) + }) + + test('a zero angle leaves the parent chain untouched', () => { + const parent = appendRotation([], outerPivot, 30) + expect(appendRotation(parent, innerPivot, 0)).toBe(parent) + }) +}) + +describe('rotatePointThroughRotations', () => { + const bounds = new DrawBounds(2, 2, 4, 4) // centre (4, 4) + const outer = new DrawBounds(0, 0, 20, 20) // centre (10, 10) + + test('an empty chain leaves the point where it is', () => { + expect(rotatePointThroughRotations([], 3, 7)).toEqual([3, 7]) + }) + + test('rotating 90° clockwise about a centre', () => { + const [x, y] = rotatePointThroughRotations([{ pivot: bounds, angle: 90 }], 6, 4) + expect(x).toBeCloseTo(4, 6) + expect(y).toBeCloseTo(6, 6) + }) + + test('inverse undoes a nested chain exactly', () => { + const rotations = [ + { pivot: outer, angle: 37 }, + { pivot: bounds, angle: -12 }, + ] + const [x, y] = rotatePointThroughRotations(rotations, 3, 7) + const [bx, by] = inverseRotatePointThroughRotations(rotations, x, y) + + expect(bx).toBeCloseTo(3, 6) + expect(by).toBeCloseTo(7, 6) + }) +}) + +describe('resolveMarkerTransform', () => { + const bounds = new DrawBounds(2, 2, 4, 6) // centre (4, 5) + + test('an unrotated marker keeps its own centre and size', () => { + expect(resolveMarkerTransform({ bounds, rotations: [] })).toEqual({ + centerX: 4, + centerY: 5, + width: 4, + height: 6, + angle: 0, }) }) - test('an outer rotation is prepended before inner rotations (outermost-first)', () => { - const inner = [{ pivot: bounds, angle: 10 }] - expect(buildSelectionMarker(bounds, pivot, 30, inner)).toEqual({ + test('rotating about its own centre changes only the angle', () => { + const transform = resolveMarkerTransform({ bounds, rotations: [{ pivot: bounds, angle: 30 }] }) + + expect(transform.centerX).toBeCloseTo(4, 6) + expect(transform.centerY).toBeCloseTo(5, 6) + expect(transform.angle).toBe(30) + }) + + test('a nested chain sums the angles and moves the centre to where the outer pivot puts it', () => { + // A group rotated 90° about (10, 10) carries the element's centre from (4, 5) to (15, 4) + const group = new DrawBounds(0, 0, 20, 20) + const transform = resolveMarkerTransform({ bounds, rotations: [ - { pivot, angle: 30 }, - { pivot: bounds, angle: 10 }, + { pivot: group, angle: 90 }, + { pivot: bounds, angle: 45 }, ], }) - }) - test('a zero angle keeps the inner rotations unchanged', () => { - const inner = [{ pivot: bounds, angle: 10 }] - expect(buildSelectionMarker(bounds, pivot, 0, inner)).toEqual({ bounds, rotations: inner }) + expect(transform.centerX).toBeCloseTo(15, 6) + expect(transform.centerY).toBeCloseTo(4, 6) + expect(transform.angle).toBe(135) + expect(transform.width).toBe(4) + expect(transform.height).toBe(6) }) }) diff --git a/webui/src/Buttons/EditButton/LayeredButtonEditor/ElementsList.tsx b/webui/src/Buttons/EditButton/LayeredButtonEditor/ElementsList.tsx index 5ad9debf1b..0cd1281462 100644 --- a/webui/src/Buttons/EditButton/LayeredButtonEditor/ElementsList.tsx +++ b/webui/src/Buttons/EditButton/LayeredButtonEditor/ElementsList.tsx @@ -176,33 +176,36 @@ export const ElementsList = observer(function ElementsList({ return ( <> -
- {/* Inside the table so the drag preview clone is styled by the real CSS */} - source?.type !== DRAG_ID} /> - -
-
 
-
Name
-
-
- -
+ +
+
 
+
Name
+
+
+ +
+
+
+
+ {/* Inside the table so the drag preview clone is styled by the real CSS */} + source?.type !== DRAG_ID} /> + + {sortableElements.map((element, index) => ( + + ))} + {canvasElements.map((element) => ( + + ))}
- {sortableElements.map((element, index) => ( - - ))} - {canvasElements.map((element) => ( - - ))}
) diff --git a/webui/src/Buttons/EditButton/LayeredButtonEditor/LayeredButtonEditor.tsx b/webui/src/Buttons/EditButton/LayeredButtonEditor/LayeredButtonEditor.tsx index e79ec0f273..f8ddb95b7e 100644 --- a/webui/src/Buttons/EditButton/LayeredButtonEditor/LayeredButtonEditor.tsx +++ b/webui/src/Buttons/EditButton/LayeredButtonEditor/LayeredButtonEditor.tsx @@ -1,11 +1,11 @@ import { faLayerGroup } from '@fortawesome/free-solid-svg-icons' import { observer } from 'mobx-react-lite' -import { useCallback, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type PropsWithChildren } from 'react' import { Group, Panel, Separator } from 'react-resizable-panels' import type { LayeredButtonModel, SomeButtonModel } from '@companion-app/shared/Model/ButtonModel.js' import type { ControlLocation } from '@companion-app/shared/Model/Common.js' +import { Button, ButtonGroup } from '~/Components/Button.js' import { NonIdealState } from '~/Components/NonIdealState.js' -import { SwitchInputFieldWithLabel } from '~/Components/SwitchInputField.js' import { LayeredStyleElementsProvider } from '~/Controls/Components/LayeredStyleElementsContext.js' import { useLocalVariablesStore, type LocalVariablesStore } from '~/Controls/LocalVariablesStore.js' import { safeSetLocalStorage } from '~/Helpers/SafeStorage.js' @@ -181,18 +181,33 @@ const LayeredButtonEditorStyle = observer(function LayeredButtonEditorStyle({
-
- -
- + + + + + + + + {elementProps ? ( ) }) + +function SeparatorInteractive({ children }: PropsWithChildren): JSX.Element { + const ref = useRef(null) + + useEffect(() => { + const root = ref.current + if (!root) return + + const onPointerDown = (event: PointerEvent) => { + if (event.target instanceof Node && root.contains(event.target)) { + event.preventDefault() + } + } + + window.addEventListener('pointerdown', onPointerDown, true) + return () => window.removeEventListener('pointerdown', onPointerDown, true) + }, []) + + return ( +
+ {children} +
+ ) +} diff --git a/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/LayeredButtonPreviewRenderer.tsx b/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/LayeredButtonPreviewRenderer.tsx index 2f03f5b6bc..777f1d08a6 100644 --- a/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/LayeredButtonPreviewRenderer.tsx +++ b/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/LayeredButtonPreviewRenderer.tsx @@ -1,26 +1,39 @@ +import { PencilIcon } from 'lucide-react' import { observer } from 'mobx-react-lite' import QuickLRU from 'quick-lru' -import { useContext, useEffect, useId, useRef, useState } from 'react' +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import { useLocalStorage, useResizeObserver } from 'usehooks-ts' +import type { ElementGeometry } from '@companion-app/shared/Graphics/Geometry.js' import type { TextLayoutCache } from '@companion-app/shared/Graphics/ImageBase.js' import { GraphicsLayeredButtonRenderer } from '@companion-app/shared/Graphics/LayeredRenderer.js' -import type { ResolveButtonStylePropertiesConfig } from '@companion-app/shared/Graphics/Util.js' +import { DrawBounds, type ResolveButtonStylePropertiesConfig } from '@companion-app/shared/Graphics/Util.js' import type { ControlLocation } from '@companion-app/shared/Model/Common.js' import type { RendererButtonStyle } from '@companion-app/shared/Model/Render.js' import { ButtonGraphicsDecorationType } from '@companion-app/shared/Model/StyleModel.js' import { PromiseDebounce } from '@companion-app/shared/PromiseDebounce.js' import type { DropdownChoice } from '@companion-module/base' -import { DropdownInputField } from '~/Components/DropdownInputField.js' -import { FormLabel } from '~/Components/Form' -import { useLocalStorage } from '~/Hooks/useLocalStorage.js' +import { InputGroup, InputGroupText } from '~/Components/Form.js' +import { NumberInputField } from '~/Components/NumberInputField.js' +import { Popover } from '~/Components/Popover.js' +import { trpc, useMutationExt } from '~/Resources/TRPC.js' import { useComputed } from '~/Resources/util.js' import { RootAppStoreContext } from '~/Stores/RootAppStore.js' import type { LayeredStyleStore } from '../StyleStore.js' +import { + buildOptionValues, + getDraggableBoundsFields, + getDraggableLineFields, + type BoundsFractions, + type BoundsKey, +} from './boundsFields.js' +import { fitCanvasSize, PAD_X, PAD_Y, parseAspectRatio } from './canvasSize.js' import { useLayeredButtonDrawStyleParser } from './DrawStyleParser.js' +import { filterElementRects, findElementRect, hitTestElements } from './elementHitTest.js' import FontLoader from './FontLoader.js' import { GraphicsImage } from './Image.js' - -const PAD_X = 10 -const PAD_Y = 10 +import { LineSelectionOverlay } from './LineSelectionOverlay.js' +import { QuickActionsToolbar } from './QuickActionsToolbar.js' +import { SelectionOverlay } from './SelectionOverlay.js' interface LayeredButtonPreviewRendererProps { controlId: string @@ -46,64 +59,190 @@ export const LayeredButtonPreviewRenderer = observer(function LayeredButtonPrevi const [aspectRatio, setAspectRatio] = useLocalStorage('layered-button-preview-aspect-ratio', '1:1') - const maxSide = 200 - let width = maxSide - let height = maxSide - - try { - const parsed = aspectRatio - .trim() - .split(/:|\/|x|\s]|to|by/i, 3) - .map(Number) - if (parsed.length >= 2 && !isNaN(parsed[0]) && parsed[0] !== 0 && !isNaN(parsed[1]) && parsed[1] !== 0) { - parsed[0] = Math.abs(parsed[0]) - parsed[1] = Math.abs(parsed[1]) - if (parsed[0] > parsed[1] && parsed[0] / parsed[1] <= maxSide) { - height = Math.round(width * (parsed[1] / parsed[0])) - } else if (parsed[0] < parsed[1] && parsed[1] / parsed[0] <= maxSide) { - width = Math.round(height * (parsed[0] / parsed[1])) - } - } else if (parsed.length >= 1 && !isNaN(parsed[0]) && parsed[0] !== 0) { - parsed[0] = Math.abs(parsed[0]) - if (parsed[0] > 1 && parsed[0] <= maxSide) { - height = Math.round(width / parsed[0]) - } else if (parsed[0] < 1 && parsed[0] >= 1 / maxSide) { - width = Math.round(height * parsed[0]) - } - } - // console.log('calculated button preview size', width, height, aspectRatio, parsed) - } catch (e) { - console.error('Failed to parse aspect ratio', e) - // Fallback to 1:1 if parsing fails - // setAspectRatio('1:1') - } + // The canvas is sized in JS to fit the measured container. It can't be done in CSS: the wrapper has to + // shrink-wrap the canvas exactly (the selection overlay positions itself as a percentage of that box), + // which leaves the canvas's own `max-height: 100%` resolving against an auto-height parent, so it would + // never scale down and would overflow the panel instead. + const containerRef = useRef(null) + const { width: containerWidth = 0, height: containerHeight = 0 } = useResizeObserver({ ref: containerRef }) + + const { width, height } = useMemo( + () => fitCanvasSize(aspectRatio, containerWidth, containerHeight), + [aspectRatio, containerWidth, containerHeight] + ) - const aspectRatioFieldId = useId() + // Owned here rather than in the overlay: the toolbar toggles them and the overlay's drag math reads + // them. Refs are what the drag listeners read, since they're registered once per drag. + const [linked, setLinked] = useState(false) + const linkedRef = useRef(false) + const toggleLinked = useCallback(() => { + linkedRef.current = !linkedRef.current + setLinked(linkedRef.current) + }, []) + + const [snapEnabled, setSnapEnabled] = useState(true) + const snapEnabledRef = useRef(true) + const toggleSnapEnabled = useCallback(() => { + snapEnabledRef.current = !snapEnabledRef.current + setSnapEnabled(snapEnabledRef.current) + }, []) return ( - <> -
- -
-
- Preview Aspect Ratio - +
+ +
+
+ +
+
+ Aspect Ratio +
+ {ASPECT_RATIO_OPTIONS.map((option) => { + const id = String(option.id) + return ( + + ) + })} + String(option.id) === aspectRatio)} + /> +
+
- +
+ ) +}) + +/** + * The command half of the editor (centre / fill / aspect-lock / z-order), as opposed to the direct + * manipulation the SelectionOverlay provides. + */ +const ElementQuickActions = observer(function ElementQuickActions({ + controlId, + styleStore, + linked, + onToggleLinked, + snapEnabled, + onToggleSnapEnabled, +}: { + controlId: string + styleStore: LayeredStyleStore + linked: boolean + onToggleLinked: () => void + snapEnabled: boolean + onToggleSnapEnabled: () => void +}) { + const updateOptionsMutation = useMutationExt(trpc.controls.styles.updateOptions.mutationOptions()) + const moveElementMutation = useMutationExt(trpc.controls.styles.moveElement.mutationOptions()) + + const selectedElement = styleStore.getSelectedElement() + const elementId = selectedElement?.id + const boundsFields = selectedElement ? getDraggableBoundsFields(selectedElement) : null + + // Only top-level elements with plain bounds can be repositioned from here + const indexInParent = styleStore.elements.findIndex((el) => el.id === elementId) + const isTopLevel = indexInParent >= 0 + const boundsDisabled = !elementId || !boundsFields || !isTopLevel + + // The only place these explanations reach the user: the outline the overlays draw over a selection they + // can't edit is `pointer-events: none`, so a `title` on it would never be hovered. + const boundsDisabledReason = !elementId + ? 'Select an element to edit it on the canvas' + : selectedElement?.type === 'canvas' + ? 'The Canvas layer has no position or scale to edit' + : !isTopLevel + ? 'Elements inside a group are not yet editable in the preview' + : selectedElement?.type === 'line' + ? getDraggableLineFields(selectedElement) + ? 'Lines have no position or scale - drag their endpoints on the canvas instead' + : 'The line endpoints are set by an expression - edit them in the properties below' + : !boundsFields + ? 'Preview editing is disabled because this element uses an expression to control its position or scale.' + : null + + const commit = useCallback( + (fields: BoundsFractions, changedKeys: readonly BoundsKey[]) => { + if (!elementId) return + updateOptionsMutation + .mutateAsync({ controlId, elementId, values: buildOptionValues(fields, changedKeys) }) + .catch((e) => console.error('Failed to update element bounds', e)) + }, + [updateOptionsMutation, controlId, elementId] + ) + + const centerHorizontal = useCallback(() => { + if (boundsFields) commit({ ...boundsFields, x: (1 - boundsFields.width) / 2 }, ['x']) + }, [boundsFields, commit]) + + const centerVertical = useCallback(() => { + if (boundsFields) commit({ ...boundsFields, y: (1 - boundsFields.height) / 2 }, ['y']) + }, [boundsFields, commit]) + + const fillBounds = useCallback(() => { + commit({ x: 0, y: 0, width: 1, height: 1 }, ['x', 'y', 'width', 'height']) + }, [commit]) + + const moveToZ = useCallback( + (newIndex: number) => { + if (!elementId) return + moveElementMutation + .mutateAsync({ controlId, elementId, parentElementId: null, newIndex }) + .catch((e) => console.error('Failed to reorder element', e)) + }, + [moveElementMutation, controlId, elementId] + ) + + const siblingCount = styleStore.elements.length + // `newIndex` is applied after the element is spliced out, so the top slot is length-1. Data index 0 is + // the locked canvas background, so the lowest a real element can sit is 1. + const bringToFront = useCallback(() => moveToZ(siblingCount - 1), [moveToZ, siblingCount]) + const sendToBack = useCallback(() => moveToZ(1), [moveToZ]) + + return ( + 1} + boundsDisabled={boundsDisabled} + boundsDisabledReason={boundsDisabledReason} + /> ) }) @@ -113,6 +252,73 @@ const ASPECT_RATIO_OPTIONS: DropdownChoice[] = [ { id: '2:1', label: '2:1 (Stream Deck Plus & Plus XL)' }, ] +const CUSTOM_RATIO_MIN = 1 +const CUSTOM_RATIO_MAX = 100 + +/** + * Lets a ratio be entered by hand, for surfaces that don't have a preset button. The value is the same + * "w:h" string the presets use, so it needs no special handling anywhere else. + */ +function CustomAspectRatioButton({ + value, + setValue, + active, +}: { + value: string + setValue: (value: string) => void + active: boolean +}) { + // Seeded from whatever is currently applied, preset or not, so opening this is a starting point rather + // than a jump to some unrelated ratio + const [w, h] = value.split(':').map(Number) + const width = isFinite(w) && w > 0 ? w : 4 + const height = isFinite(h) && h > 0 ? h : 3 + + const clamp = (val: number) => Math.min(CUSTOM_RATIO_MAX, Math.max(CUSTOM_RATIO_MIN, Math.round(val))) + + return ( + + + + + + + W + setValue(`${clamp(val)}:${height}`)} + min={CUSTOM_RATIO_MIN} + max={CUSTOM_RATIO_MAX} + /> + + + H + setValue(`${width}:${clamp(val)}`)} + min={CUSTOM_RATIO_MIN} + max={CUSTOM_RATIO_MAX} + /> + + + + ) +} + +// A little outlined rectangle drawn to the ratio, so the option reads at a glance +function AspectRatioGlyph({ ratio }: { ratio: number }) { + const max = 15 + const glyphWidth = ratio >= 1 ? max : max * ratio + const glyphHeight = ratio >= 1 ? max / ratio : max + return +} + interface LayeredButtonCanvasProps { width: number height: number @@ -121,8 +327,12 @@ interface LayeredButtonCanvasProps { hiddenElements: ReadonlySet selectedElementId: string | null className?: string + controlId: string + styleStore: LayeredStyleStore + linkedRef: React.RefObject + snapEnabledRef: React.RefObject } -function LayeredButtonCanvas({ +const LayeredButtonCanvas = observer(function LayeredButtonCanvas({ width, height, location, @@ -130,16 +340,26 @@ function LayeredButtonCanvas({ hiddenElements, selectedElementId, className, + controlId, + styleStore, + linkedRef, + snapEnabledRef, }: LayeredButtonCanvasProps) { const drawContext = useRef(null) + // Element geometry as resolved by the renderer itself, so the editor never has to recompute the layout + const [geometry, setGeometry] = useState([]) + const [canvas, setCanvas] = useState(null) useEffect(() => { if (!canvas || !drawStyle) return // Setup the context on the first run, or when something changes - if (!drawContext.current || drawContext.current.canvas !== canvas) - drawContext.current = new RendererDrawContext(canvas) + if (!drawContext.current || drawContext.current.canvas !== canvas) { + // Drop the previous canvas' geometry rather than hit-testing against a stale layout + setGeometry([]) + drawContext.current = new RendererDrawContext(canvas, setGeometry) + } // Update any cached properties drawContext.current.setHiddenElements(hiddenElements) @@ -165,33 +385,115 @@ function LayeredButtonCanvas({ } }, []) - return ( + const canvasWidthPx = width + PAD_X * 2 + const canvasHeightPx = height + PAD_Y * 2 + + const selectedElement = selectedElementId ? styleStore.getSelectedElement() : undefined + + // Use the fully-resolved per-button decoration (drawStyle.decoration), not the global default - a button's + // own canvas element can override it (eg to "None"), which changes whether top-bar space is reserved and + // would otherwise throw off the overlay's alignment with what's actually drawn. + const contentBoundsPx = useMemo( + () => + drawStyle + ? GraphicsLayeredButtonRenderer.computeContentBounds( + new DrawBounds(PAD_X, PAD_Y, width, height), + drawStyle.decoration + ) + : null, + [drawStyle, width, height] + ) + + // Rects for every selectable element, used for click-to-select and for outlining an unselectable section + const selectableIds = styleStore.selectableElementIds + const elementRects = useMemo( + () => (drawStyle ? filterElementRects(geometry, drawStyle.elements, hiddenElements, selectableIds) : []), + [drawStyle, geometry, hiddenElements, selectableIds] + ) + + const selectElementById = useCallback((id: string) => styleStore.setSelectedElementId(id), [styleStore]) + + const onCanvasPointerDown = useCallback( + (e: React.PointerEvent) => { + if (!canvas) return + + const rect = canvas.getBoundingClientRect() + const x = ((e.clientX - rect.left) * canvas.width) / rect.width + const y = ((e.clientY - rect.top) * canvas.height) / rect.height + + styleStore.setSelectedElementId(hitTestElements(elementRects, x, y)?.id ?? null) + }, + [canvas, elementRects, styleStore] + ) + + const canvasEl = ( ) -} + + return ( +
+ {canvasEl} + {canvas && + drawStyle && + contentBoundsPx && + selectedElement && + // Lines are defined by two endpoints rather than bounds, so they get their own overlay + (selectedElement.type === 'line' ? ( + el.id === selectedElement.id)} + elementRects={elementRects} + contentBoundsPx={contentBoundsPx} + canvasSizePx={{ width: canvasWidthPx, height: canvasHeightPx }} + snapEnabledRef={snapEnabledRef} + /> + ) : ( + el.id === selectedElement.id)} + elementRects={elementRects} + contentBoundsPx={contentBoundsPx} + canvasSizePx={{ width: canvasWidthPx, height: canvasHeightPx }} + linkedRef={linkedRef} + snapEnabledRef={snapEnabledRef} + onSelectElement={selectElementById} + /> + ))} +
+ ) +}) class RendererDrawContext { readonly #image: GraphicsImage readonly #debounce: PromiseDebounce + readonly #onGeometry: (geometry: readonly ElementGeometry[]) => void readonly canvas: HTMLCanvasElement #hiddenElements: ReadonlySet = new Set() #selectedElementId: string | null = null - constructor(canvas: HTMLCanvasElement) { + constructor(canvas: HTMLCanvasElement, onGeometry: (geometry: readonly ElementGeometry[]) => void) { const textLayoutCache: TextLayoutCache = new QuickLRU({ maxSize: 200 }) const image = GraphicsImage.create(canvas, textLayoutCache) if (!image) throw new Error('Failed to create image') this.#image = image this.#debounce = new PromiseDebounce(this.#debounceDraw, 1, 10) + this.#onGeometry = onGeometry this.canvas = canvas } @@ -217,7 +519,7 @@ class RendererDrawContext { } } - await GraphicsLayeredButtonRenderer.draw( + const geometry = await GraphicsLayeredButtonRenderer.draw( this.#image, this.#lastDrawStyle, this.#hiddenElements, @@ -226,6 +528,7 @@ class RendererDrawContext { ) this.#image.drawComplete() + this.#onGeometry(geometry) } catch (e) { console.error('draw failed!', e) } diff --git a/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/LineSelectionOverlay.tsx b/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/LineSelectionOverlay.tsx new file mode 100644 index 0000000000..be6f02bf0a --- /dev/null +++ b/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/LineSelectionOverlay.tsx @@ -0,0 +1,399 @@ +import { observer } from 'mobx-react-lite' +import { useCallback, useEffect, useRef, useState } from 'react' +import { resolveMarkerTransform } from '@companion-app/shared/Graphics/Geometry.js' +import type { SomeButtonGraphicsElement } from '@companion-app/shared/Model/StyleLayersModel.js' +import { trpc, useMutationExt } from '~/Resources/TRPC.js' +import { + buildOptionValues, + getDraggableLineFields, + LINE_KEYS, + ROUND_STEP, + roundFields, + type LineFractions, + type LineKey, +} from './boundsFields.js' +import type { ElementRect, PixelRect } from './elementHitTest.js' +import { SnapGuide } from './SnapGuide.js' +import { collectSnapTargets, snapAxis, thresholdFractionFor } from './snapping.js' + +/** Which end is being dragged, or the whole line */ +type LineDragMode = 'move' | 'from' | 'to' + +interface LineSelectionOverlayProps { + controlId: string + canvas: HTMLCanvasElement + selectedElement: SomeButtonGraphicsElement + /** Absolute rect of the selection, used to outline a line the overlay can't edit */ + selectedElementRect: ElementRect | null + isTopLevelSelection: boolean + /** Every element's absolute rect, used as snap targets */ + elementRects: readonly ElementRect[] + /** The pixel rect the element's fractions are relative to */ + contentBoundsPx: PixelRect + /** The full canvas backing-pixel size, used to convert pixel positions into percentages of the overlay box */ + canvasSizePx: { width: number; height: number } + /** Owned by the toolbar; read during a drag to gate snapping */ + snapEnabledRef: React.RefObject +} + +interface LineDragState { + mode: LineDragMode + startClientX: number + startClientY: number + startFields: LineFractions + snapTargetsX: number[] + snapTargetsY: number[] +} + +interface SnapLines { + x: number | null + y: number | null +} + +const clamp01 = (value: number) => Math.min(1, Math.max(0, value)) + +/** Keys each drag mode writes back */ +const CHANGED_KEYS: Record = { + move: LINE_KEYS, + from: ['fromX', 'fromY'], + to: ['toX', 'toY'], +} + +/** + * Direct manipulation for line elements. They carry two endpoints rather than the x/y/width/height every + * other element type has, so they get their own overlay: a handle on each end, plus the line itself as a + * drag target for moving both at once. + */ +export const LineSelectionOverlay = observer(function LineSelectionOverlay({ + controlId, + canvas, + selectedElement, + selectedElementRect, + isTopLevelSelection, + elementRects, + contentBoundsPx, + canvasSizePx, + snapEnabledRef, +}: LineSelectionOverlayProps) { + const updateOptionsMutation = useMutationExt(trpc.controls.styles.updateOptions.mutationOptions()) + + const elementId = selectedElement.id + const lineFields = getDraggableLineFields(selectedElement) + + const isInteractive = isTopLevelSelection && !!lineFields + + const [liveFields, setLiveFields] = useState(null) + // Mirrors `liveFields` synchronously so onPointerUp can read the final drag value without a setState + // updater - `commit` is a side effect, and React may invoke an updater more than once. + const liveFieldsRef = useRef(null) + const dragState = useRef(null) + const [snapLines, setSnapLines] = useState({ x: null, y: null }) + + // Held until the server round-trips a matching value, so the overlay doesn't flick back to the pre-drag + // position for a frame on drop. Mirrors SelectionOverlay's pendingCommitRef. + const pendingCommitRef = useRef(null) + + // Detaches whichever listeners are actually attached. Held in a ref rather than removed by identity so a + // re-render that changes the handler identities mid-drag can't detach the wrong pair (or none). + const detachListenersRef = useRef<(() => void) | null>(null) + + // Nothing else drops the listeners if the overlay unmounts mid-drag (selection cleared, panel closed), + // which would otherwise leave a stray pointerup committing a mutation for a component that's gone. + useEffect(() => { + return () => { + dragState.current = null + detachListenersRef.current?.() + detachListenersRef.current = null + } + }, []) + + useEffect(() => { + if (dragState.current) return + pendingCommitRef.current = null + liveFieldsRef.current = null + setLiveFields(null) + }, [elementId]) + + const onPointerMove = useCallback( + (e: PointerEvent) => { + const state = dragState.current + if (!state) return + + const rect = canvas.getBoundingClientRect() + const scaleX = canvas.width / rect.width + const scaleY = canvas.height / rect.height + let dxFraction = ((e.clientX - state.startClientX) * scaleX) / contentBoundsPx.width + let dyFraction = ((e.clientY - state.startClientY) * scaleY) / contentBoundsPx.height + + // Shift locks to whichever axis the pointer has travelled further along, which is how a line is + // made exactly horizontal or vertical + if (e.shiftKey) { + if (Math.abs(dxFraction) >= Math.abs(dyFraction)) { + dyFraction = 0 + } else { + dxFraction = 0 + } + } + + const next: LineFractions = { ...state.startFields } + if (state.mode === 'move' || state.mode === 'from') { + next.fromX = state.startFields.fromX + dxFraction + next.fromY = state.startFields.fromY + dyFraction + } + if (state.mode === 'move' || state.mode === 'to') { + next.toX = state.startFields.toX + dxFraction + next.toY = state.startFields.toY + dyFraction + } + + // Ctrl/cmd inverts the toolbar's snap-enabled setting for the duration of the drag + const lines: SnapLines = { x: null, y: null } + const snapActive = e.ctrlKey || e.metaKey ? !snapEnabledRef.current : snapEnabledRef.current + if (snapActive) { + for (const axis of ['x', 'y'] as const) { + const fromKey = axis === 'x' ? 'fromX' : 'fromY' + const toKey = axis === 'x' ? 'toX' : 'toY' + const targets = axis === 'x' ? state.snapTargetsX : state.snapTargetsY + const threshold = thresholdFractionFor(axis === 'x' ? contentBoundsPx.width : contentBoundsPx.height) + + // Moving the whole line can snap on either end; dragging one end only snaps that end + const candidates = + state.mode === 'move' + ? [next[fromKey], next[toKey]] + : state.mode === 'from' + ? [next[fromKey]] + : [next[toKey]] + + const snap = snapAxis(candidates, targets, threshold) + if (!snap) continue + + lines[axis] = snap.line + if (state.mode === 'move' || state.mode === 'from') next[fromKey] += snap.delta + if (state.mode === 'move' || state.mode === 'to') next[toKey] += snap.delta + } + } + + // The schema caps the endpoint fields at 0-100%, so keep drags inside the same range rather than + // producing values the properties panel would reject + if (state.mode === 'move') { + for (const axis of ['x', 'y'] as const) { + const fromKey = axis === 'x' ? 'fromX' : 'fromY' + const toKey = axis === 'x' ? 'toX' : 'toY' + const start = state.startFields + + const lowEnd = Math.min(start[fromKey], start[toKey]) + const highEnd = Math.max(start[fromKey], start[toKey]) + const shift = Math.min(Math.max(next[fromKey] - start[fromKey], -lowEnd), 1 - highEnd) + + next[fromKey] = start[fromKey] + shift + next[toKey] = start[toKey] + shift + } + } else { + for (const key of LINE_KEYS) next[key] = clamp01(next[key]) + } + + setSnapLines(lines) + liveFieldsRef.current = next + setLiveFields(next) + }, + [canvas, contentBoundsPx, snapEnabledRef] + ) + + const onPointerUp = useCallback(() => { + const state = dragState.current + dragState.current = null + detachListenersRef.current?.() + detachListenersRef.current = null + setSnapLines({ x: null, y: null }) + + if (!state) return + + const finalFields = liveFieldsRef.current + if (!finalFields) { + liveFieldsRef.current = null + setLiveFields(null) + return + } + + const rounded = roundFields(finalFields) + liveFieldsRef.current = rounded + setLiveFields(rounded) + pendingCommitRef.current = rounded + + // One mutation, so the line is never persisted or redrawn with only one end updated + updateOptionsMutation + .mutateAsync({ controlId, elementId, values: buildOptionValues(rounded, CHANGED_KEYS[state.mode]) }) + .catch((e) => console.error('Failed to update line endpoints', e)) + }, [updateOptionsMutation, controlId, elementId]) + + // Once the props round-trip what was committed, drop back to tracking them directly + useEffect(() => { + const pending = pendingCommitRef.current + if (!pending || !lineFields || dragState.current) return + + if (LINE_KEYS.every((key) => Math.abs(lineFields[key] - pending[key]) < ROUND_STEP / 2)) { + pendingCommitRef.current = null + liveFieldsRef.current = null + setLiveFields(null) + } + }, [lineFields]) + + const startDrag = useCallback( + (mode: LineDragMode, e: React.PointerEvent) => { + if (!lineFields) return + e.preventDefault() + e.stopPropagation() + + dragState.current = { + mode, + startClientX: e.clientX, + startClientY: e.clientY, + startFields: lineFields, + snapTargetsX: collectSnapTargets(elementRects, contentBoundsPx, elementId, 'x'), + snapTargetsY: collectSnapTargets(elementRects, contentBoundsPx, elementId, 'y'), + } + + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', onPointerUp) + detachListenersRef.current = () => { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerUp) + } + }, + [lineFields, onPointerMove, onPointerUp, elementId, elementRects, contentBoundsPx] + ) + + const percentOf = (value: number, total: number) => `${(value / total) * 100}%` + + // A line the overlay can't edit still gets an outline, so the selection stays visible. The reason why is + // carried by the quick-actions toolbar's tooltip - this outline is click-through, so it can't be hovered. + if (!isInteractive || !lineFields) { + if (!selectedElementRect) return null + + // A line has no rotation of its own, but it can sit inside rotated groups - place the box at the + // rotated centre and let one CSS rotation carry the whole chain. + const transform = resolveMarkerTransform({ + bounds: selectedElementRect.rect, + rotations: selectedElementRect.rotations, + }) + + const readonlyStyle: React.CSSProperties = { + position: 'absolute', + left: percentOf(transform.centerX - transform.width / 2, canvasSizePx.width), + top: percentOf(transform.centerY - transform.height / 2, canvasSizePx.height), + width: percentOf(transform.width, canvasSizePx.width), + height: percentOf(transform.height, canvasSizePx.height), + transform: transform.angle ? `rotate(${transform.angle}deg)` : undefined, + border: '1px dashed rgba(255, 255, 255, 0.6)', + outline: '1px dashed rgba(0, 0, 0, 0.4)', + boxSizing: 'border-box', + pointerEvents: 'none', + } + + return
+ } + + const displayFields = liveFields ?? lineFields + const isDragging = liveFields !== null + + const toPx = (fraction: number, axis: 'x' | 'y') => + axis === 'x' + ? contentBoundsPx.x + fraction * contentBoundsPx.width + : contentBoundsPx.y + fraction * contentBoundsPx.height + + const fromXPx = toPx(displayFields.fromX, 'x') + const fromYPx = toPx(displayFields.fromY, 'y') + const toXPx = toPx(displayFields.toX, 'x') + const toYPx = toPx(displayFields.toY, 'y') + + return ( + <> + {snapLines.x !== null && ( + + )} + {snapLines.y !== null && ( + + )} + + {/* SVG rather than a div: only a stroke can be a hit target that follows a diagonal. The viewBox is + the canvas backing-pixel space, which is what all the maths above is already in. */} + + startDrag('move', e)} + /> + {isDragging && ( + + )} + + + startDrag('from', e)} + /> + startDrag('to', e)} + /> + + ) +}) + +function EndpointHandle({ + leftPercent, + topPercent, + title, + onPointerDown, +}: { + leftPercent: string + topPercent: string + title: string + onPointerDown: (e: React.PointerEvent) => void +}) { + // Round, to read as an endpoint rather than one of the square corner handles a box selection gets + const style: React.CSSProperties = { + position: 'absolute', + left: leftPercent, + top: topPercent, + width: 10, + height: 10, + background: '#2276d2', + border: '1px solid #fff', + borderRadius: '50%', + transform: 'translate(-50%, -50%)', + cursor: 'move', + pointerEvents: 'auto', + } + + return
+} diff --git a/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/QuickActionsToolbar.tsx b/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/QuickActionsToolbar.tsx new file mode 100644 index 0000000000..a1232124b0 --- /dev/null +++ b/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/QuickActionsToolbar.tsx @@ -0,0 +1,139 @@ +import { + faArrowsLeftRight, + faArrowsUpDown, + faExpand, + faLayerGroup, + faLink, + faMagnet, + faObjectGroup, +} from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { Tooltip } from '~/Components/Tooltip.js' + +export interface QuickActionsToolbarProps { + onCenterHorizontal: () => void + onCenterVertical: () => void + onFill: () => void + linked: boolean + onToggleLinked: () => void + snapEnabled: boolean + onToggleSnapEnabled: () => void + onBringToFront: () => void + onSendToBack: () => void + canBringToFront: boolean + canSendToBack: boolean + /** The position/scale actions need a selection with plain bounds; snapping and z-order don't */ + boundsDisabled: boolean + /** Shown as a tooltip on the disabled buttons to explain why */ + boundsDisabledReason: string | null +} + +/** + * Quick actions for the selected element. Rendered as a normal row beneath the preview rather than floating + * over it - the preview panel clips its overflow, so an absolutely positioned bar either covers the button + * or gets cut off entirely. + */ +export function QuickActionsToolbar({ + onCenterHorizontal, + onCenterVertical, + onFill, + linked, + onToggleLinked, + snapEnabled, + onToggleSnapEnabled, + onBringToFront, + onSendToBack, + canBringToFront, + canSendToBack, + boundsDisabled, + boundsDisabledReason, +}: QuickActionsToolbarProps): React.JSX.Element { + // Only the element-level disable (expression/nested/no selection) gets an explanation; a z-order button + // greyed out because it's already at the front/back is self-evident. + const reason = boundsDisabled ? boundsDisabledReason : null + return ( +
+ + + + + {/* A preference for the canvas as a whole rather than an action on the selection, so it stays + usable no matter what (or whether anything) is selected */} + +
+ {/* Z-order applies to any top-level element, including ones with no editable bounds (eg lines), + so it's gated on the can* flags alone */} + + +
+ ) +} + +function ToolbarButton({ + title, + icon, + onClick, + active, + disabled, + disabledReason, +}: { + title: string + icon: Parameters[0]['icon'] + onClick: () => void + active?: boolean + disabled?: boolean + disabledReason?: string | null +}) { + const button = ( + + ) + + // A disabled button doesn't emit hover events, so the trigger span (not the button) carries the tooltip + if (!disabledReason) return button + return ( + + {button}} delay={300} /> + + {disabledReason} + + + ) +} diff --git a/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/SelectionOverlay.tsx b/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/SelectionOverlay.tsx new file mode 100644 index 0000000000..69315f96a7 --- /dev/null +++ b/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/SelectionOverlay.tsx @@ -0,0 +1,526 @@ +import { observer } from 'mobx-react-lite' +import { useCallback, useEffect, useRef, useState } from 'react' +import { resolveMarkerTransform } from '@companion-app/shared/Graphics/Geometry.js' +import type { SomeButtonGraphicsElement } from '@companion-app/shared/Model/StyleLayersModel.js' +import { trpc, useMutationExt } from '~/Resources/TRPC.js' +import { + buildOptionValues, + getDraggableBoundsFields, + MIN_FRACTION_SIZE, + ROUND_STEP, + roundFields, + type BoundsFractions, + type BoundsKey, +} from './boundsFields.js' +import { netRotation, type ElementRect, type PixelRect } from './elementHitTest.js' +import { SnapGuide } from './SnapGuide.js' +import { collectSnapTargets, snapAxis, thresholdFractionFor } from './snapping.js' + +type Corner = 'nw' | 'ne' | 'sw' | 'se' + +interface SelectionOverlayProps { + controlId: string + canvas: HTMLCanvasElement + selectedElement: SomeButtonGraphicsElement + /** The selection as the renderer drew it, used to outline selections the overlay can't edit */ + selectedElementRect: ElementRect | null + isTopLevelSelection: boolean + /** Every element's absolute rect, used as snap targets */ + elementRects: readonly ElementRect[] + /** The pixel rect (in the canvas's backing-pixel space) that the element's x/y/width/height fractions are relative to */ + contentBoundsPx: PixelRect + /** The full canvas backing-pixel size, used to convert pixel rects into percentages of the overlay box */ + canvasSizePx: { width: number; height: number } + /** Owned by the toolbar above; read during a resize to lock the aspect ratio */ + linkedRef: React.RefObject + /** Owned by the toolbar above; read during a drag to gate snapping */ + snapEnabledRef: React.RefObject + onSelectElement: (elementId: string) => void +} + +interface DragState { + mode: 'move' | 'resize' + corner: Corner | undefined + startClientX: number + startClientY: number + startFields: BoundsFractions + /** Element the drag commits to. Retargeted to the clone once an alt-drag duplicate resolves. */ + targetId: string + /** Snap targets per axis, computed once at drag start since the other elements don't move mid-drag */ + snapTargetsX: number[] + snapTargetsY: number[] +} + +/** Rotate a vector clockwise by `degrees` (matching the canvas' positive rotation direction) */ +function rotateVector(x: number, y: number, degrees: number): [number, number] { + const rad = (degrees * Math.PI) / 180 + const cos = Math.cos(rad) + const sin = Math.sin(rad) + return [x * cos - y * sin, x * sin + y * cos] +} + +/** Guide lines to draw during a drag, in fraction-of-content space */ +interface SnapLines { + x: number | null + y: number | null +} + +export const SelectionOverlay = observer(function SelectionOverlay({ + controlId, + canvas, + selectedElement, + selectedElementRect, + isTopLevelSelection, + elementRects, + contentBoundsPx, + canvasSizePx, + linkedRef, + snapEnabledRef, + onSelectElement, +}: SelectionOverlayProps) { + const updateOptionsMutation = useMutationExt(trpc.controls.styles.updateOptions.mutationOptions()) + const duplicateElementMutation = useMutationExt(trpc.controls.styles.duplicateElement.mutationOptions()) + + const elementId = selectedElement.id + const boundsFields = getDraggableBoundsFields(selectedElement) + + // The rotation the renderer actually drew the element at, so the overlay lines up with the canvas even + // when the element's `rotation` is expression-driven. Interactive selections are always top-level, so + // this is just the element's own rotation - there is no parent frame to compose with. + const angle = selectedElementRect ? netRotation(selectedElementRect.rotations) : 0 + + // Only top-level elements with plain (non-expression) bounds can be dragged. Anything else still gets an + // outline so the selection is visible, rather than the overlay silently disappearing. + const isInteractive = isTopLevelSelection && !!boundsFields + + const [liveFields, setLiveFields] = useState(null) + // Mirrors `liveFields` synchronously so onPointerUp can read the final drag value without relying on + // a setState updater callback (side effects like `commit` must not live inside one - see onPointerUp). + const liveFieldsRef = useRef(null) + const dragState = useRef(null) + const [snapLines, setSnapLines] = useState({ x: null, y: null }) + + // The rounded value just committed to the server, kept in `liveFields` until `boundsFields` (derived + // from props) round-trips back with a matching value. Without this, clearing `liveFields` on drop makes + // the overlay snap back to the stale pre-drag position for one render, then jump forward again once the + // mutation resolves - a visible flicker on every move/resize. + const pendingCommitRef = useRef(null) + + // Detaches whichever listeners are actually attached. Held in a ref rather than removed by identity so a + // re-render that changes the handler identities mid-drag can't detach the wrong pair (or none). + const detachListenersRef = useRef<(() => void) | null>(null) + + // Nothing else drops the listeners if the overlay unmounts mid-drag (selection cleared, panel closed), + // which would otherwise leave a stray pointerup committing a mutation for a component that's gone. + useEffect(() => { + return () => { + dragState.current = null + detachListenersRef.current?.() + detachListenersRef.current = null + } + }, []) + + // Discard any in-flight drag/pending-commit state when the selection changes to a different element - + // otherwise the overlay could keep showing the previous element's held position over the new one. + useEffect(() => { + if (dragState.current) return + dragState.current = null + pendingCommitRef.current = null + liveFieldsRef.current = null + setLiveFields(null) + }, [elementId]) + + // `targetElementId` is passed explicitly rather than closed over: an alt-drag retargets mid-gesture to a + // clone that didn't exist when the pointer listeners were registered. + const commit = useCallback( + (fields: BoundsFractions, changedKeys: readonly BoundsKey[], targetElementId: string) => { + // Sent as one mutation so the element is never persisted or redrawn half-updated (eg x applied + // but height not yet), which otherwise shows as a flicker on drop. + updateOptionsMutation + .mutateAsync({ controlId, elementId: targetElementId, values: buildOptionValues(fields, changedKeys) }) + .catch((e) => console.error('Failed to update element bounds', e)) + }, + [updateOptionsMutation, controlId] + ) + + const onPointerMove = useCallback( + (e: PointerEvent) => { + const state = dragState.current + if (!state) return + + const rect = canvas.getBoundingClientRect() + const scaleX = canvas.width / rect.width + const scaleY = canvas.height / rect.height + let dxPx = (e.clientX - state.startClientX) * scaleX + let dyPx = (e.clientY - state.startClientY) * scaleY + + // A resize handle sits in the element's rotated frame, so undo the rotation to get the movement + // along the element's own axes. A move needs no such correction: rotation is about the element's + // own centre, so a screen-space translation is the same translation unrotated. + if (state.mode === 'resize' && angle) [dxPx, dyPx] = rotateVector(dxPx, dyPx, -angle) + + // Done after the rotation, which has to happen in pixel space - the two axes of fraction space are + // scaled differently whenever the content bounds aren't square + let dxFraction = dxPx / contentBoundsPx.width + let dyFraction = dyPx / contentBoundsPx.height + + // Shift locks a move to whichever axis the pointer has travelled further along + if (state.mode === 'move' && e.shiftKey) { + if (Math.abs(dxFraction) >= Math.abs(dyFraction)) { + dyFraction = 0 + } else { + dxFraction = 0 + } + } + + const next: BoundsFractions = { ...state.startFields } + + if (state.mode === 'move') { + next.x = state.startFields.x + dxFraction + next.y = state.startFields.y + dyFraction + } else if (state.corner) { + const left = state.corner.includes('w') + const top = state.corner.includes('n') + + if (linkedRef.current) { + // Locked: scale width and height by the same factor, driven by whichever axis the + // pointer has moved further along, so the aspect ratio never drifts during the drag. + const dxOutward = left ? -dxFraction : dxFraction + const dyOutward = top ? -dyFraction : dyFraction + const scale = + Math.abs(dxOutward) >= Math.abs(dyOutward) + ? (state.startFields.width + dxOutward) / state.startFields.width + : (state.startFields.height + dyOutward) / state.startFields.height + + next.width = Math.max(MIN_FRACTION_SIZE, state.startFields.width * scale) + next.height = Math.max(MIN_FRACTION_SIZE, state.startFields.height * scale) + next.x = left ? state.startFields.x + state.startFields.width - next.width : state.startFields.x + next.y = top ? state.startFields.y + state.startFields.height - next.height : state.startFields.y + } else { + if (left) { + const newX = state.startFields.x + dxFraction + next.width = Math.max(MIN_FRACTION_SIZE, state.startFields.x + state.startFields.width - newX) + next.x = state.startFields.x + state.startFields.width - next.width + } else { + next.width = Math.max(MIN_FRACTION_SIZE, state.startFields.width + dxFraction) + } + + if (top) { + const newY = state.startFields.y + dyFraction + next.height = Math.max(MIN_FRACTION_SIZE, state.startFields.y + state.startFields.height - newY) + next.y = state.startFields.y + state.startFields.height - next.height + } else { + next.height = Math.max(MIN_FRACTION_SIZE, state.startFields.height + dyFraction) + } + } + } + + // Ctrl/cmd inverts the toolbar's snap-enabled setting for the duration of the drag + // (shift is axis-lock, alt is duplicate). Snapping is off entirely for a rotated element: its + // unrotated edges aren't where the user sees them, so the guides would land somewhere other than + // the element (collectSnapTargets skips rotated elements as targets for the same reason). + const lines: SnapLines = { x: null, y: null } + const snapActive = !angle && (e.ctrlKey || e.metaKey ? !snapEnabledRef.current : snapEnabledRef.current) + if (snapActive) { + const left = state.corner?.includes('w') + const top = state.corner?.includes('n') + const thresholdX = thresholdFractionFor(contentBoundsPx.width) + const thresholdY = thresholdFractionFor(contentBoundsPx.height) + + if (state.mode === 'resize' && linkedRef.current && state.corner) { + // Linked resize: snap the dragged corner, then scale both axes by the same factor so the + // aspect ratio the lock maintains isn't broken. + const anchorX = left ? state.startFields.x + state.startFields.width : state.startFields.x + const anchorY = top ? state.startFields.y + state.startFields.height : state.startFields.y + const snapX = snapAxis([left ? next.x : next.x + next.width], state.snapTargetsX, thresholdX) + const snapY = snapAxis([top ? next.y : next.y + next.height], state.snapTargetsY, thresholdY) + + // Apply whichever axis snaps closer, deriving a uniform scale from the anchor + const pick = + snapX && (!snapY || Math.abs(snapX.delta) <= Math.abs(snapY.delta)) + ? { + axis: 'x' as const, + line: snapX.line, + size: Math.abs(snapX.line - anchorX), + start: state.startFields.width, + } + : snapY + ? { + axis: 'y' as const, + line: snapY.line, + size: Math.abs(snapY.line - anchorY), + start: state.startFields.height, + } + : null + + if (pick) { + const scale = pick.size / pick.start + next.width = Math.max(MIN_FRACTION_SIZE, state.startFields.width * scale) + next.height = Math.max(MIN_FRACTION_SIZE, state.startFields.height * scale) + next.x = left ? anchorX - next.width : anchorX + next.y = top ? anchorY - next.height : anchorY + lines[pick.axis] = pick.line + } + } else { + for (const axis of ['x', 'y'] as const) { + const start = next[axis] + const size = axis === 'x' ? next.width : next.height + const targets = axis === 'x' ? state.snapTargetsX : state.snapTargetsY + const threshold = axis === 'x' ? thresholdX : thresholdY + + // A move can snap on any of its three edges; a resize only on the corner being dragged + const candidates = + state.mode === 'move' + ? [start, start + size / 2, start + size] + : (axis === 'x' ? left : top) + ? [start] + : [start + size] + + const snap = snapAxis(candidates, targets, threshold) + if (!snap) continue + + lines[axis] = snap.line + if (state.mode === 'move') { + next[axis] = start + snap.delta + } else if (axis === 'x') { + // Resizing moves the dragged edge only, so the opposite edge stays put + if (left) { + next.x = start + snap.delta + next.width = Math.max(MIN_FRACTION_SIZE, size - snap.delta) + } else { + next.width = Math.max(MIN_FRACTION_SIZE, size + snap.delta) + } + } else { + if (top) { + next.y = start + snap.delta + next.height = Math.max(MIN_FRACTION_SIZE, size - snap.delta) + } else { + next.height = Math.max(MIN_FRACTION_SIZE, size + snap.delta) + } + } + } + } + } + + // Resizing anchors the opposite edges in the element's own coordinates, but the element rotates about + // its centre - so moving that centre swings the anchored corner away on screen. Translate the result + // by however much the rotation displaced it. `d - R(d)` is zero at zero rotation. + if (state.mode === 'resize' && angle) { + const centreShiftX = state.startFields.x + state.startFields.width / 2 - (next.x + next.width / 2) + const centreShiftY = state.startFields.y + state.startFields.height / 2 - (next.y + next.height / 2) + + // The rotation is a screen-space one, so the displacement has to be rotated in pixel space - + // fraction space scales its two axes differently unless the content bounds are square + const dx = centreShiftX * contentBoundsPx.width + const dy = centreShiftY * contentBoundsPx.height + const [rx, ry] = rotateVector(dx, dy, angle) + + next.x += (dx - rx) / contentBoundsPx.width + next.y += (dy - ry) / contentBoundsPx.height + } + + setSnapLines(lines) + liveFieldsRef.current = next + setLiveFields(next) + }, + [canvas, contentBoundsPx, linkedRef, snapEnabledRef, angle] + ) + + const onPointerUp = useCallback(() => { + const state = dragState.current + dragState.current = null + detachListenersRef.current?.() + detachListenersRef.current = null + setSnapLines({ x: null, y: null }) + + if (!state) return + + // Read the final value from the ref rather than a setState updater - `commit` triggers a mutation, + // and side effects inside a setState updater can be invoked more than once by React and have caused + // "Maximum update depth exceeded" crashes here. + const finalFields = liveFieldsRef.current + if (!finalFields) { + liveFieldsRef.current = null + setLiveFields(null) + return + } + + // Snap the displayed overlay to the exact rounded value that's being committed, and hold it there + // (via pendingCommitRef, resolved in the effect below) instead of clearing it - see the comment on + // pendingCommitRef for why. + const rounded = roundFields(finalFields) + liveFieldsRef.current = rounded + setLiveFields(rounded) + pendingCommitRef.current = rounded + + const changedKeys = state.mode === 'move' ? (['x', 'y'] as const) : (['x', 'y', 'width', 'height'] as const) + commit(rounded, changedKeys, state.targetId) + }, [commit]) + + // Once the server-confirmed bounds (via props) match what was last committed, drop back to tracking + // `boundsFields` directly so future prop updates (eg from someone else editing) are reflected live. + useEffect(() => { + const pending = pendingCommitRef.current + if (!pending || !boundsFields || dragState.current) return + + const settled = (['x', 'y', 'width', 'height'] as const).every( + (key) => Math.abs(boundsFields[key] - pending[key]) < ROUND_STEP / 2 + ) + if (settled) { + pendingCommitRef.current = null + liveFieldsRef.current = null + setLiveFields(null) + } + }, [boundsFields]) + + const startDrag = useCallback( + (mode: 'move' | 'resize', corner: Corner | undefined, e: React.PointerEvent) => { + if (!boundsFields) return + e.preventDefault() + e.stopPropagation() + + // Latch the drag synchronously so a fast alt-drag isn't dropped while the duplicate is in flight + const state: DragState = { + mode, + corner, + startClientX: e.clientX, + startClientY: e.clientY, + startFields: boundsFields, + targetId: elementId, + snapTargetsX: collectSnapTargets(elementRects, contentBoundsPx, elementId, 'x'), + snapTargetsY: collectSnapTargets(elementRects, contentBoundsPx, elementId, 'y'), + } + dragState.current = state + + // Alt-drag leaves the original in place and drags an exact copy instead. The clone is inserted + // directly above the original, so it starts from the same bounds - no coordinate fixup needed. + if (mode === 'move' && e.altKey) { + duplicateElementMutation + .mutateAsync({ controlId, elementId }) + .then((newId) => { + if (typeof newId !== 'string') return + // Ignore a resolve that lands after the drag already ended + if (dragState.current !== state) return + + state.targetId = newId + onSelectElement(newId) + }) + .catch((err) => console.error('Failed to duplicate element', err)) + } + + window.addEventListener('pointermove', onPointerMove) + window.addEventListener('pointerup', onPointerUp) + detachListenersRef.current = () => { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', onPointerUp) + } + }, + [ + boundsFields, + onPointerMove, + onPointerUp, + elementId, + controlId, + duplicateElementMutation, + onSelectElement, + elementRects, + contentBoundsPx, + ] + ) + + const percentOf = (value: number, total: number) => `${(value / total) * 100}%` + + // A selection the overlay can't drag still gets an outline, so it's clear what's selected. The reason why + // is carried by the quick-actions toolbar's tooltip - this outline is click-through, so it can't be hovered. + if (!isInteractive || !boundsFields) { + if (!selectedElementRect) return null + + // The element may sit inside rotated groups, so place its box at the rotated centre and let one CSS + // rotation carry the whole chain (see resolveMarkerTransform) + const transform = resolveMarkerTransform({ + bounds: selectedElementRect.rect, + rotations: selectedElementRect.rotations, + }) + + const readonlyStyle: React.CSSProperties = { + position: 'absolute', + left: percentOf(transform.centerX - transform.width / 2, canvasSizePx.width), + top: percentOf(transform.centerY - transform.height / 2, canvasSizePx.height), + width: percentOf(transform.width, canvasSizePx.width), + height: percentOf(transform.height, canvasSizePx.height), + transform: transform.angle ? `rotate(${transform.angle}deg)` : undefined, + border: '1px dashed rgba(255, 255, 255, 0.6)', + outline: '1px dashed rgba(0, 0, 0, 0.4)', + boxSizing: 'border-box', + pointerEvents: 'none', + } + + return
+ } + + const displayFields = liveFields ?? boundsFields + const isDragging = liveFields !== null + + // Convert the element's fraction-of-contentBounds into a percentage of the full canvas box, + // since that's the box the overlay is absolutely positioned over + const xPx = contentBoundsPx.x + displayFields.x * contentBoundsPx.width + const yPx = contentBoundsPx.y + displayFields.y * contentBoundsPx.height + const widthPx = displayFields.width * contentBoundsPx.width + const heightPx = displayFields.height * contentBoundsPx.height + + const style: React.CSSProperties = { + position: 'absolute', + left: percentOf(xPx, canvasSizePx.width), + top: percentOf(yPx, canvasSizePx.height), + width: percentOf(widthPx, canvasSizePx.width), + height: percentOf(heightPx, canvasSizePx.height), + // Rotating the box carries the resize handles round with it, since they're its children + transform: angle ? `rotate(${angle}deg)` : undefined, + border: isDragging ? '1px dashed #2276d2' : '1px solid transparent', + cursor: 'move', + pointerEvents: 'auto', + boxSizing: 'border-box', + } + + return ( + <> + {snapLines.x !== null && ( + + )} + {snapLines.y !== null && ( + + )} +
startDrag('move', undefined, e)}> + {(['nw', 'ne', 'sw', 'se'] as const).map((corner) => ( + startDrag('resize', corner, e)} /> + ))} +
+ + ) +}) + +function ResizeHandle({ corner, onPointerDown }: { corner: Corner; onPointerDown: (e: React.PointerEvent) => void }) { + const style: React.CSSProperties = { + position: 'absolute', + width: 10, + height: 10, + background: '#2276d2', + border: '1px solid #fff', + borderRadius: 2, + top: corner.includes('n') ? 0 : undefined, + bottom: corner.includes('s') ? 0 : undefined, + left: corner.includes('w') ? 0 : undefined, + right: corner.includes('e') ? 0 : undefined, + transform: `translate(${corner.includes('w') ? '-50%' : '50%'}, ${corner.includes('n') ? '-50%' : '50%'})`, + cursor: corner === 'nw' || corner === 'se' ? 'nwse-resize' : 'nesw-resize', + pointerEvents: 'auto', + } + + return
+} diff --git a/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/SnapGuide.tsx b/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/SnapGuide.tsx new file mode 100644 index 0000000000..9b3f854b5e --- /dev/null +++ b/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/SnapGuide.tsx @@ -0,0 +1,27 @@ +/** + * A guide line drawn across the preview at a snapped position. + * + * Positioned as a percentage of the overlay box, like everything else the overlays render: the canvas is + * resized to fit its container, so a backing-pixel offset would drift away from the position it marks. + */ +export function SnapGuide({ + orientation, + positionPercent, +}: { + orientation: 'vertical' | 'horizontal' + positionPercent: string +}): React.JSX.Element { + const vertical = orientation === 'vertical' + + const style: React.CSSProperties = { + // Blue, to stay distinct from the red bounds lines the renderer draws around the selected element + position: 'absolute', + background: '#00a3ff', + pointerEvents: 'none', + ...(vertical + ? { left: positionPercent, top: 0, bottom: 0, width: 1 } + : { top: positionPercent, left: 0, right: 0, height: 1 }), + } + + return
+} diff --git a/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/__tests__/elementHitTest.test.ts b/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/__tests__/elementHitTest.test.ts new file mode 100644 index 0000000000..53a5bd38f2 --- /dev/null +++ b/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/__tests__/elementHitTest.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, test } from 'vitest' +import type { ElementGeometry } from '@companion-app/shared/Graphics/Geometry.js' +import { DrawBounds } from '@companion-app/shared/Graphics/Util.js' +import type { SomeButtonGraphicsDrawElement } from '@companion-app/shared/Model/StyleLayersModel.js' +import { filterElementRects, hitTestElements } from '../elementHitTest.js' + +const NO_HIDDEN: ReadonlySet = new Set() + +// Ids the edited model knows about. Composite internals are deliberately absent from this set. +function selectable(...ids: string[]): ReadonlySet { + return new Set(ids) +} + +function base(id: string) { + return { id, usage: 'automatic', enabled: true, opacity: 1, contentHash: id } as const +} + +function box(id: string): SomeButtonGraphicsDrawElement { + return { + ...base(id), + type: 'box', + x: 0, + y: 0, + width: 1, + height: 1, + rotation: 0, + color: 0, + borderWidth: 0, + borderColor: 0, + borderPosition: 'inside', + } as unknown as SomeButtonGraphicsDrawElement +} + +function group(id: string, children: SomeButtonGraphicsDrawElement[]): SomeButtonGraphicsDrawElement { + return { + ...base(id), + type: 'group', + x: 0, + y: 0, + width: 1, + height: 1, + rotation: 0, + squareCoords: false, + children, + } as unknown as SomeButtonGraphicsDrawElement +} + +/** + * Stand-in for what the renderer emits. The bounds composition itself is the renderer's job (and is + * covered by its own tests) - these only exercise the editor's policy on top of it. + */ +function geom(id: string, x: number, y: number, width: number, height: number, rotation = 0): ElementGeometry { + const bounds = new DrawBounds(x, y, width, height) + return { id, bounds, rotations: rotation ? [{ pivot: bounds, angle: rotation }] : [] } +} + +describe('filterElementRects', () => { + test('keeps a selectable element, marking a root element as top-level', () => { + const rects = filterElementRects([geom('a', 10, 20, 50, 25)], [box('a')], NO_HIDDEN, selectable('a')) + + expect(rects).toHaveLength(1) + expect(rects[0]).toMatchObject({ + id: 'a', + isTopLevel: true, + rect: { x: 10, y: 20, width: 50, height: 25 }, + }) + }) + + test('a group child is kept, but not marked top-level', () => { + const rects = filterElementRects( + [geom('g', 0, 0, 100, 100), geom('child', 0, 0, 50, 100)], + [group('g', [box('child')])], + NO_HIDDEN, + selectable('g', 'child') + ) + + expect(rects.map((r) => r.id)).toEqual(['g', 'child']) + expect(rects[1].isTopLevel).toBe(false) + }) + + test('carries the rotations through', () => { + const rects = filterElementRects([geom('a', 0, 0, 10, 10, 30)], [box('a')], NO_HIDDEN, selectable('a')) + + expect(rects[0].rotations).toEqual([{ pivot: new DrawBounds(0, 0, 10, 10), angle: 30 }]) + }) + + test('skips the canvas background', () => { + const canvas = { ...base('bg'), type: 'canvas' } as unknown as SomeButtonGraphicsDrawElement + const rects = filterElementRects( + [geom('bg', 0, 0, 100, 100), geom('a', 0, 0, 100, 100)], + [canvas, box('a')], + NO_HIDDEN, + selectable('bg', 'a') + ) + + expect(rects.map((r) => r.id)).toEqual(['a']) + }) + + test('skips disabled and hidden elements', () => { + const disabled = { ...box('off'), enabled: false } as SomeButtonGraphicsDrawElement + const rects = filterElementRects( + [geom('off', 0, 0, 100, 100), geom('hidden', 0, 0, 100, 100), geom('shown', 0, 0, 100, 100)], + [disabled, box('hidden'), box('shown')], + new Set(['hidden']), + selectable('off', 'hidden', 'shown') + ) + + expect(rects.map((r) => r.id)).toEqual(['shown']) + }) + + test('drops reference children, which cannot be selected individually', () => { + const reference = { + ...base('ref'), + type: 'reference', + x: 0, + y: 0, + width: 1, + height: 1, + rotation: 0, + children: [box('inner')], + } as unknown as SomeButtonGraphicsDrawElement + + const rects = filterElementRects( + [geom('ref', 0, 0, 100, 100), geom('inner', 0, 0, 100, 100)], + [reference], + NO_HIDDEN, + selectable('ref') + ) + + expect(rects.map((r) => r.id)).toEqual(['ref']) + }) + + test('keeps only the composite itself, not the internal children it renders as', () => { + // A composite is converted to a group whose children carry generated ids absent from the edited model + const composite = group('comp', [box('comp-abc123/inner')]) + const rects = filterElementRects( + [geom('comp', 0, 0, 100, 100), geom('comp-abc123/inner', 0, 0, 100, 100)], + [composite], + NO_HIDDEN, + selectable('comp') + ) + + expect(rects.map((r) => r.id)).toEqual(['comp']) + }) + + test('pads a thin line out to a grabbable thickness, without moving its centre', () => { + const line = { + ...base('l'), + type: 'line', + fromX: 0, + fromY: 0.5, + toX: 1, + toY: 0.5, + borderWidth: 0.01, + borderColor: 0, + borderPosition: 'center', + } as unknown as SomeButtonGraphicsDrawElement + + // The renderer only pads the line out to its 1px stroke + const rects = filterElementRects([geom('l', 10, 49.5, 100, 1)], [line], NO_HIDDEN, selectable('l')) + + expect(rects[0].rect).toMatchObject({ x: 10, y: 46, width: 100, height: 8 }) + }) + + test('leaves a line that already exceeds the minimum thickness alone', () => { + const line = { + ...base('l'), + type: 'line', + fromX: 0, + fromY: 0, + toX: 1, + toY: 1, + borderWidth: 0.01, + borderColor: 0, + borderPosition: 'center', + } as unknown as SomeButtonGraphicsDrawElement + + const rects = filterElementRects([geom('l', 10, 20, 100, 100)], [line], NO_HIDDEN, selectable('l')) + + expect(rects[0].rect).toMatchObject({ x: 10, y: 20, width: 100, height: 100 }) + }) +}) + +describe('hitTestElements', () => { + test('returns null over empty space', () => { + const rects = filterElementRects([geom('a', 0, 0, 25, 25)], [box('a')], NO_HIDDEN, selectable('a')) + + expect(hitTestElements(rects, 100, 100)).toBeNull() + }) + + test('prefers the top-most of two overlapping elements', () => { + const rects = filterElementRects( + [geom('under', 0, 0, 100, 100), geom('over', 0, 0, 100, 100)], + [box('under'), box('over')], + NO_HIDDEN, + selectable('under', 'over') + ) + + expect(hitTestElements(rects, 60, 70)?.id).toBe('over') + }) + + test('prefers a group child over the group containing it', () => { + const rects = filterElementRects( + [geom('g', 0, 0, 100, 100), geom('child', 0, 0, 100, 100)], + [group('g', [box('child')])], + NO_HIDDEN, + selectable('g', 'child') + ) + + expect(hitTestElements(rects, 60, 70)?.id).toBe('child') + }) + + test('hits the group itself where the child does not cover it', () => { + const rects = filterElementRects( + [geom('g', 0, 0, 100, 100), geom('child', 0, 0, 25, 25)], + [group('g', [box('child')])], + NO_HIDDEN, + selectable('g', 'child') + ) + + expect(hitTestElements(rects, 90, 90)?.id).toBe('g') + }) + + test('a rotated element is picked in its rotated frame, not its bounding box', () => { + // A 40x20 box centred at (50, 50), rotated 90° - it now covers x 40-60, y 30-70 + const rects = filterElementRects([geom('a', 30, 40, 40, 20, 90)], [box('a')], NO_HIDDEN, selectable('a')) + + expect(hitTestElements(rects, 50, 65)?.id).toBe('a') // inside the rotated body, outside the unrotated one + expect(hitTestElements(rects, 65, 50)).toBeNull() // inside the unrotated body, outside the rotated one + }) +}) diff --git a/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/__tests__/fitCanvasSize.test.ts b/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/__tests__/fitCanvasSize.test.ts new file mode 100644 index 0000000000..9d88731294 --- /dev/null +++ b/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/__tests__/fitCanvasSize.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'vitest' +import { fitCanvasSize, parseAspectRatio } from '../canvasSize.js' + +// The canvas adds 10px of padding on each side, so a container loses 20px per axis +const PAD = 20 + +describe('parseAspectRatio', () => { + test('parses a "w:h" pair into a ratio', () => { + expect(parseAspectRatio('2:1')).toBe(2) + expect(parseAspectRatio('9:7')).toBeCloseTo(9 / 7) + expect(parseAspectRatio('1:1')).toBe(1) + }) + + test('falls back to square for anything malformed', () => { + for (const input of ['', 'abc', '1', '1:0', '0:1', '-2:1', '1:x']) { + expect(parseAspectRatio(input)).toBe(1) + } + }) +}) + +describe('fitCanvasSize', () => { + test('fills the limiting axis when the container is wider than the ratio needs', () => { + // 1:1 in a 500x200 box is height-limited + expect(fitCanvasSize('1:1', 500, 200 + PAD)).toEqual({ width: 200, height: 200 }) + }) + + test('fills the limiting axis when the container is taller than the ratio needs', () => { + // 1:1 in a 150x500 box is width-limited + expect(fitCanvasSize('1:1', 150 + PAD, 500)).toEqual({ width: 150, height: 150 }) + }) + + test('honours a non-square ratio', () => { + const { width, height } = fitCanvasSize('2:1', 300 + PAD, 500) + + expect(width / height).toBeCloseTo(2) + expect(width).toBe(300) + }) + + test('never exceeds either container axis', () => { + for (const ratio of ['1:1', '2:1', '9:7']) { + const { width, height } = fitCanvasSize(ratio, 300, 180) + + expect(width + PAD).toBeLessThanOrEqual(300) + expect(height + PAD).toBeLessThanOrEqual(180) + } + }) + + test('falls back to a usable size before the container has been measured', () => { + const { width, height } = fitCanvasSize('1:1', 0, 0) + + expect(width).toBeGreaterThan(0) + expect(height).toBeGreaterThan(0) + }) + + test('does not grow without bound on a very large container', () => { + const { width, height } = fitCanvasSize('1:1', 5000, 5000) + + expect(Math.max(width, height)).toBeLessThanOrEqual(360) + }) + + test('keeps the aspect ratio when capped by the maximum size', () => { + const { width, height } = fitCanvasSize('2:1', 5000, 5000) + + expect(width / height).toBeCloseTo(2) + }) +}) diff --git a/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/__tests__/snapping.test.ts b/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/__tests__/snapping.test.ts new file mode 100644 index 0000000000..199b7fc526 --- /dev/null +++ b/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/__tests__/snapping.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from 'vitest' +import { DrawBounds } from '@companion-app/shared/Graphics/Util.js' +import type { ElementRect, PixelRect } from '../elementHitTest.js' +import { collectSnapTargets, SNAP_THRESHOLD_PX, snapAxis, thresholdFractionFor } from '../snapping.js' + +const CONTENT: PixelRect = { x: 10, y: 20, width: 100, height: 100 } + +function rect( + id: string, + x: number, + y: number, + width: number, + height: number, + isTopLevel = true, + rotation = 0 +): ElementRect { + const bounds = new DrawBounds(x, y, width, height) + return { id, rect: bounds, rotations: rotation ? [{ pivot: bounds, angle: rotation }] : [], isTopLevel } +} + +describe('thresholdFractionFor', () => { + test('converts the pixel threshold into a fraction of the axis extent', () => { + expect(thresholdFractionFor(100)).toBe(SNAP_THRESHOLD_PX / 100) + }) + + test('returns 0 for a degenerate extent rather than dividing by zero', () => { + expect(thresholdFractionFor(0)).toBe(0) + }) +}) + +describe('collectSnapTargets', () => { + test('always offers the content edges and centre', () => { + expect(collectSnapTargets([], CONTENT, 'none', 'x')).toEqual([0, 0.5, 1]) + }) + + test('adds the leading, centre and trailing edges of other elements, in fraction space', () => { + // A 25px-wide element starting 25px into the content area + const targets = collectSnapTargets([rect('other', 35, 20, 25, 100)], CONTENT, 'me', 'x') + + expect(targets).toEqual([0, 0.5, 1, 0.25, 0.375, 0.5]) + }) + + test('excludes the element being dragged', () => { + expect(collectSnapTargets([rect('me', 35, 20, 25, 100)], CONTENT, 'me', 'x')).toEqual([0, 0.5, 1]) + }) + + test('excludes rotated elements, whose unrotated edges are not where they appear', () => { + const rotated = rect('other', 35, 20, 25, 100, true, 30) + + expect(collectSnapTargets([rotated], CONTENT, 'me', 'x')).toEqual([0, 0.5, 1]) + }) + + test('excludes nested elements, which are not valid snap targets', () => { + const nested = rect('child', 35, 20, 25, 100, false) + + expect(collectSnapTargets([nested], CONTENT, 'me', 'x')).toEqual([0, 0.5, 1]) + }) +}) + +describe('snapAxis', () => { + test('returns null when nothing is within the threshold', () => { + expect(snapAxis([0.4], [0, 1], 0.05)).toBeNull() + }) + + test('snaps to the nearest target and reports the delta needed', () => { + const result = snapAxis([0.48], [0, 0.5, 1], 0.05) + + expect(result?.line).toBe(0.5) + expect(result?.delta).toBeCloseTo(0.02) + }) + + test('picks the smallest correction across several candidates', () => { + // The trailing edge (0.98) is closer to 1 than the leading edge (0.03) is to 0 + const result = snapAxis([0.03, 0.98], [0, 1], 0.05) + + expect(result?.line).toBe(1) + expect(result?.delta).toBeCloseTo(0.02) + }) + + test('includes a target exactly at the threshold', () => { + expect(snapAxis([0.05], [0], 0.05)?.line).toBe(0) + }) + + test('excludes a target just beyond the threshold', () => { + expect(snapAxis([0.0501], [0], 0.05)).toBeNull() + }) + + test('produces a negative delta when the target is behind the candidate', () => { + const result = snapAxis([0.52], [0.5], 0.05) + + expect(result?.delta).toBeCloseTo(-0.02) + }) +}) diff --git a/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/boundsFields.ts b/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/boundsFields.ts new file mode 100644 index 0000000000..63a1ea1211 --- /dev/null +++ b/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/boundsFields.ts @@ -0,0 +1,87 @@ +import type { JsonValue } from 'type-fest' +import type { ExpressionOrValue } from '@companion-app/shared/Model/Options.js' +import type { SomeButtonGraphicsElement } from '@companion-app/shared/Model/StyleLayersModel.js' + +export type BoundsKey = 'x' | 'y' | 'width' | 'height' +export type BoundsFractions = Record + +const BOUNDS_KEYS = ['x', 'y', 'width', 'height'] as const + +/** Lines have no bounds - they're defined by their two endpoints instead */ +export type LineKey = 'fromX' | 'fromY' | 'toX' | 'toY' +export type LineFractions = Record + +export const LINE_KEYS = ['fromX', 'fromY', 'toX', 'toY'] as const + +/** Smallest size a drag/resize will leave an element at, as a fraction of its parent bounds */ +export const MIN_FRACTION_SIZE = 0.02 + +/** One decimal place of a percentage - the precision bounds are stored at (0.001 fraction = 0.1%) */ +export const ROUND_STEP = 0.001 + +export function roundFraction(value: number): number { + return Math.round(value / ROUND_STEP) * ROUND_STEP +} + +/** A 0-1 fraction as the percentage the model stores, rounded to one decimal place and free of float noise */ +export function fractionToStoredPercent(fraction: number): number { + return Math.round(fraction * 1000) / 10 +} + +export function roundFields(fields: Record): Record { + const out = {} as Record + for (const key of Object.keys(fields) as Key[]) { + out[key] = roundFraction(fields[key]) + } + return out +} + +/** + * Read an element's bounds as 0-1 fractions, or null if it has none or any is expression-driven (in which + * case the value isn't ours to overwrite). + * + * The stored values are percentages (0-100, matching the "X %" / "Width %" field labels), normalised here + * to the fraction-of-parent-bounds space used by the drag/resize math and GraphicsLayeredButtonRenderer. + */ +export function getDraggableBoundsFields(element: SomeButtonGraphicsElement): BoundsFractions | null { + if (!('x' in element) || !('width' in element)) return null + + const raw = element as unknown as Record | undefined> + const out: Partial = {} + for (const key of BOUNDS_KEYS) { + const field = raw[key] + if (!field || field.isExpression) return null + out[key] = field.value / 100 + } + return out as BoundsFractions +} + +/** + * Read a line element's endpoints as 0-1 fractions, or null if any is expression-driven. + * + * Lines carry `fromX`/`fromY`/`toX`/`toY` percentages instead of the `x`/`y`/`width`/`height` every other + * element type has, so they need their own reader. + */ +export function getDraggableLineFields(element: SomeButtonGraphicsElement): LineFractions | null { + if (element.type !== 'line') return null + + const out: Partial = {} + for (const key of LINE_KEYS) { + const field = element[key] + if (!field || field.isExpression) return null + out[key] = field.value / 100 + } + return out as LineFractions +} + +/** Build the mutation payload for a set of option keys, converting fractions back to stored percentages. */ +export function buildOptionValues( + fields: Record, + changedKeys: readonly Key[] +): Record> { + const values: Record> = {} + for (const key of changedKeys) { + values[key] = { isExpression: false, value: fractionToStoredPercent(fields[key]) as JsonValue } + } + return values +} diff --git a/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/canvasSize.ts b/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/canvasSize.ts new file mode 100644 index 0000000000..6866253f1b --- /dev/null +++ b/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/canvasSize.ts @@ -0,0 +1,55 @@ +/** Padding the preview canvas leaves around the button drawing area, per side */ +export const PAD_X = 10 +export const PAD_Y = 10 + +/** Used until the container has been measured, and as a floor so the canvas never collapses */ +const MIN_CANVAS_SIZE = 80 +/** Keeps the button a sensible size on a very large panel */ +const MAX_CANVAS_SIZE = 360 + +/** Parse "w:h" into a width/height ratio, falling back to square for anything malformed */ +export function parseAspectRatio(aspectRatio: string): number { + const [w, h] = aspectRatio.split(':').map(Number) + if (!isFinite(w) || !isFinite(h) || w <= 0 || h <= 0) return 1 + return w / h +} + +/** + * Largest button drawing area of the given aspect ratio that fits the container, allowing for the padding + * the canvas adds around it. + * + * This is done in JS rather than CSS because the canvas wrapper has to shrink-wrap the canvas exactly - the + * selection overlay positions itself as a percentage of that box - which leaves the canvas's own + * `max-height: 100%` resolving against an auto-height parent, so it would never scale down. + */ +export function fitCanvasSize( + aspectRatio: string, + containerWidth: number, + containerHeight: number +): { width: number; height: number } { + const ratio = parseAspectRatio(aspectRatio) + + const availableWidth = containerWidth - PAD_X * 2 + const availableHeight = containerHeight - PAD_Y * 2 + + // Before the first measurement there's nothing to fit to, so start from the floor + if (availableWidth <= 0 || availableHeight <= 0) { + return ratio >= 1 + ? { width: MIN_CANVAS_SIZE * ratio, height: MIN_CANVAS_SIZE } + : { width: MIN_CANVAS_SIZE, height: MIN_CANVAS_SIZE / ratio } + } + + // Fit to whichever axis runs out first + let width = availableWidth + let height = width / ratio + if (height > availableHeight) { + height = availableHeight + width = height * ratio + } + + const scale = Math.min(1, MAX_CANVAS_SIZE / Math.max(width, height)) + return { + width: Math.max(MIN_CANVAS_SIZE, Math.floor(width * scale)), + height: Math.max(MIN_CANVAS_SIZE, Math.floor(height * scale)), + } +} diff --git a/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/elementHitTest.ts b/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/elementHitTest.ts new file mode 100644 index 0000000000..0ad53e3b0a --- /dev/null +++ b/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/elementHitTest.ts @@ -0,0 +1,110 @@ +import { + inverseRotatePointThroughRotations, + type ElementGeometry, + type MarkerRotation, +} from '@companion-app/shared/Graphics/Geometry.js' +import { DrawBounds } from '@companion-app/shared/Graphics/Util.js' +import type { SomeButtonGraphicsDrawElement } from '@companion-app/shared/Model/StyleLayersModel.js' + +export interface PixelRect { + x: number + y: number + width: number + height: number +} + +export interface ElementRect { + id: string + /** Unrotated rect in the canvas's backing-pixel space */ + rect: DrawBounds + /** Rotations applied to `rect`, outermost first */ + rotations: MarkerRotation[] + /** Top-level elements are the only ones the drag/resize overlay can edit */ + isTopLevel: boolean +} + +/** Minimum clickable thickness given to a line's bounding box, in canvas backing pixels */ +const LINE_HIT_THICKNESS_PX = 8 + +/** Net rotation of an element in degrees, 0 when it sits in an unrotated frame. */ +export function netRotation(rotations: readonly MarkerRotation[]): number { + let total = 0 + for (const rotation of rotations) total += rotation.angle + return total +} + +/** + * Narrow the renderer's geometry down to the elements the editor lets the user pick, in draw order + * (bottom-most first, parents before their children). + * + * The geometry itself - bounds composition, group coordinate spaces, rotation - comes from the renderer + * so it can't drift from what was actually drawn. This only applies the editor's own policy about what + * is selectable. + */ +export function filterElementRects( + geometry: readonly ElementGeometry[], + elements: readonly SomeButtonGraphicsDrawElement[], + hiddenElements: ReadonlySet, + selectableIds: ReadonlySet +): ElementRect[] { + const topLevelIds = new Set(elements.map((element) => element.id)) + const byId = new Map() + const index = (list: readonly SomeButtonGraphicsDrawElement[]) => { + for (const element of list) { + byId.set(element.id, element) + if (element.type === 'group' || element.type === 'reference') index(element.children) + } + } + index(elements) + + const out: ElementRect[] = [] + + for (const entry of geometry) { + const element = byId.get(entry.id) + if (!element) continue + + // The canvas is the background - it fills the button, so treating it as a hit target would swallow + // every click on empty space. + if (element.type === 'canvas') continue + if (!element.enabled || hiddenElements.has(element.id)) continue + + // Composite elements are emitted as groups, but their children are internal and carry generated ids + // that don't exist in the edited model - clicking one must select the composite as a whole. The same + // test keeps us out of reference children, which come from another button entirely. + if (!selectableIds.has(element.id)) continue + + let rect = entry.bounds + + if (element.type === 'line') { + // The renderer only pads a line's bounds out to its stroke thickness, which is all but impossible + // to click on a thin line, so give every line rect a minimum grabbable thickness. + const padX = Math.max(0, (LINE_HIT_THICKNESS_PX - rect.width) / 2) + const padY = Math.max(0, (LINE_HIT_THICKNESS_PX - rect.height) / 2) + rect = new DrawBounds(rect.x - padX, rect.y - padY, rect.width + padX * 2, rect.height + padY * 2) + } + + out.push({ id: element.id, rect, rotations: entry.rotations, isTopLevel: topLevelIds.has(element.id) }) + } + + return out +} + +/** Find the top-most element containing the point, or null when the point is over empty space. */ +export function hitTestElements(rects: readonly ElementRect[], x: number, y: number): ElementRect | null { + for (let i = rects.length - 1; i >= 0; i--) { + const entry = rects[i] + const { rect } = entry + + // Undo the element's rotations to test the point in the frame the rect is expressed in + const [localX, localY] = inverseRotatePointThroughRotations(entry.rotations, x, y) + + if (localX >= rect.x && localX <= rect.x + rect.width && localY >= rect.y && localY <= rect.y + rect.height) { + return entry + } + } + return null +} + +export function findElementRect(rects: readonly ElementRect[], id: string): ElementRect | undefined { + return rects.find((entry) => entry.id === id) +} diff --git a/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/snapping.ts b/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/snapping.ts new file mode 100644 index 0000000000..2856a2a58a --- /dev/null +++ b/webui/src/Buttons/EditButton/LayeredButtonEditor/Preview/snapping.ts @@ -0,0 +1,67 @@ +import { netRotation, type ElementRect, type PixelRect } from './elementHitTest.js' + +/** How close (in canvas backing pixels) an edge must be to a target before it snaps */ +export const SNAP_THRESHOLD_PX = 5 + +export interface SnapResult { + /** Correction to add to the moving edges, in fraction-of-content space */ + delta: number + /** The target that was snapped to, in fraction-of-content space, for drawing a guide */ + line: number +} + +/** + * Collect snap targets for one axis, in fraction-of-content space: the content edges and centre, plus the + * leading/centre/trailing edges of every other top-level element. + */ +export function collectSnapTargets( + rects: readonly ElementRect[], + contentBoundsPx: PixelRect, + excludeId: string, + axis: 'x' | 'y' +): number[] { + const origin = axis === 'x' ? contentBoundsPx.x : contentBoundsPx.y + const extent = axis === 'x' ? contentBoundsPx.width : contentBoundsPx.height + if (extent <= 0) return [0, 0.5, 1] + + const targets = [0, 0.5, 1] + + for (const entry of rects) { + if (!entry.isTopLevel || entry.id === excludeId) continue + // A rotated element's unrotated edges aren't where the user sees them, so it makes a misleading target + if (netRotation(entry.rotations)) continue + + const start = ((axis === 'x' ? entry.rect.x : entry.rect.y) - origin) / extent + const size = (axis === 'x' ? entry.rect.width : entry.rect.height) / extent + targets.push(start, start + size / 2, start + size) + } + + return targets +} + +/** + * Find the smallest correction that brings any one of `candidates` onto a target within the threshold. + * Candidates and targets are both in fraction-of-content space. + */ +export function snapAxis( + candidates: readonly number[], + targets: readonly number[], + thresholdFraction: number +): SnapResult | null { + let best: SnapResult | null = null + + for (const candidate of candidates) { + for (const target of targets) { + const delta = target - candidate + if (Math.abs(delta) > thresholdFraction) continue + if (!best || Math.abs(delta) < Math.abs(best.delta)) best = { delta, line: target } + } + } + + return best +} + +/** Convert the pixel snap threshold into a fraction of the content bounds on one axis. */ +export function thresholdFractionFor(contentExtentPx: number): number { + return contentExtentPx > 0 ? SNAP_THRESHOLD_PX / contentExtentPx : 0 +} diff --git a/webui/src/Buttons/EditButton/LayeredButtonEditor/StyleStore.tsx b/webui/src/Buttons/EditButton/LayeredButtonEditor/StyleStore.tsx index c86868c99f..a8bb17b9c5 100644 --- a/webui/src/Buttons/EditButton/LayeredButtonEditor/StyleStore.tsx +++ b/webui/src/Buttons/EditButton/LayeredButtonEditor/StyleStore.tsx @@ -48,11 +48,31 @@ export class LayeredStyleStore { return toJS(this.#hiddenElements) } + /** + * Ids of every element the user can select, including those nested in groups. Used to tell real elements + * apart from the internal children a composite contributes to the rendered output, which carry generated + * ids that aren't part of this model. + */ + get selectableElementIds(): Set { + const ids = new Set() + + const collect = (elements: readonly SomeButtonGraphicsElement[]) => { + for (const element of elements) { + ids.add(element.id) + if (element.type === 'group') collect(element.children) + } + } + collect(this.elements) + + return ids + } + constructor() { makeObservable(this, { setSelectedElementId: action, setElementVisibility: action, hiddenElements: computed, // This caches the JS set, allowing for efficient change detection + selectableElementIds: computed, }) } diff --git a/webui/src/scss/_button-edit.scss b/webui/src/scss/_button-edit.scss index ae2bc103e9..a3fb57afc8 100644 --- a/webui/src/scss/_button-edit.scss +++ b/webui/src/scss/_button-edit.scss @@ -351,13 +351,76 @@ } .button-layer-resize-handle { - height: 5px; - background-color: #ddd; + display: flex; + align-items: center; + justify-content: center; + height: 28px; + background-color: #e8e8e8; cursor: row-resize; flex-shrink: 0; + border-block: 1px solid #ddd; + // Lift the bar slightly so it reads as a distinct divider between panels + box-shadow: + 0 1px 3px rgba(0, 0, 0, 0.1), + 0 -1px 2px rgba(0, 0, 0, 0.04); + z-index: 1; &:hover { - background-color: #aaa; + background-color: #dedede; + } + + // Keep pointer cursor on the toggle while the separator forces ns-resize on hover/drag + &[data-separator='hover'], + &[data-separator='inactive'] { + [data-separator-button], + [data-separator-button] * { + cursor: pointer !important; + } + } + + .button-layer-separator-interactive { + display: flex; + align-items: center; + justify-content: center; + } + + .button-layer-mode-toggle.button-group { + background: #ececec; + border: 1px solid #c8c8c8; + border-radius: 6px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); + padding: 2px; + gap: 2px; + + > .button { + min-width: 5.5rem; + padding-block: 0.15rem; + font-size: 0.8rem; + box-shadow: none; + + border-radius: 4px; + border-start-start-radius: 4px; + border-end-start-radius: 4px; + border-start-end-radius: 4px; + border-end-end-radius: 4px; + border-inline-width: var(--btn-border-width); + border-inline-start-color: transparent; + border-inline-end-color: transparent; + + --btn-color: #4a4a4a; + --btn-bg: #fff; + --btn-border-color: transparent; + --btn-hover-color: #080a0c; + --btn-hover-bg: #f5f5f5; + --btn-hover-border-color: transparent; + --btn-active-color: #fff; + --btn-active-bg: #d50215; + --btn-active-border-color: #d50215; + + &.active { + box-shadow: 0 1px 2px rgba(213, 2, 21, 0.35); + } + } } } @@ -370,16 +433,10 @@ background-color: #fff; display: grid; - grid-template-columns: 1fr 1fr; + // minmax(0, …) so the canvas column can't grow past its half and squeeze the element list + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; - } - - .button-layer-simple { - grid-column: 2; - grid-row: 2; - align-self: end; - justify-self: end; - padding-right: 2em; + column-gap: 1rem; } .button-layer-preview { @@ -389,26 +446,204 @@ margin-bottom: 0.75rem; min-height: 0; - > :first-child { - min-height: 0; // allows the canvas container (flex-grow: 1) to shrink below 200px + // Toolbar down the left edge, canvas filling the rest - keeps the layout from growing taller + .button-layer-preview-main { + display: flex; + flex: 1 1 0; + min-height: 0; + } + + // The canvas and the aspect-ratio control share one bordered box, so the ratio control reads as part + // of the preview area rather than a loose block below it. A darker workspace fill sets it off from the + // white element list and the lighter toolbar. + .button-layer-canvas-section { + display: flex; + flex-direction: column; + flex: 1 1 0; + min-width: 0; + min-height: 0; + overflow: hidden; + + background-color: rgb(228, 228, 228); + border: 1px solid rgb(221, 221, 221); + border-radius: 4px; + } + + .button-layer-canvas-container { + display: flex; + align-items: center; + justify-content: center; + + // Basis 0, not auto: the canvas is sized from this container's measured size, so letting the + // container size itself from its content instead would feed back into that measurement. + flex: 1 1 0; + min-width: 0; + min-height: 0; + // Safety net for the frame between a container resize and the canvas being re-sized to match + overflow: hidden; + } + + // A compact control strip at the bottom of the canvas box + .button-layer-canvas-footer { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 0.5rem; + padding: 2px 6px; + border-top: 1px solid rgb(221, 221, 221); + background-color: rgb(245, 245, 245); + + // Truncates rather than pushing the buttons out of the strip when the panel is narrow + .button-layer-footer-label { + white-space: nowrap; + font-size: 0.85em; + color: #4a4a4a; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + + .button-layer-aspect-options { + display: flex; + gap: 2px; + margin-left: auto; + flex: 0 0 auto; + } + + .button-layer-aspect-option { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 24px; + height: 24px; + padding: 0; + border: 1px solid transparent; + border-radius: 3px; + background: transparent; + color: #4a4a4a; + cursor: pointer; + + &:hover:not(.active) { + background: rgb(238, 238, 238); + border-color: rgb(221, 221, 221); + } + + &.active { + background: #2276d2; + border-color: #2276d2; + color: #fff; + } + } + + // The custom option is a popover trigger, which always carries the `btn` class - undo its metrics + // so it keeps the same 24x24 box as the preset buttons + .button-layer-aspect-option.btn { + line-height: 1; + font-size: inherit; + min-width: 0; + } + + // Outlined rectangle drawn to the option's ratio; currentColor flips to white when active + .button-layer-aspect-glyph { + display: block; + border: 1.5px solid currentColor; + border-radius: 1px; + } + } + + // Must shrink-wrap the canvas exactly: the selection overlay positions its handles and snap guides as + // percentages of this box, so any difference between it and the canvas's displayed box offsets them. + .button-layer-canvas-wrapper { + position: relative; + display: inline-block; } .button-layer-canvas { - max-width: 100%; - max-height: 100%; + display: block; + } + + .button-layer-quick-actions { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 4px; + + // Hold its own width beside the canvas rather than being squeezed + flex: 0 0 auto; + + // Set it off from the canvas as a distinct toolbar area + margin-right: 0.5rem; + background-color: rgb(245, 245, 245); + border: 1px solid rgb(221, 221, 221); + border-radius: 4px; + + .button-layer-quick-actions-separator { + align-self: stretch; + height: 1px; + background: rgb(221, 221, 221); + margin: 3px 0; + } + + // Wraps a disabled button so its tooltip can still fire on hover + .button-layer-quick-action-tooltip { + display: flex; + } + + .button-layer-quick-action { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: 1px solid transparent; + border-radius: 3px; + background: transparent; + color: #4a4a4a; + cursor: pointer; + + &:hover:not(:disabled):not(.active) { + background: rgb(238, 238, 238); + border-color: rgb(221, 221, 221); + } + + &.active { + background: #2276d2; + border-color: #2276d2; + color: #fff; + + &:hover:not(:disabled) { + background: #1a5ea3; + border-color: #1a5ea3; + } + } + + &:disabled { + opacity: 0.3; + cursor: default; + // Let the wrapping tooltip trigger receive the hover instead + pointer-events: none; + } + } } } .button-layer-elementlist { - overflow-y: auto; + display: flex; + flex-direction: column; min-height: 0; - .button-layer-elementlist-table { - .heading { - position: sticky; - top: 0; - background-color: #fff; - } + // Only the rows scroll; the heading is a static sibling above them + > .heading { + flex: 0 0 auto; + } + + .button-layer-elementlist-body { + flex: 1 1 auto; + overflow-y: auto; + min-height: 0; } .button-layer-elementlist-table-row { @@ -515,6 +750,17 @@ } } +// Portaled to the body, so it can't live inside the .button-layer-preview block +.button-layer-aspect-custom { + // Sized for the two-digit values these ratios realistically use + width: 135px; + min-width: 0; + padding: 6px; + display: flex; + flex-direction: column; + gap: 4px; +} + .layered-style-element-picker-modal { .modal2-content { // height: 65vh; diff --git a/webui/src/scss/components/_form.scss b/webui/src/scss/components/_form.scss index 74b67e278f..b9f5d5c83d 100644 --- a/webui/src/scss/components/_form.scss +++ b/webui/src/scss/components/_form.scss @@ -110,8 +110,11 @@ z-index: 5; } - > :not(:last-child), - > :not(:last-child) .input-group-borders { + // Base UI's NumberField renders a visually-hidden as a trailing + // sibling, so :last-child would wrongly target it and strip the real last element's + // rounding. Match "has a following visible sibling" instead. + > :has(~ :not([aria-hidden])), + > :has(~ :not([aria-hidden])) .input-group-borders { --border-right-radius: 0; border-top-right-radius: 0; border-bottom-right-radius: 0;