Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0656c7b
feat: wysiwyg toolbar for basic edits to a button's style
bryce-seifert Jul 20, 2026
04b07cf
skip reset while dragState.current is set
bryce-seifert Jul 21, 2026
efae89d
make toolbar vertical to save some space
bryce-seifert Jul 21, 2026
11e9e88
make sure snapping gets disabled state too
bryce-seifert Jul 21, 2026
06d8bbe
add tooltips about why toolbar is disabled
bryce-seifert Jul 21, 2026
c93bdc6
fix canvas size getting too big
bryce-seifert Jul 21, 2026
5db4bb7
try out a toolbar for the aspect ratio
bryce-seifert Jul 21, 2026
ac31c0a
cleaner gap between preview and list
bryce-seifert Jul 21, 2026
5e39fa6
move mode toggle to the separator
bryce-seifert Jul 21, 2026
c9a8f3d
advanced -> all properties; make buttons stand out more
bryce-seifert Aug 2, 2026
d02483b
add custom ratio option
bryce-seifert Aug 2, 2026
f6ce397
allow line endpoints to be manipulated in canvas
bryce-seifert Aug 2, 2026
59b7e20
fix: name bar disappearing in safari
bryce-seifert Aug 2, 2026
f0163f5
keep lines from distorting during move
bryce-seifert Aug 2, 2026
27a9697
fix drag listener cleanup in overlays
bryce-seifert Aug 2, 2026
7d65736
cleanup readonlyReason tooltip
bryce-seifert Aug 2, 2026
e0e6fd2
breakout SnapGuide, use % instead of pixels
bryce-seifert Aug 2, 2026
89215fe
fix: try to ensure module are shutdown cleanly when companion exits
Julusian Aug 2, 2026
596fe13
Merge branch 'main' into feat/wysiwygEditor
Julusian Aug 3, 2026
f5294ef
fix
Julusian Aug 3, 2026
51a5aa8
fix
Julusian Aug 3, 2026
9e20276
fix rotation on overlay, use renderer for element geometry
bryce-seifert Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions companion/lib/Controls/ControlTypes/Button/Layered.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,10 @@ export class ControlButtonLayered
return this.drawing.updateOption(id, key, newVal)
}

layeredStyleUpdateOptions(id: string, values: Record<string, ExpressionOrValue<JsonValue | undefined>>): boolean {
return this.drawing.updateOptions(id, values)
}

layeredStyleUpdateFromLegacyProperties(diff: Partial<ButtonStyleProperties>): boolean {
return this.drawing.updateFromLegacyProperties(diff, this.options.canModifyStyleInApis)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,10 @@ export class LayeredButtonStyleEditor extends LayeredButtonDrawer {
return true
}

updateOption(id: string, key: string, newVal: ExpressionOrValue<JsonValue | undefined>): 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' ||
Expand All @@ -210,14 +211,29 @@ export class LayeredButtonStyleEditor extends LayeredButtonDrawer {
key === 'connectionId' ||
key === 'elementId'
)
return false
}

updateOption(id: string, key: string, newVal: ExpressionOrValue<JsonValue | undefined>): 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<string, ExpressionOrValue<JsonValue | undefined>>): 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)
Expand Down
7 changes: 7 additions & 0 deletions companion/lib/Controls/ControlTypes/Button/Preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,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<string, ExpressionOrValue<JsonValue | undefined>>): boolean {
throw new Error('ControlButtonPreset does not support mutations')
}

/**
* Update the style from legacy properties
*/
Expand Down
8 changes: 8 additions & 0 deletions companion/lib/Controls/IControlFragments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ export interface ControlWithLayeredStyle extends ControlBase<any> {
*/
layeredStyleUpdateOption(id: string, key: string, value: ExpressionOrValue<JsonValue | undefined>): 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<string, ExpressionOrValue<JsonValue | undefined>>): 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
Expand Down
17 changes: 17 additions & 0 deletions companion/lib/Controls/StylesTrpcRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,5 +125,22 @@ export function createStylesTrpcRouter(controlsMap: Map<string, SomeControl<any>

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)
}),
})
}
49 changes: 36 additions & 13 deletions shared-lib/lib/Graphics/LayeredRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,30 +26,51 @@ import { DrawBounds, parseColor, rgbRev } from './Util.js'
const TEXT_OUTLINE_FACTOR = 1 / 16

export class GraphicsLayeredButtonRenderer {
static #computeTopBarBounds(imgWidth: number, imgHeight: number, paddingPx: { x: number; y: number }): DrawBounds {
const drawHeight = imgHeight - paddingPx.y * 2

return new DrawBounds(
paddingPx.x,
paddingPx.y,
imgWidth - paddingPx.x * 2,
Math.max(ButtonDecorationRenderer.DEFAULT_HEIGHT, Math.floor(0.2 * drawHeight))
)
}

