Aller au contenu principal

svg-editor Intent × Element Matrix

Current-state inventory. No design content here. This doc records what the implementation does TODAY when each public command on @grida/svg-editor is invoked on each SVG element type. It is the input to the IR redesign that follows.

"TODAY" here is an in-flight implementation not yet on main. Source paths under packages/grida-svg-editor/src/ describe the forthcoming implementation slice. Verdicts are the design's pre-redesign baseline; the redesign target is element-ir.md.

1. Method

The matrix below was built by reading the public API surface in packages/grida-svg-editor/README.md (the v0 command vocabulary), then walking every per-element branch in the implementation (src/core/intents.ts, src/core/editor.ts, src/dom.ts, src/core/rotate-pipeline/, src/core/transform/classify.ts). Each cell records the implemented verdict, not the README's aspirational surface; the v0.0.0 status disclaimer (README §Status) warns that nothing here is stable. Verdicts use this fixed vocabulary:

VerdictMeaning
nativeWrites geometry / presentation attrs directly in the element's local frame; faithful by construction.
transform-onlyWrites the transform= attribute; geometry attrs are left untouched.
geometry-rewriteRewrites d=, points=, etc. (lossless but heavy; only affects <path> / <polyline> / <polygon>).
mixedCombines two of the above in one command (e.g. rotated-rect resize writes geometry AND would need a transform-pivot rewrite that doesn't happen today).
refused (essential)Refused because attempting it would violate P1 round-trip; the editor correctly refuses.
refused (accidental)Refused only because the code path isn't written, OR because of a bug (e.g. the is_resizable_node typo at intents.ts:800).
n/aThe command doesn't apply to this element type (e.g. set_text on <rect>).
unimplementedThe command exists in the public API but the per-element arm is missing.

For every cell that is not n/a or trivially native, §5 records a one-line note citing file:line and the user-observable behaviour.

2. Commands

The full closed set from the README's Commands section, in source order. Each command is the addressable editor.commands.{…} member; keymap chord ids (history.undo etc.) in src/commands/defaults.ts are not separate commands — they delegate to these.

v0.0.0 caveat. The README header bills the package as "selection only, no mutation." In source, mutation commands exist and are wired through the headless editor.commands surface and the DOM surface's gesture handlers. The matrix reflects the source; the README v0 status warning still stands.

GroupCommandREADME spec
selectionselect, deselect, select_all, select_sibling, enter_scope, exit_scope§Commands
modeset_mode§Modes
propertyset_property, preview_property§Properties
paintset_paint, preview_paint, set_paint_from_gradient§Paint
transformtranslate, nudge, resize_to, rotate, rotate_to, flatten_transform, align§Commands — transforms
structurereorder, remove, group, insert, insert_preview§Commands — structure
contentset_text, enter_content_edit§Commands — content
fileload_svg, serialize_svg§External control
cleanuptidy§Commands — cleanup
historyundo, redo§Commands — history
defsdefs.gradients.{list,get,upsert,remove,subscribe} and patterns / symbols / markers / clip_paths / masks / filters mirrors§Defs

Counted commands carried into the matrix below: 26 public editor.commands.{…} members (selection: 6, mode: 1, property: 2, paint: 3, transform: 7, structure: 5, content: 2, file: 2, cleanup: 1, history: 2 — minus enter_content_edit which is a mode flip with no per-element arm). Commands with no per-element variance — undo, redo, load_svg, serialize_svg, set_mode, enter_scope, exit_scope, select*, deselect, tidy, defs.*, preview_property, preview_paint — are not crossed against the element axis below; they are listed for completeness and called out in §6.

3. Elements

The element types the implementation knows about, harvested by enumerating case "<tag>": arms in capture_translate_baseline (intents.ts:42–86), is_resizable (intents.ts:280–296), capture_resize_baseline (intents.ts:298–380), and apply_resize (intents.ts:499–555), and by checking is_text_edit_target (document.ts:413), default_paint_attrs_for (editor.ts:1471), and InsertableTag (types.ts:25).

Grouped per the spec's broad categories:

GroupTagsCoverage
Shapesrect, circle, ellipse, line, polyline, polygon, pathfull per-element arms in translate / resize
Refs / rasterimage, usefull per-element arms in translate / resize
Texttext, tspantranslate arm; resize arm only on <text>; set_text on either
Containersg, svg, symbol, defs, switchg translates viaTransform; others have no per-element handler
Paint serverslinearGradient, radialGradient, patternno per-element handler — fall through unsupported
Refs / glyphsmarkerno per-element handler — fall through unsupported
Clip / maskclipPath, mask, filterno per-element handler — fall through unsupported
Foreign contentforeignObjectno per-element handler — fall through unsupported
Stylingstyleno per-element handler — set_property would still write attrs on it

4. The matrix

Three tables, grouped by command class. Verdicts are from §1. Hints in parens point to the local-frame attribute(s) the command writes (or the reason for a refusal).

4.1 Transform commands

These are where per-element divergence is highest. Columns are ordered so translate and nudge (same code path) are first, then resize_to, then rotate / rotate_to, then the catch-alls.

Elementtranslate / nudgeresize_torotate / rotate_toflatten_transformalign
rectnative (x, y)native (x, y, width, height)transform-onlytransform-onlynative (via translate)
circlenative (cx, cy)native (cx, cy, r; uniform min(sx,sy))transform-onlytransform-onlynative
ellipsenative (cx, cy)native (cx, cy, rx, ry)transform-onlytransform-onlynative
linenative (x1,y1,x2,y2)native (all four endpoints scaled)transform-onlytransform-onlynative
polylinenative (points)geometry-rewrite (points rescaled)transform-onlytransform-onlynative
polygonnative (points)geometry-rewrite (points rescaled)transform-onlytransform-onlynative
pathgeometry-rewrite (d translated)geometry-rewrite (d matrix-transformed)transform-onlytransform-onlynative (via translate)
imagenative (x, y)native (x, y, width, height)transform-onlytransform-onlynative
usenative (x, y)native (x, y, width, height)transform-onlytransform-onlynative
textnative (x, y)mixed (corner-only; uniform font-size scale)refused (essential) when rotate= settransform-onlynative
tspannative (x, y)unimplemented (no tspan arm)refused (essential) when rotate= settransform-onlynative (only if surface bounds)
gtransform-only (viaTransform)refused (essential) — is_resizable falsetransform-onlytransform-onlynative (via translate)
svg (nested)transform-only when transform=refused (essential) — is_resizable falsetransform-onlytransform-onlyn/a (viewport)
symboltransform-only when transform=refused (essential) — no handlertransform-onlytransform-onlyn/a
defsn/a (not selectable as artwork)n/an/an/an/a
switchtransform-only when transform=refused (essential) — no handlertransform-onlytransform-onlyn/a
linearGradientn/an/an/an/an/a
radialGradientn/an/an/an/an/a
patternn/an/an/an/an/a
markern/an/an/an/an/a
clipPathn/an/an/an/an/a
maskn/an/an/an/an/a
filtern/an/an/an/an/a
foreignObjectunimplemented (no per-element arm)refused (accidental) — no handlertransform-onlytransform-onlyunimplemented
stylen/an/an/an/an/a

Additional cross-cutting refusal: any element whose own transform= classifies as single_rotate_only is refused by is_resizable_node due to the typo at intents.ts:800 — listed in §7.1. Affects every shape-row in the resize_to column when the element carries a single rotation.

4.2 Property / paint / content commands

Elementset_propertyset_paint (fill/stroke)set_textenter_content_edit
rectnative (cascade-aware carrier)native (cascade-aware carrier)n/an/a
circlenativenativen/an/a
ellipsenativenativen/an/a
linenativenativen/an/a
polylinenativenativen/an/a
polygonnativenativen/an/a
pathnativenativen/an/a
imagenativen/a (no paint on raster content)n/an/a
usenativenative (fill on <use> is inherited)n/an/a
textnativenativenative (only when text-edit target)native (mode flip)
tspannativenativenative (only when text-edit target)native (delegates to parent)
gnativenativen/an/a
svgnativenativen/an/a
symbolnativenativen/an/a
defsnative (writes attr; rarely useful)n/an/an/a
switchnativenativen/an/a
paint-server tagsnative (raw attr write)n/an/an/a
markernativenative (fill/stroke inside)n/an/a
clipPath/mask/filternative (raw attr write)n/an/an/a
foreignObjectnativenativen/an/a
stylenative (would write attr on <style>)n/an/an/a

set_property and set_paint do not branch per-tag in editor.ts:642 (set_property) / editor.ts:717 (set_paint) — they delegate to the choose_write_carrier cascade logic in core/properties.ts. They write to whatever element is selected, so every row is native.

4.3 Structure commands

Elementreorderremovegroupinsert / insert_preview
rectnativenativenative (wrap in <g>; subject to grouping.md policy)native (with default paint attrs)
ellipsenativenativenativenative (with default paint attrs)
circlenativenativenativeunimplemented (no InsertableTag arm)
linenativenativenativenative (with default paint attrs)
polylinenativenativenativeunimplemented
polygonnativenativenativeunimplemented
pathnativenativenativeunimplemented
imagenativenativenativeunimplemented
usenativenativenativeunimplemented
textnativenativenativeunimplemented (no <text> insert UX)
tspannativenativerefused (essential) — not a valid group parent memberunimplemented
gnativenativenativeunimplemented
svgnativenativerefused (essential) per plan_groupunimplemented
symbolnativenativerefused (essential)unimplemented
defsnative (low-value)refused (essential) — would dangle refsrefused (essential)unimplemented
switchnativenativerefused (essential)unimplemented
paint-server tagsn/a (live in <defs>)unimplemented (no ref-count check today on direct remove)refused (essential)unimplemented
markern/aunimplementedrefused (essential)unimplemented
clipPath/mask/filtern/aunimplementedrefused (essential)unimplemented
foreignObjectnativenativenativeunimplemented
stylenativenativerefused (essential)unimplemented

reorder and remove operate on the IR's tree structure (editor.ts reorder / remove paths), not on the element's geometry, so every selectable row is native. defs.* resource APIs are the only sanctioned way to manage paint-server / marker / clipPath / mask / filter / symbol resources today (defs.gradients.remove rejects when ref_count > 0 per README §Defs).

5. Per-cell notes

One line per non-trivial cell, with file:line citation and the observable behaviour. Trivial native cells in §4.2 and §4.3 are not repeated.

5.1 Transform cells

  • translate / nudgerect/image/use/text/tspanintents.ts:227–234. Sets x/y directly from baseline + delta. Observable: NW anchor moves by (dx, dy).
  • translatecircle / ellipseintents.ts:235–239. Sets cx/cy. Observable: center moves; bounding box follows.
  • translatelineintents.ts:240–245. Sets all four endpoints. Observable: both endpoints translated together.
  • translatepolyline / polygonintents.ts:246–248 via shift_points_string (intents.ts:175). Each x,y pair in the points string is rewritten. Observable: lossless point shift; trivia between points is rebuilt.
  • translatepathintents.ts:249–252 via shift_path_d (intents.ts:201) which calls SVGPathDataTransformer.TRANSLATE. Observable: d= is fully re-encoded (heavy diff; not minimal).
  • translateg and any element with a transform=intents.ts:43–45, applied in intents.ts:220–225 via compose_leading_translate (intents.ts:183). Composes a leading translate into the existing transform list, preserving the remainder. Observable: transform= gains or absorbs a translate(...) head; rest of list intact.
  • resize_torect/image/useintents.ts:509–516. Writes x/y/width/height (clamps to ≥ 0.001). Observable: faithful local-frame resize. Known follow-up: rotated rect's rotate(θ cx cy) pivot is not re-normalised — see feedback-transform.md §BLOCKER 1.
  • resize_tocircleintents.ts:517–523. Uses min(sx, sy) for uniform r; corner drags on a non-uniform bbox produce a circle that doesn't fill the bbox.
  • resize_toellipseintents.ts:524–529. Independent rx/ry. Faithful.
  • resize_tolineintents.ts:530–535. Endpoints rescaled around origin.
  • resize_topolyline / polygonintents.ts:536–539 via scale_points_string. Lossless but every coordinate moves.
  • resize_topathintents.ts:540–542 via scale_path_d which applies SVGPathDataTransformer.MATRIX(sx, 0, 0, sy, e, f). Observable: d= is fully re-encoded; the diff is the entire path string.
  • resize_totextintents.ts:543–551. Refuses edge-only drags (!isCorner early-returns). For corner drags, uses uniform min(sx, sy) and updates font-size. <tspan> has no arm — falls through to unsupported (refused).
  • resize_tog/svg/symbol/switch/foreignObject — refused at is_resizable (intents.ts:280–296), which only returns true for the nine concrete shape/raster/text tags. Observable: command is a no-op (editor.ts:818 skips members).
  • rotate / rotate_to — every element — handled by the rotate orchestrator (core/rotate-pipeline/apply.ts:74–109) which writes transform="rotate(θ cx cy)" via apply_rotate (intents.ts:742–771). There is no per-tag dispatch; rotation is always transform-only. is_rotatable (intents.ts:627–659) refuses for four reasons (essential, see §7.2). The pivot is fixed at gesture start by the orchestrator and not re-computed after resize (see intents.ts:735 apply doc and feedback-transform.md §BLOCKER 1).
  • rotatetext / tspan with rotate= — refused essential by is_rotatable (intents.ts:641–645), reason "text-with-glyph-rotate". Observable: orchestrator emits a refusal toast (dom.ts:2252–2278).
  • rotate — any element with style="transform: …" — refused essential (intents.ts:647–651), reason "css-property-transform".
  • rotate — any element with <animateTransform> child — refused essential (intents.ts:652–657), reason "animated-transform".
  • rotate — any element whose transform= classifies as mixed — refused essential (intents.ts:636–639), reason "non-trivial-transform". Flatten Transform is the documented escape valve.
  • flatten_transformeditor.ts flatten path delegates to parse_transform_list / emit_transform_list from core/transform/. Always writes transform="matrix(...)", collapses the entire transform list to one affine. Observable: one-token diff per element; subsequent rotate gestures re-pass is_rotatable (since matrix(...) classifies as mixed it would actually be refused — see §7.3).
  • aligncore/align.ts + editor.ts align path computes per-member deltas and uses the same apply_translate intent, so cell verdict equals the row's translate verdict. Refuses on <2 members or no surface (essential — undefined geometry).

5.2 Property / paint / content cells

  • set_property / set_painteditor.ts:642, editor.ts:717, with cascade carrier choice in core/properties.ts choose_write_carrier. Writes to whichever carrier won the cascade for that node (presentation attribute, inline style, or stylesheet rule promotion). Verdict is native on every element type, including non-renderable ones — the command does not gate on tag.
  • set_texttext / tspaneditor.ts:1478–1496. Refuses unless is_text_edit_target (document.ts:413) returns true, which requires the node to be text or tspan AND every child to be a CDATA text node (no inline <tspan> mixed content). Observable: when refused, command is a silent no-op.
  • set_text — every other element — refused essential at editor.ts:1481. The command doesn't apply.
  • enter_content_edit — surface-bound mode flip; per-element routing decision lives in the host (see README §Modes / §Surface contract). Not a mutation; no per-element data path.

5.3 Structure cells

  • reorder — operates on the IR tree (SvgDocument move operations); no per-tag arm. The only refusal is the keymap-level guard requiring exactly one selected node (commands/defaults.ts:189).
  • removeeditor.ts remove path is tag-agnostic. Known gap: removing a paint-server / marker / clipPath / mask / filter via this command bypasses the defs.* ref-count check (which only runs through the resource registry's remove). Listed as unimplemented for those tags in §4.3 to flag the hole, not because the call is rejected.
  • groupcore/group.ts plan_group (referenced in editor.ts:21). Policy lives in grouping.md. Refuses (essential) on: empty selection, cross-parent selection, paint-server / resource members, and <defs> / <svg> / <symbol> / <switch> parents.
  • insert / insert_previeweditor.ts insert / insert_preview. Gate is default_paint_attrs_for (editor.ts:1471) which only knows about rect | ellipse | line for paint defaults; the public API accepts any tag string but the bundled tool surface (types.ts:25 InsertableTag) restricts to those three. Observable: passing other tags works through the headless API but with no default paint.

6. Hot zones

Branch-count census of the implementation files, in descending order. These are the cells the IR most needs to consolidate.

Sitecase/branch countWhat it dispatches
intents.ts:46 capture_translate_baseline11 (incl. default)per-tag baseline shape for translate
intents.ts:219 apply_translate8 baseline kindsper-tag attribute write for translate
intents.ts:305 capture_resize_baseline10 (incl. default)per-tag baseline shape for resize
intents.ts:508 apply_resize8 attr-kindsper-tag attribute write for resize
intents.ts:119 baseline_anchor8 baseline kindsper-tag anchor for snap alignment
intents.ts:280 is_resizable9-tag whitelisttag-gate on resize
intents.ts:627 is_rotatable4 essential refusalsdoc-state gate on rotate
core/transform/classify.ts:34 classify5 verdictstransform-list shape
dom.ts:1489 shape_of4 branches (line / no-ctm / translate-scale / transformed)HUD shape kind
dom.ts:2174 handle_resize2 branches (transformed / AABB)local-frame vs zoom-AABB decision
editor.ts:818 resize_to member loop1 tag gate (is_resizable)drops non-resizable selection members

Three sites carry the bulk of the per-tag knowledge — the two baseline + apply pairs in intents.ts (translate, resize). Every new element type touches all four. Rotate is the opposite extreme: one universal transform-only write, gated by four document-state checks. set_property / set_paint carry zero per-tag knowledge (they delegate entirely to the cascade-carrier resolver).

7. Known typos and refusals

Surfaced by building the matrix.

7.1 is_resizable_node typo — single_rotate vs classifier's single_rotate_only

intents.ts:800:

return (
cls === "identity" ||
cls === "leading_translate_only" ||
cls === "single_rotate" || // <-- typo
cls === "leading_translate_then_single_rotate"
);

The classifier (core/transform/classify.ts:19) returns "single_rotate_only", never "single_rotate". Effect: any element whose transform= is a bare rotate(...) falls through to false and is refused for resize, despite the comment ("Allow identity, leading-translate, single rotation, and the combined translate-then-rotate form") explicitly stating it should pass. Maps to "verify single-rotated elements are resizable" — every resize_to cell in §4.1 is silently refused (accidental) for elements carrying a pure rotate(...).

This is the only refusal in the matrix tagged (accidental) with a code-citation backing it; the other "no per-element arm exists" refusals are tracked as unimplemented because they were never written, not broken.

7.2 is_rotatable essential refusals (correct, but documented for completeness)

Four reasons, all refused (essential):

Reasonintents.tsWhy essential
non-trivial-transform633–639transform= carries matrix / scale / skew / multi-rotate.
text-with-glyph-rotate641–645<text rotate> is per-glyph; semantics ambiguous on compose.
css-property-transform647–651style="transform: …" interacts with transform-box/cascade.
animated-transform652–657<animateTransform> makes static transform= ambiguous.

Listed for completeness; these are not bugs.

7.3 Flatten Transform → rotate refusal pipeline gap

flatten_transform collapses each member's transform list to a single matrix(...) token. Per the classifier (core/transform/classify.ts:51–59), matrix(...) is mixed. Per is_rotatable (intents.ts:636–639), mixed refuses with reason "non-trivial-transform". Net effect: an element flattened via the documented "escape valve" for accumulated drift becomes non-rotatable until the user manually re-extracts the rotation. Documented in feedback-transform.md §2 RotateBaseline parse-classify dance.

7.4 Two parallel resize paths

commands.resize_to (headless) reads world-space AABB via geometry_provider.bounds_of(id) and writes through apply_resize in local frame — no intent.shape opt-in. The gesture path through dom.ts:handle_resize consumes intent.shape.local when the shape is transformed. For rotated rects the two paths diverge: headless uses AABB, gesture uses local frame. See feedback-transform.md §Structural flaw 1.

7.5 Rotate pivot drift on resize

apply_resize writes new width/height but does not re-write the transform="rotate(θ cx cy)" pivot to the new local centre. Sequential resize → rotate → resize on a rotated rect drifts the artwork. Tracked as the headline blocker in feedback-transform.md §BLOCKER 1. In the matrix this is recorded as a native resize on rotated shape-tag rows (§4.1) — the write succeeds; the side-effect on the rotation pivot is the bug.


Inventory complete. The matrix is the input; the IR design is the output, and lives in the follow-up doc.