Skip to content

Latest commit

 

History

History
673 lines (515 loc) · 20.2 KB

File metadata and controls

673 lines (515 loc) · 20.2 KB

@lgs1920/widgetizer specification

Status

Proposed specification.

Purpose

@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.

Goals

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

Non-goals for the first release

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.

Product vocabulary

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

Architecture

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.

Widget model

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: {},
}

Geometry rules

The geometry model uses canvas coordinates and keeps layout dimensions separate from visual scale.

  • x and y identify the widget position in canvas coordinates
  • width and height identify the unscaled layout size
  • rotation is expressed in degrees
  • scaleX and scaleY express visual scaling
  • originX and originY are normalized values between 0 and 1
  • 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.

Widget references

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.

Public API

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.

Control box and handles

When a widget is selected, Widgetizer displays a control box in an overlay layer above the canvas.

Default handles

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 modes

Resize and scale must not be ambiguous. The default UI uses:

  • resize handles for changes to width and height
  • a dedicated scale handle for changes to scaleX and scaleY
  • 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

Rotation must support:

  • free rotation
  • configurable snap angles such as 0, 45, 90, and 180
  • a configurable snap threshold
  • rotation around the current transform origin
  • a readable angle indicator during the interaction

Selection

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
  • Shift click 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'])

Pointer interactions

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

Click and double-click

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.

Context menu

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:

  • duplicate
  • delete
  • lock
  • unlock
  • bring-to-front
  • send-to-back
  • reset-transform
  • center-in-canvas

Widgetizer must not mutate application data when an action is clicked. The action callback remains responsible for the command.

Constraints and snapping

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,
}

Group operations

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.

Events and transactions

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.

Keyboard and accessibility

The control box must be keyboard accessible.

Required behavior:

  • selected widgets can receive focus
  • arrow keys move the selection
  • Shift increases the movement step
  • Escape cancels the current interaction
  • Enter starts 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.

Rendering and styling

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.

Studio integration

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.

Motion and timeline integration

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.

Performance requirements

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

Testing requirements

Core tests

  • 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

DOM interaction tests

  • 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

Studio integration tests

  • 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

Delivery phases

Phase 1: Core single-widget editing

  • geometry model
  • selection
  • move
  • resize
  • scale
  • rotation
  • bounds and ratio constraints
  • transaction events
  • DOM control box

Phase 2: Application interactions

  • click and double-click events
  • context menu contract
  • keyboard support
  • locked and read-only states
  • configurable handles
  • programmatic requests

Phase 3: Snapping and groups

  • grid snapping
  • guide snapping
  • widget snapping
  • marquee selection
  • group move, scale, resize, and rotation

Phase 4: Studio migration

  • Studio adapter
  • cropper adapter
  • persistence integration
  • removal of direct Moveable references
  • regression coverage for all existing widget types

Phase 5: Timeline integration

  • keyframe aware geometry
  • playback preview mode
  • animation editing mode
  • integration with widget motion and transition systems

Acceptance criteria

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

Main design decision

@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.