/**
* 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(
imgWidth: number,
imgHeight: number,
paddingPx: { x: number; y: number },
decoration: ButtonGraphicsDecorationType
): DrawBounds {
const drawWidth = imgWidth - paddingPx.x * 2
const drawHeight = imgHeight - paddingPx.y * 2

const topBarBounds = this.#computeTopBarBounds(imgWidth, imgHeight, paddingPx)
const topBarHeight = decoration === ButtonGraphicsDecorationType.TopBar ? topBarBounds.height : 0

return new DrawBounds(paddingPx.x, paddingPx.y + topBarHeight, drawWidth, drawHeight - topBarHeight)
}

static async draw(
img: ImageBase<any>,
drawStyle: RendererButtonStyle,
elementsToHide: ReadonlySet<string>,
selectedElementId: string | null,
paddingPx: { x: number; y: number }
): Promise<void> {
): Promise<DrawBounds | null> {
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(
paddingPx.x,
paddingPx.y,
drawWidth,
Math.max(ButtonDecorationRenderer.DEFAULT_HEIGHT, Math.floor(0.2 * drawHeight))
)
const topBarHeight = showTopBar ? topBarBounds.height : 0
const drawBounds = new DrawBounds(paddingPx.x, paddingPx.y + topBarHeight, drawWidth, drawHeight - topBarHeight)
const topBarBounds = this.#computeTopBarBounds(img.width, img.height, paddingPx)
const drawBounds = this.computeContentBounds(img.width, img.height, paddingPx, decoration)

this.#drawBackgroundElement(img, drawBounds, backgroundElement)

Expand Down Expand Up @@ -84,6 +105,8 @@ export class GraphicsLayeredButtonRenderer {

// Draw a border around the selected element, do this last so it's on top
if (selectedElementBounds) this.#drawBoundsLines(img, selectedElementBounds)

return selectedElementBounds
}

/**
Expand Down
48 changes: 25 additions & 23 deletions webui/src/Buttons/EditButton/LayeredButtonEditor/ElementsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,30 +105,32 @@ export const ElementsList = observer(function ElementsList({
return (
<>
<GenericConfirmModal ref={confirmModalRef} />
<div className="button-layer-elementlist-table">
<div className="button-layer-elementlist-table-row heading">
<div className="td-reorder-placeholder">&nbsp;</div>
<div>Name</div>
<div></div>
<div className="element-buttons">
<AddElementDropdownButton styleStore={styleStore} controlId={controlId} />
</div>
<div className="button-layer-elementlist-table-row heading">
<div className="td-reorder-placeholder">&nbsp;</div>
<div>Name</div>
<div></div>
<div className="element-buttons">
<AddElementDropdownButton styleStore={styleStore} controlId={controlId} />
</div>
</div>
<div className="button-layer-elementlist-body">
<div className="button-layer-elementlist-table">
{sortableElements.map((element, index) => (
<ElementListItem
key={element.id}
element={element}
group={ROOT_GROUP}
index={index}
depth={0}
styleStore={styleStore}
confirmModalRef={confirmModalRef}
controlId={controlId}
/>
))}
{canvasElements.map((element) => (
<CanvasElementRow key={element.id} element={element} styleStore={styleStore} />
))}
</div>
{sortableElements.map((element, index) => (
<ElementListItem
key={element.id}
element={element}
group={ROOT_GROUP}
index={index}
depth={0}
styleStore={styleStore}
confirmModalRef={confirmModalRef}
controlId={controlId}
/>
))}
{canvasElements.map((element) => (
<CanvasElementRow key={element.id} element={element} styleStore={styleStore} />
))}
</div>
</>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
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 { useLocalStorage } from 'usehooks-ts'
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'
Expand Down Expand Up @@ -181,18 +181,33 @@ const LayeredButtonEditorStyle = observer(function LayeredButtonEditorStyle({
<div className="button-layer-elementlist">
<ElementsList styleStore={styleStore} controlId={controlId} />
</div>
<div className="button-layer-simple">
<SwitchInputFieldWithLabel
className="text-muted"
label="Simple"
value={simpleMode}
setValue={setSimpleMode}
tooltip={simpleMode ? 'Showing a reduced set of properties' : 'Showing the full set of properties'}
small
/>
</div>
</Panel>
<Separator className="button-layer-resize-handle" />
<Separator className="button-layer-resize-handle">
<SeparatorInteractive>
<ButtonGroup aria-label="Property detail level" className="button-layer-mode-toggle">
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<Button
size="sm"
className={simpleMode ? 'active' : undefined}
aria-pressed={simpleMode}
onClick={() => setSimpleMode(true)}
// pointerdown: claim the gesture before the separator starts a resize (click may be suppressed)
onPointerDown={() => setSimpleMode(true)}
>
Basic
</Button>
<Button
size="sm"
className={!simpleMode ? 'active' : undefined}
aria-pressed={!simpleMode}
onClick={() => setSimpleMode(false)}
onPointerDown={() => setSimpleMode(false)}
title="Show every property for the selected element, including the less commonly used ones"
>
All Properties
</Button>
</ButtonGroup>
</SeparatorInteractive>
</Separator>
<Panel id="bottom" className="button-layer-options" minSize="250px">
{elementProps ? (
<ElementPropertiesEditor
Expand All @@ -211,3 +226,27 @@ const LayeredButtonEditorStyle = observer(function LayeredButtonEditorStyle({
</Group>
)
})

function SeparatorInteractive({ children }: PropsWithChildren): JSX.Element {
const ref = useRef<HTMLDivElement>(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 (
<div ref={ref} className="button-layer-separator-interactive" data-separator-button>
{children}
</div>
)
}
Loading