Selection Foreground (replacing the selection UI)

Swap out the default handles, bounding box, and marquee with a custom React component.

The default selection UI — bounding box, eight resize handles, rotation handle, marquee rectangle, hover highlight, drop-target indicator — is rendered by usketch-plugin-tool-select. For most apps that is what you want. When you need a substantially different visual or behavior (e.g. integrating a host app’s own selection UI with extra handles, custom adornments, or branded styling), uSketch lets you replace the entire foreground with a single React component.

When to use this API

Reach for the selection foreground API when you want to:

  • Render fundamentally different handles (crop handles for images, text-editing handles, role-specific adornments).
  • Wrap selection in app-specific chrome (e.g. embedded iframe overlays, browser-specific workarounds).
  • Provide a non-React-canvas-style UI (HTML overlay, anchored buttons next to the selection).

If you only want to change a shape’s resize behavior (e.g. constrain aspect ratio), modify the shape’s resize function instead. If you only need a small visual tweak (color, stroke), restyle via CSS where possible. The selection foreground API replaces the entire overlay; it is not a slot for partial overrides.

Two ways to register

There are two equivalent paths to register an entry — pick whichever fits your distribution model.

As an app option

For apps that own the canvas embedding and want a fixed selection UI:

import { createApp } from "@edv4h/usketch-core";

const app = await createApp({
  store,
  plugins,
  selectionForeground: {
    render: (ctx) => <MySelectionForeground ctx={ctx} />,
  },
});

Internally registered with id __app:selectionForeground at priority 100, after all plugins have run. This wins over any plugin default.

As a plugin

For shareable plugins that other apps can opt into:

import type { UsketchPlugin } from "@edv4h/usketch-shared";

export const myFancyHandlesPlugin: UsketchPlugin = {
  id: "fancy-handles",
  name: "Fancy Handles",
  setup(ctx) {
    const off = ctx.ui.registerSelectionForeground({
      id: "fancy-handles",
      priority: 50,
      order: 80,
      fixed: true,
      render: (renderCtx) => <FancyHandles ctx={renderCtx} />,
    });
    (this as UsketchPlugin).teardown = () => off();
  },
};

Priority conventions

Higher priority wins. On a tie, the most recently registered entry wins.

SourcePriority
Plugin default (usketch-plugin-tool-select)0
Third-party plugin custom UI50
createApp({ selectionForeground }) host option100

These are conventions, not enforced ranges. App options are registered after plugin setup, so an app option at priority 100 also wins on ties with a plugin that registered at priority 100.

The render contract

Your render function receives the same LayerRenderContext that regular layers receive:

FieldWhat it gives you
viewportCurrent { x, y, zoom } — used to convert world to screen coordinates.
shapesAll shapes on the canvas as ReadonlyMap<string, ShapeData>.
shapesSortedThe same shapes sorted by zIndex (back-to-front).
selectionThe currently selected shape IDs as ReadonlySet<string>.
themeActive theme tokens.
renderModeLOD mode ("interactive" / "simplified").

Set fixed: true (the default) and your component will be rendered outside the viewport transform — useful for screen-space SVG/HTML overlays. Set fixed: false if your component lives in world coordinates.

Minimal example

A dashed orange box that follows the rotation of a single selected shape, passed as a createApp option. The option accepts render (required) plus optional priority / order / fixed — the entry id is always set internally to __app:selectionForeground, so don’t supply one here.

const app = await createApp({
  store,
  plugins,
  selectionForeground: {
    render: ({ viewport, shapes, selection }) => {
      if (selection.size !== 1) return null;
      const id = [...selection][0]!;
      const shape = shapes.get(id);
      if (!shape) return null;

      const x = shape.x * viewport.zoom + viewport.x;
      const y = shape.y * viewport.zoom + viewport.y;
      const w = shape.width * viewport.zoom;
      const h = shape.height * viewport.zoom;
      const cx = x + w / 2;
      const cy = y + h / 2;
      const rotation = shape.rotation ?? 0;

      return (
        <svg style={{ position: "absolute", inset: 0, overflow: "visible", pointerEvents: "none" }}>
          <g transform={rotation ? `rotate(${rotation}, ${cx}, ${cy})` : undefined}>
            <rect
              x={x}
              y={y}
              width={w}
              height={h}
              fill="none"
              stroke="#ff8800"
              strokeWidth={2}
              strokeDasharray="6 3"
            />
          </g>
        </svg>
      );
    },
  },
});

If you need a full SelectionForeground (with your own id and priority) — for example when distributing a plugin that registers via ctx.ui.registerSelectionForeground(...) — use the plugin-shaped example earlier in this guide instead.

Default behavior and fallback

usketch-plugin-tool-select self-registers a default entry at priority 0 (id: "tool-select-default", order: 80, fixed: true). If you do not load tool-select and do not provide an app option, nothing is rendered for the selection UI — the registry simply has no active entry. The canvas itself continues to work; only the visual overlay disappears.

The reserved layer id __selection-foreground is used internally to mount the active entry. Do not register a regular ctx.layers layer with that id from outside.