Proposed specification.
@lgs1920/widgetizer is a framework independent interaction component for editing widgets on a canvas. It provides the selection overlay, pointer and keyboard interactions, transformation handles, constraints, context menu integration, and structured change events required by Studio and other LGS1920 applications.
The first implementation must provide the practical equivalent of the current Studio integration with react-moveable, while exposing a stable LGS1920 model instead of making Moveable events and CSS transforms part of the application contract.
The current reference integration is in Widget.jsx in the Studio repository. It uses react-moveable for dragging, resizing, scaling, rotating, bounds, guides, snapping, edge dragging, crop handles, and ratio constraints. The package is declared as react-moveable ^0.56.0 in Studio.
Moveable remains the functional reference for the first release. Its official capabilities include draggable, resizable, scalable, rotatable, warpable, pinchable, groupable, snappable, clippable, and roundable interactions: Moveable repository.
The package must:
- edit one or more widgets on a canvas
- support move, resize, scale, and rotation interactions
- expose visible and configurable handles
- support click, double-click, pointer and keyboard interactions
- expose a context menu contract without imposing a menu implementation
- support bounds, minimum and maximum dimensions, ratio locking, snapping, and rotation increments
- support locked, hidden, disabled, and read-only widgets
- provide controlled and uncontrolled usage
- produce transaction oriented changes suitable for persistence and undo/redo
- work without React
- provide an optional React adapter for Studio
- allow custom widget rendering and custom action buttons
- keep DOM, CSS, and framework details out of the core model
The first release does not need to provide:
- a complete widget content system
- a built-in application state store
- a built-in context menu component
- a built-in undo/redo store
- a built-in text editor
- video or audio playback
- a complete animation editor
- perspective warp or free-form mesh editing
- collaborative editing
The core must remain extensible so that these features can be added by an adapter or a later package version.
| Term | Meaning |
|---|---|
| Canvas | Coordinate space in which widgets are edited |
| Widget | Application owned visual object controlled by Widgetizer |
| Target | DOM element containing the widget content |
| Selection | One or more selected widget IDs |
| Control box | Overlay containing the widget border, handles, and optional toolbar |
| Interaction | A continuous user gesture such as one drag or one rotation |
| Transaction | The committed before and after state of one interaction |
| Action | An application command such as duplicate, delete, or reset |
| Constraint | A rule limiting a widget geometry or interaction |
The package is split into layers.
@lgs1920/widgetizer-core
model, geometry, constraints, commands, interaction state, events
@lgs1920/widgetizer-dom
pointer events, keyboard events, control box, handles, focus, CSS
@lgs1920/widgetizer-react
React adapter and controlled component API
The core must not import React, access the global document, or depend on a particular UI component library.
The DOM adapter owns event listeners and rendering. The application owns widget data and decides how to persist changes. The React adapter maps props and callbacks to the core without duplicating geometry calculations.
The minimum widget model is:
{
id: 'widget-1',
type: 'text',
geometry: {
x: 120,
y: 80,
width: 400,
height: 180,
rotation: 15,
scaleX: 1,
scaleY: 1,
originX: 0.5,
originY: 0.5,
},
capabilities: {
selectable: true,
draggable: true,
resizable: true,
scalable: true,
rotatable: true,
contextMenu: true,
},
constraints: {
minWidth: 40,
minHeight: 40,
maxWidth: null,
maxHeight: null,
keepRatio: false,
bounds: 'canvas',
},
locked: false,
visible: true,
zIndex: 2,
metadata: {},
}The geometry model uses canvas coordinates and keeps layout dimensions separate from visual scale.
xandyidentify the widget position in canvas coordinateswidthandheightidentify the unscaled layout sizerotationis expressed in degreesscaleXandscaleYexpress visual scalingoriginXandoriginYare normalized values between0and1- the default transform origin is the center of the widget
- negative scales are disabled in the first release unless explicitly enabled
- geometry values must remain finite numbers
Resize changes width and height. Scale changes scaleX and scaleY. This distinction is required for crop zones, text widgets, responsive layouts, and future animation support.
The application provides the rendered target for each widget through one of these mechanisms:
widgetizer.registerTarget('widget-1', element)
widgetizer.unregisterTarget('widget-1')or, in the React adapter:
<WidgetizerTarget id="widget-1">
<WidgetContent />
</WidgetizerTarget>Widgetizer must never assume that the target is a direct child of the canvas.
The DOM API should follow this shape:
const widgetizer = createWidgetizer({
container: canvasElement,
widgets,
selection: [],
options: {
multiple: true,
marquee: true,
keyboard: true,
snapping: true,
},
actions,
onChange: handleChange,
})Required methods:
widgetizer.setWidgets(widgets)
widgetizer.setSelection(ids)
widgetizer.getSelection()
widgetizer.registerTarget(id, element)
widgetizer.unregisterTarget(id)
widgetizer.updateOptions(options)
widgetizer.request(operation, id, payload)
widgetizer.cancelInteraction()
widgetizer.commitInteraction()
widgetizer.destroy()The request method must support programmatic operations without pointer input:
widgetizer.request('move', 'widget-1', {x: 300, y: 180})
widgetizer.request('resize', 'widget-1', {width: 600, height: 240})
widgetizer.request('scale', 'widget-1', {scaleX: 1.2, scaleY: 1.2})
widgetizer.request('rotate', 'widget-1', {rotation: 90})
widgetizer.request('reset-transform', 'widget-1')Programmatic operations must pass through the same constraints and produce the same change events as pointer operations.
When a widget is selected, Widgetizer displays a control box in an overlay layer above the canvas.
The first release provides:
- north, south, east, and west resize handles
- north-east, north-west, south-east, and south-west resize handles
- one rotation handle connected to the control box
- one scale handle with a visual style different from resize handles
- an optional transform origin handle
The application can configure visible handles per widget. Crop widgets may expose only the eight resize handles. A locked widget exposes no active handles.
Resize and scale must not be ambiguous. The default UI uses:
- resize handles for changes to
widthandheight - a dedicated scale handle for changes to
scaleXandscaleY - an optional toolbar mode allowing the corner handles to switch between Resize and Scale
Keyboard modifiers may provide shortcuts, but they must not be the only way to access scale.
Rotation must support:
- free rotation
- configurable snap angles such as
0,45,90, and180 - a configurable snap threshold
- rotation around the current transform origin
- a readable angle indicator during the interaction
Selection behavior is part of Widgetizer rather than being delegated to Moveable.
Required behavior:
- click a widget to select it
- click empty canvas space to clear selection
Shiftclick to add or remove a widget from the selection- optional marquee selection on empty canvas space
- click a selected widget without moving it keeps the selection
- clicking a locked widget may select it but must not start a transform
- hidden widgets are not selectable unless explicitly enabled
The selection API uses IDs rather than DOM elements:
widgetizer.setSelection(['widget-1', 'widget-2'])All pointer interactions must use Pointer Events.
The interaction engine must:
- use pointer capture after an interaction starts
- handle
pointercancel - use a configurable movement threshold before converting a click into a drag
- ignore editable content when configured as an input zone
- support mouse, pen, and touch where the browser supports them
- avoid saving application state on every pointer event
- render intermediate geometry at animation frame rate
- commit a transaction once the interaction ends
The interaction state machine is:
idle
→ pointer-down
→ pending-click or interaction-start
→ moving, resizing, scaling, or rotating
→ interaction-commit or interaction-cancel
→ idle
Widgetizer provides semantic events for the target widget and the exact input target.
widgetizer.addEventListener('widget-click', event => {})
widgetizer.addEventListener('widget-double-click', event => {})The event detail includes:
{
widgetId,
inputTarget,
selection,
clientX,
clientY,
originalEvent,
}The implementation must prevent an accepted double-click from producing two application level click actions. A pointer movement beyond the drag threshold must produce an interaction instead of a click.
Widgetizer provides the context and event. It does not render the application menu.
widgetizer.addEventListener('widget-context-menu', event => {
event.preventDefault()
openContextMenu({
x: event.clientX,
y: event.clientY,
widgetId: event.widgetId,
selection: event.selection,
actions: event.actions,
})
})Action definitions are application supplied:
{
id: 'duplicate',
label: 'Duplicate',
icon: 'copy',
visible: context => !context.locked,
enabled: context => context.selection.length > 0,
run: context => duplicateWidgets(context.selection),
}Generic action IDs may be supplied by the package:
duplicatedeletelockunlockbring-to-frontsend-to-backreset-transformcenter-in-canvas
Widgetizer must not mutate application data when an action is clicked. The action callback remains responsible for the command.
The constraint engine must be shared by pointer and programmatic operations.
Supported constraints:
- minimum and maximum width
- minimum and maximum height
- fixed or dynamic aspect ratio
- canvas bounds
- custom rectangular bounds
- grid snapping
- horizontal and vertical guide snapping
- widget edge and center snapping
- rotation angle snapping
- optional center based resize
- optional resize from the opposite edge
The constraint result must include enough information for the UI to display the active snap or constraint:
{
geometry,
snapped: true,
snapGuides: [
{type: 'vertical', position: 960},
],
clamped: false,
}Multiple selection is represented as an array of widget IDs. The group control box is calculated from the selected widgets.
The first group release must support:
- group move
- group scale
- group rotation
- group resize when all selected widgets allow it
Each member receives its own updated geometry. The committed transaction contains all member changes:
{
type: 'group-transform',
widgetIds: ['widget-1', 'widget-2'],
before: {},
after: {},
}Individual widget constraints remain active during a group operation. A widget that cannot be transformed must either be excluded according to the configured policy or cause the group operation to be rejected before it starts.
The public event names are:
selection-change
widget-click
widget-double-click
widget-context-menu
interaction-start
interaction-change
interaction-commit
interaction-cancel
widget-action
interaction-start includes the operation, widget IDs, and initial geometry.
interaction-change includes the current geometry and constraint information. It may be emitted once per animation frame.
interaction-commit includes a transaction suitable for persistence and undo/redo:
{
id: 'interaction-123',
type: 'rotate',
widgetIds: ['widget-1'],
before: {
'widget-1': {
geometry: {
rotation: 15,
},
},
},
after: {
'widget-1': {
geometry: {
rotation: 45,
},
},
},
source: 'pointer',
}interaction-cancel must restore the last committed geometry.
The control box must be keyboard accessible.
Required behavior:
- selected widgets can receive focus
- arrow keys move the selection
Shiftincreases the movement stepEscapecancels the current interactionEnterstarts or confirms an editable transform when applicable- controls have accessible names
- focus indicators remain visible
- locked and disabled states are exposed to assistive technologies
- no state is communicated by color alone
- reduced motion preferences are respected for UI animations
The package must not depend on pointer input for the only way to move or transform a widget.
The control box must be rendered in a dedicated overlay layer. It must remain aligned when:
- the canvas is scrolled
- the canvas is zoomed
- the browser window is resized
- the target uses a CSS transform
- the target is inside a nested positioned element
The package provides default CSS and CSS custom properties for:
- border color
- handle color
- handle size
- rotation handle distance
- focus color
- disabled opacity
- snap guide color
- toolbar spacing
The host application may replace the default control box renderer while keeping the core interaction engine.
The migration adapter must map the current Studio behavior to Widgetizer options.
| Current Studio behavior | Widgetizer mapping |
|---|---|
draggable |
capabilities.draggable |
resizable |
capabilities.resizable |
scalable |
capabilities.scalable |
rotatable |
capabilities.rotatable |
keepRatio |
constraints.keepRatio |
bounds |
constraints.bounds |
renderDirections |
handles.resize |
elementGuidelines |
snapping.elements |
horizontalGuidelines |
snapping.horizontalGuidelines |
verticalGuidelines |
snapping.verticalGuidelines |
snapRotationDegrees |
snapping.rotationAngles |
onDragStart and onDragEnd |
interaction transaction events |
onResize |
interaction-change with operation: resize |
onRotate |
interaction-change with operation: rotate |
updateRect() |
refreshTarget() |
| Studio context menu store | widget-context-menu |
The migration must preserve crop behavior. Crop widgets need a custom geometry adapter because a crop resize may update both the outer widget dimensions and internal crop dimensions.
The first migration phase should keep the existing WidgetManager handlers and translate Widgetizer transactions into the current manager calls. The second phase should move geometry ownership into the Widgetizer model. The final phase can remove direct getMoveable() references from Studio.
Widgetizer should expose geometry changes in a way that can later be driven by the timeline.
The editor must distinguish between:
- a user interaction that changes the committed widget state
- a preview update driven by playback
- a keyframe edit at the current timeline time
The same geometry object can be evaluated from keyframes:
{
x: 120,
y: 80,
width: 400,
height: 180,
rotation: 15,
scaleX: 1,
scaleY: 1,
}Playback updates must not create undo entries. Manual changes during an edit session must create transactions.
The implementation must:
- calculate geometry in the core without forced layout where possible
- batch visual updates with
requestAnimationFrame - avoid writing to the application store for every pointer event
- update only the affected control box and targets
- support at least 100 registered widgets with one active selection without noticeable interaction lag
- clean up all observers and event listeners in
destroy() - avoid mutation observers and resize observers unless explicitly enabled
- move with and without bounds
- resize from all eight directions
- resize with a locked ratio
- resize from the center
- scale with uniform and independent axes
- rotation around the center and a custom origin
- rotation snapping
- grid and guide snapping
- minimum and maximum dimensions
- cancel restores the initial geometry
- programmatic requests use the same constraints as pointer operations
- group transformations update every selected widget
- click selects the expected widget
- double-click emits one semantic double-click
- dragging does not emit a click
- context menu contains the correct widget and selection
- pointer capture is released after completion and cancellation
- locked widgets cannot be transformed
- handles expose accessible labels
- keyboard movement and cancellation work
- current widget drag behavior is preserved
- current widget resize behavior is preserved
- current widget rotation behavior is preserved
- cropper resize remains synchronized
- canvas and widget snapping remain available
- context menu actions still target the correct widget
- widget persistence still occurs at the end of a transaction
updateRect()callers have an equivalent refresh method
- geometry model
- selection
- move
- resize
- scale
- rotation
- bounds and ratio constraints
- transaction events
- DOM control box
- click and double-click events
- context menu contract
- keyboard support
- locked and read-only states
- configurable handles
- programmatic requests
- grid snapping
- guide snapping
- widget snapping
- marquee selection
- group move, scale, resize, and rotation
- Studio adapter
- cropper adapter
- persistence integration
- removal of direct Moveable references
- regression coverage for all existing widget types
- keyframe aware geometry
- playback preview mode
- animation editing mode
- integration with widget motion and transition systems
The first production ready version is accepted when:
- Studio can replace its current Moveable overlay for standard widgets
- move, resize, scale, and rotation produce the same visible result as the current integration
- constraints and snapping remain available
- click, double-click, and context menu behavior are available through stable events
- changes are represented as transactions with before and after state
- undo/redo can be implemented by the host without inspecting DOM styles
- locked widgets cannot be modified
- the component works without React
- the React adapter does not duplicate the geometry engine
- crop widgets have a documented adapter path
- automated core, DOM, and Studio regression tests pass
@lgs1920/widgetizer should not expose Moveable as its public implementation contract. Moveable can be used as a temporary reference or internal implementation during migration, but applications should depend on Widgetizer geometry, actions, events, and transactions. This keeps Studio independent from a third party event model and leaves room for timeline animation, accessibility, custom menus, and future widget types.