The drawing syncs to the code and vice versa. Switch between using code and UI. Built on composable lens [Foster et al. 2007] and edit-lens [Hofmann et al. 2012] theory, but applied in the string domain so unknown semantics like SVG animations are preserved without parsing. Follows the path of Sketch-n-Sketch [Chugh et al. 2016] [Hempel et al. 2019] but applied to an existing image format: SVG. Generated with a novel prompting strategy of requesting an LLM to use property checking against mathematical laws as a architecture, leading to strong implementation consistency over an expansive domain.
| gesture | what it does | lens |
|---|---|---|
| drag a shape | moves it | transform |
| tap a polygon or path, drag a handle | moves one vertex or anchor | points , path d |
| tap anything else, drag a corner or the rotate handle | scale or rotate from the box | transform |
| drag the pivot dot | moves the point the box scales and rotates around | transform |
| double-click an edge / a vertex / empty canvas | add a vertex / remove one / drop in a shape | children |
| tap again in the same place | steps down through stacked shapes | — |
| shift-tap, or rubber-band | adds to the selection | — |
A selection also carries chips: the same commands, one tap away, drawn only when they apply:
| chip | chip | ||
|---|---|---|---|
| ⧉ | |||
| duplicate | ∿ | ||
| smooth ↔ corner the anchor | |||
| ⇄ | |||
| swap fill and stroke | ◑ | ||
give it a gradient (minted into defs ) |
|||
| ≡ (drag) | |||
| stroke width | ➤ | ||
| put an arrowhead on it | |||
| ⊚ / ◠ | |||
| close / open the path |
The panel under the drawing edits fill
, stroke
, width, opacity and the dash as typed inputs; each change commits through the same write path, so the source keeps the notation you wrote.
| key | |
|---|---|
| R E L | |
| drag out a rect, an ellipse, a line | |
| P | |
| place path anchors; click the first again to close, double-click to finish open | |
| S | |
| scribble a freehand path; it is fitted to Béziers on release | |
| V / Esc | |
| back to selecting (a finished tool returns by itself) | |
| ⌘G / ⇧⌘G | |
| group / ungroup the selection | |
| ⌘D | |
| duplicate | |
| ⌘C ⌘X ⌘V / ⇧⌘V | |
| copy, cut, paste / paste in place | |
| ⌘A | |
| select all | |
| 1 / 2 | |
| reset the view / fit it to the selection | |
| [ ] / { } | |
| lower, raise / send to back, front | |
| Delete | |
| removes the selection | |
| ⌘Z / ⇧⌘Z | |
| undo, redo (byte for byte), and declines if something else wrote to the cell first | |
| arrows, shift+arrows | |
| nudge by one unit, by ten | |
| alt while dragging | |
| ignore snapping |
import {svgLens} from "@tomlarkworthy/svg-lens"
viewof picture = svgLens(svg`<svg viewBox="0 0 100 100">
<circle cx="50" cy="50" r="20" fill="tomato"/>
</svg>`)
What follows is the reasoning behind the editor above: what a lens is, which laws it keeps, how we combine to cover a complex domain like SVG, how the maths of sync is kept apart from the UX, and additionally how to apply when the drawing is itself a parameterized function.
You can create computer graphics by typing code or by a graphical user interface, each has advantages in certain situations. If we can switch between these two modes fluidly, bidirectional, the advantage of each can be leveraged at that moment in time and the combined sum is greater than the parts in isolation. Keeping both means keeping them in step, which is the view-update problem [Bancilhon & Spyratos 1981] recast for programs [Chugh et al. 2016]. Feedback in the other direction, from program to picture, is the older half of the problem and has a standard vocabulary [Tanimoto 1990]; what is at issue here is the return path.
Propagation is the hard part. Sketch-n-Sketch infers program changes from output changes by tracing values back to the expressions that produced them [Chugh et al. 2016] [Hempel et al. 2019], which works for programs the tracer understands.
This notebook takes a different approach. The update path is a composition of bidirectional lenses [Foster et al. 2007], which gives it laws, a stated domain, and property tests ( §2.1 The laws).
What is editable is a drawing whose shape is written literally; interpolated values are rendered but left to their source ( §7 Interpolation and holes).
A lens between a source type S and a view type A is a pair of functions [Foster et al. 2007]:
and that is exactly the chain a drag runs down: cell source → template literal → attribute string → typed value, then back up again. Composition of well-behaved lenses is well-behaved, so the laws are established once, per level, and hold for the composite.
Two laws make a lens well-behaved [Foster et al. 2007]:
GetPut (left) says a null edit is a null write: drag a shape back to where it started and the cell is byte-identical to before, not merely equivalent. PutGet (right) says the view you asked for is the view you get, so a drag of 40 pixels moves the shape 40 pixels and the number in the source agrees.
A third law, PutPut, makes a lens very well-behaved:
That is, the last write wins and intermediate ones leave no trace. Most of these lenses keep it; some deliberately do not ( §2.2 Residue, and why PutPut bends).
SVG's syntax is not canonical. "0,0 100 100"
and "0 0 100 100"
are the same rectangle;
rotate(45)
and matrix(0.707 0.707 -0.707 0.707 0 0)
are the same transform. So get is not injective: a whole fibre of source strings maps to one view.
Any put has to pick a representative from that fibre. Reprinting canonically is the obvious choice, and it reformats the file every time a shape is nudged. The rule used here instead:
Skip rule.If a=get(s), return s unchanged.
This implements GetPut rather than merely satisfying it, and it is what keeps comments, spacing and
rotate(45)
intact across an edit. Everything the view does not determine is residue, and residue survives.
It costs strict PutPut. Take a2=get(s) and a1=get(s), with s written non-canonically. On the left the first put reprints, the second skips, and the non-canonical spelling is gone; on the right the single put skips and the spelling survives, giving two different strings that encode the same drawing. What holds is PutPut up to observation:
and strict PutPut holds whenever a2=get(s). This is a deliberate trade rather
than a defect: canonical printing would restore the law and remove the property that makes the editor
usable on source a human wrote. Weakening a law to a quotient is the usual move when residue matters
[Czarnecki et al. 2009]. test_putput_skip_rule_corner
in the appendix targets this case directly, since random generators are unlikely to reach it.
Two things make SVG harder than the string examples in [Foster et al. 2007]: the source is nested, and every address is relative to a source that editing can change.
Notation. Composition is written in diagrammatic order throughout: ℓ1∘ℓ2 applies ℓ1 first, so the outer lens is on the left. This is the reverse of the usual convention for functions, and matches the direction a gesture travels.
The tower. Four types and three lenses:
Function.prototype.toString()
; Doc is the bytes of the svg
template literal inside it; Attr is one attribute value; A is what the gesture actually manipulates: a number, a point list, an affine matrix, a list of path operations. A drag of a shape is the composite ℓlit∘ℓattr(p,transform)∘ℓtransform:Src⇀R6 and structural editing branches off one level up, at ℓkids(p)=childrenLens(p):Doc⇀Elem∗, whose view is the list of child source slices, so that every child carries its own residue and only the children that changed are reprinted.
Addressing is dependent, and that is the interesting part. For a document s let Path(s) be the set of addresses of its elements. ℓattr is not a lens but a family indexed by that set, and the set depends on the source:
The two kinds of put differ exactly here:
Path(putℓattr(p,n)(a,s))Path(putℓkids(p)(k,s))=Path(s)=Path(s)in generalAn attribute edit preserves the address space; a structural edit does not. That one inequality is the reason the editor holds a selection as a path and re-resolves it after every put instead of caching a node, and the reason handles are refreshed after an attribute edit but cleared after a structural one.
Partiality is part of the type. ℓlit refuses text that would not survive re-entry into the template literal; ℓkids refuses a string that is not exactly one element. So put is partial, put:A×S⇀S, and composition intersects the domains:
The editor's contract is that this partiality is visible: a gesture whose put is undefined is declined before it previews, and the handle is drawn locked with the reason ( §7 Interpolation and holes). Refusing is the domain being honest, not a failure.
Composition preserves the laws. If ℓ1 and ℓ2 are well-behaved on their domains then so is ℓ1∘ℓ2. GetPut:
using GetPut of ℓ2 then of ℓ1; PutGet is the same argument read downwards. This is why the laws are established once per level and never re-argued for the chain. It is also why a new microsyntax lens is a self-contained obligation: prove it at its own level and the tower is unaffected.
The weakened form composes too. Each level keeps PutPut only up to observation ( §2.2 Residue, and why PutPut bends), and since (ℓ1∘ℓ2).get=ℓ2.get∘ℓ1.get, the composite's quotient is exactly the composite's own
get
, so "up to observation" at each level gives "up to observation" of the whole chain, with no further loss.One rule separates the maths from the pointer:
Tools emit commands, commands are lens puts, one writer applies them.
A tool is a pointer state machine (press, move, release). It may draw a preview into an overlay layer and mutate the live DOM; it may not persist anything. What it produces at the end of a gesture is a command: a pure function from the document text to new document text, or an attribute value to write at an address. The writer is the only code in the notebook that redefines a cell.
The layers, what lives in each, and what each is allowed to know are shown in the figure below; it is itself an svgLens
drawing, so the stack can be edited by the editor it describes. Nothing below L4 touches the DOM or the pointer, so L0–L3 is testable outside of a browser, and is ( §2.1 The laws). Two consequences worth stating:
The source is the truth; the DOM is a projection. [Larkworthy 2026] calls the general arrangement source-last: the live runtime is canonical and text is recovered from it on demand, and this editor applies it one level down, to the bytes inside one cell. A gesture is therefore an ordinary edit; what that buys, and why the resulting feedback loop terminates, is §4.1 Propagation, and why the loop closes.
The browser stays the authority on geometry. Hit testing is elementsFromPoint
, bounding boxes are getBBox
, screen-to-user conversion is getScreenCTM
. The lenses never re-implement SVG semantics; they only rewrite the text that produced them.
Commands are endomorphisms of the source. Currying a put gives
so every lens at every level of §3 The SVG tower: addressing and nesting induces a source-to-source function, and the command layer needs no vocabulary of its own: insert, delete, reorder and the gizmo operations are all of this shape. Commands compose by ordinary function composition, and the skip rule says a null edit is the identity of that monoid:
This has an operational consequence, not just an algebraic one: the writer compares the produced source with the old one and returns before touching the runtime when they are equal, so a gesture that ends where it started causes no recompute at all.
Disjoint addresses commute. Dragging n selected shapes is n commands, one per address, and for p, q neither a prefix of the other
because an attribute put splices a byte range inside its own element and leaves the element list (and therefore every other address) untouched. So "one put per shape" needs no ordering discipline, and test_commands_commute
in the appendix checks it on random documents. Structural puts are excluded from the claim, and genuinely do not commute: they renumber the addresses the other put is holding.
Tools are folds over pointer events. A tool is a state machine
where δ may draw into the overlay and mutate the live DOM, and ε is consulted once, at release. The preview is a projection that is thrown away; the command is the only thing that survives the gesture. Tools sit in a priority list and the first whose hit test claims the pointer owns the gesture, so adding a tool is adding an element to a list; it cannot interfere with the levels below because nothing in Q can reach S.
Propagation is the runtime's, not ours. The writer applies the command and installs the result with Variable.define
:
Nothing in the editor notifies anything. editor-5
's buffer, the change history and every dependent cell update because the runtime recomputed a variable, which is what it does whenever a definition changes.
Why the loop closes in one step. The editor is the value of the cell it rewrites, so a gesture edits its own producer: the drawing is downstream of the gesture that changed it. That is a feedback loop, and it terminates for a reason worth naming: PutGet. The recomputed view is already the view the gesture asked for,
so the new node has nothing left to write and emits no second command. One gesture, one command, one recompute. Had the design gone the other way, mutating the DOM and deriving the source from it, the loop would need a fixed point and an argument that it converges. Here the law does that work.
The smallest useful lens: one attribute of one shape. The triangle's three corners live in a single
points
attribute; the rest of the source (a comment, the fill) must come through every edit untouched.
attrTextLens(i, name)
views the text of one attribute of element i; parsePoints
views the numbers inside that text and puts them back into the same slots. Composing them gives a lens from the whole document to the corner coordinates, and that composite is what a drag on a vertex writes through.
Click the triangle, then drag a corner. The readout below reports the put that just happened: which attribute changed, and its value before and after. Two things hold, computed on each drag rather than asserted:
points
attribute is written, and only the corner you grabbed changes value; the fill, the comment and everything else in the source are left exactly as typed, because put splices the new value into the existing string instead of reprinting the whole element;Attributes are the easy half. The structural lens is harder, because inserting an element is where a naive implementation reformats the document.
childrenLens(p):Document⇄String∗The view is a list of child source strings, not parsed nodes. With strings, insert
, delete
and reorder
are splice
on an array, and a moved child carries its own bytes with it: attribute order, inner spacing, its own comments. With parsed nodes the commands would be tidier and every structural edit would reprint half the file.
The view does not contain the text between children: newlines, indentation, comments sitting in the gaps. That is residue again, and put
has to re-thread it: each surviving gap stays with its neighbour, and a genuinely new child gets only the indentation of the gap it lands in. An early version gave a new child the whole gap, so every insert duplicated the comment above the first child; four inserts produced five copies. The regression test is in the appendix, and the button below runs the same operation.
The commands are pure functions of text, with no DOM and no pointer, so the same code runs under Node in CI and under a finger here ( §4 Tools, commands, one writer). Working at the granularity of operations rather than states is the move edit lenses make
Use [
and ]
to move the elements forwards and backwards in the child list
Everything so far assumed the drawing is written out literally. A drawing may also interpolate
values from elsewhere: transform="translate(${shift} 0)"
reads shift
from a slider. The editor tolerates interpolation. The drawing renders, and the literal text around a hole stays editable; what the editor will not do is write back into a hole.
The source text of an attribute is cut into slots. Each slot is either a literal run of digits or
an interpolation ${...}
, and only the first kind is somewhere a gesture can write:
| slot | can a gesture write it? |
|---|---|
| literal digits | yes: spliced in place, exactly as in |
| §5 A first lens: one attribute of one shape |
${...}
A gesture that touches only literal slots commits as usual. A gesture that would have to change a hole is declined: any literal slots it also moved are written, the hole is left byte for byte, and the readout reports the mix. When the whole value is one hole there is nothing to write, so the gesture is refused outright.
Refusing is a guarantee, not effort withheld. A hole could in principle be inverted: resolve
${shift}
to the slider and set its value, or solve ${Math.sin(t)}
numerically. Inversion by reflection into an upstream view was tried and removed. It is fragile, it rarely holds, and its result is indistinguishable on screen from an exact edit until it silently drifts. Declining instead keeps every law in §2.1 The laws true of every accepted gesture, and the handle says which case it is in. The term
Three rectangles, two sliders. The first is written literally, so a drag rewrites this cell's
source and the box moves. The second carries transform="translate(${shift} 0)"
: move the slider
and it follows, because the drawing interpolates the value; but drag the box and the editor declines,
because the number lives in the hole and this editor does not write back into an interpolation.
The third carries rotate(${spin} 32 100)
and behaves the same way for rotation.
Interpolation is tolerated, not inverted: the slider feeds the drawing, and a gesture that lands on a hole is refused rather than guessed. Watch the table underneath: every gesture reports whether it wrote literal source or hit a locked hole.
The lenses so far answer one question: given a new view, what source produces it. A tool asks something narrower and carries a memory with it: given a pointer delta and the context a gesture accumulates, what edit does the source get? That memory is what a state-based lens lacks, and naming it as one value is what lets a single law describe every tool's edit instead of one law per tool.
The generalisation is already in the literature the paper cites. Where a state-based lens maps states to states, an edit lens [Hofmann et al. 2012] maps edits to edits through a complement (a memory the translation is allowed to keep), and [Johnson & Rosebrugh 2016] axiomatises it alongside the asymmetric delta lens of [Diskin et al. 2011]. The two frameworks do two jobs here. The delta lens is the shape of the writer: the source is authoritative, the drawing is derived. The edit lens is the shape of a tool, because what a tool has that a state-based lens lacks is exactly a complement. The dictionary is one line. The complement is that memory:
| edit lens | svg-lens |
|---|---|
| X, MX | the cell source; the edits a command can make to it |
| Y, MY | the rendered drawing; pointer deltas |
| C | ctx.state.drag : the origin, the grabbed targets, the hit list, the snap boxes |
| q:MY×C⇀MX×C | the tool |
| K | "the DOM on screen is what this source renders to" |
Making the delta a value is the whole of the framework. A tool returns a gestureDelta
(one of attr
, command
, select
, view
, clip
), and three functions consume it: previewDelta
paints the live DOM, commitDelta
writes the source once, revertDelta
throws the preview away. One write path, so every law in §8.1 The laws a tool owes is stated about the delta and checked in one place rather than re-proved per tool. It is also what lets the registry stay
svgLens(node)
uses the default tools, svgLens(node, { tools: [toolVertex] })
pins a vertex-only editor, and svgLens(node, { tools: (d) => [myTool, ...d] })
extends without restating the defaults: a test runs one tool in isolation and a figure shows a reduced editor by the same mechanism.Five laws come straight from the axioms [Johnson & Rosebrugh 2016]. The null gesture is the sharpest.
| law | what it says here | what it rules out | |
|---|---|---|---|
| T1 | |||
| p(1,c)=(1,c) | a null gesture writes nothing and returns its memory untouched | ||
| a click that grabs nothing creating a shape, or a gesture losing the context it holds | |||
| T2 | |||
| p(mm′,c)=(nn′,c′′) | translating a composite gesture equals composing the translations | float drift — two gestures ending at the same point committing different bytes | |
| T3 | |||
| PutGet | the gesture you made is the gesture the committed source shows | a preview that looks right on screen but not once the source is reloaded | |
| T4 | |||
| PutInc | a gesture commits against the state it began from | ||
| a commit that mints a new node letting a follow-up write land on a stale document | |||
| T5 | |||
| consistency | if the DOM agreed with the source before, it agrees after | makes T3's screen-level check a consequence, not a separate assertion |
Partiality is the formal content of "a tool must decline cleanly." A gesture outside a lens's domain is undefined, so its translation is undefined too: the action is partial. onPointerDown
returning false
is that partiality — a tool with nothing to say about a press declines it rather than falling through to drop a shape on an empty click, which is why an empty-canvas tool gates on ctx.pick
first. The same partiality runs through every surface that reaches the DOM: a command declines by returning null
from its plan, and an affordance chip greys out exactly when its command declines. One notion of "not applicable here" spans pointer, command and chrome.
Six more laws are ours, not the papers':
false
has changed nothing, and installing it cannot change what earlier tools do. Registry order is priority, so tool sets form a non-commutative monoid under concatenation. This is the law that makes a tool a rebase(path, cmd)
equals re-locating the same element after the command runs: operational transformation's TP1, one-sided.ctx.pick
, so they cannot drift apart. It also pins the group policy: a click takes the outermost unopened container, a double-click descends a level, Escape ascends.ctx
and getScreenCTM
already carries the viewBox, with alignment snapping off. (Snapping is magnetism measured in Three things that looked like special cases are textbook constructions with textbook laws: the shape registry is a family of prisms (tag dispatch is a sum type; preview∘review=Just is what each rect
/circle
/path
entry owes), hit-testing is an affine (a focus that may fail, whose law is that a failed get makes put a no-op, T1 again), and multi-selection is a traversal (so "set the fill on the selection" is traversal ∘ lens, and align, distribute and group-edit come with it). All of them run live: test_gesture_*
and gestureLaws
check the laws above over a random corpus of gestures, on the same forAll
harness the lens laws in §2.1 The laws use.
Bidirectional programming. The lens formulation, the laws and the well-behaved / very
well-behaved distinction are Foster et al.'s [Foster et al. 2007], addressing the view-update
problem posed first for databases [Bancilhon & Spyratos 1981]. [Czarnecki et al. 2009] survey how many
fields arrived at the same structure independently. What differs here is the source:
[Foster et al. 2007] lens trees and strings, this notebook lenses the executing function's own
text, so the residue that must survive is the author's formatting and put
splices into bytes that are also a running program.
Sketch-n-Sketch. The closest system [Chugh et al. 2016] [Hempel & Chugh 2016] [Hempel et al. 2019]: an SVG editor where you write a program, manipulate the output, and the system updates the program. It is considerably more expressive, handling abstraction, loops and recursion via trace-based provenance and later output-directed synthesis [Hempel et al. 2019], and it can produce a program the user never typed. Three differences, none a claim of superiority:
Lenses in practice. Cambria applies lenses to schema evolution in local-first documents [Litt et al. 2020]; edit lenses [Hofmann et al. 2012] shift the currency from states to edits, which is the same instinct as our command layer, though we implement it concretely as splices on child source strings ( §6 The structural lens).
The host this depends on. The editor relies on the system it runs in being source-last
[Larkworthy 2026]: Lopecode keeps no canonical saved file, treats the live runtime as the
source of truth, and recovers each cell's text on demand from Function.prototype.toString()
. There
is no file on a server that the definition is a copy of, and no save step for an edit to fall out of.
Read the other way, this notebook is a test of that paper's claim that a plurality of editing surfaces
follows from runtime primacy: editor-5
edits a cell as text, editable-md
as prose, sticky
edits a literal by manipulation, and svgLens
edits one as a drawing. None is privileged, and since
all four go through Variable.define
( §4 Tools, commands, one writer), each one's edits are visible to the others.
Notebooks and liveness. Liveness has a standard scale [Tanimoto 1990], but it measures feedback from program to output; the quality at issue here is the reverse. [Horowitz & Heer 2023] name persistence as what separates a rich widget from a programming system: interactions with a rendered tool "cannot be 'saved' back to the notebook". This is one answer for one domain: the widget's output is the cell, so there is nothing to save back. [Edwards et al. 2019] ask for costs to be reported alongside benefits, which is what §10 Future work is for.
The literal lens is not specific to SVG. literalLens
finds a tagged template in a cell's own
definition and lenses its text; everything above it is SVG-specific, everything below is not. The same
primitive points at markdown in an md
cell, CSS in a css
cell, or JSON in a data cell: a click-to-edit prose editor and a colour picker that rewrites a stylesheet differ only in the microsyntax layer. This is the generalization worth doing next.
Writing through holes. Today a hole is tolerated and locked ( §7 Interpolation and holes). Two ways to make one writable are visible from here, both deliberately out of scope: resolving a hole to another cell's literal, which makes the lens target the dataflow graph rather than one cell, and numerical inversion for holes feeding arithmetic, the approximate solve
@tomlarkworthy/manipulate
already implements. Reflection into an upstream view was tried and removed for being fragile; either replacement would have to keep reporting that its guarantee is weaker than an exact edit's.Structure the domain refuses. An interpolation in element position is out of scope, because document-order indices would stop matching the DOM. Addressing children by identity rather than position would lift the restriction, at the cost of putting ids into a drawing the author is writing.
What it costs. Reported in the manner [Edwards et al. 2019] argue for: the editor reaches only
drawings written literally; the parser is a deliberate subset of XML (no CDATA, no entity decoding, no
nested <svg>
); PutPut holds only up to observation ( §2.2 Residue, and why PutPut bends); undo declines rather than merging when another writer has touched the cell; each gesture commits once, so dragging a 200-element selection is 200 puts; and since a commit recomputes the cell (
The tests come first because they are the specification: every law quoted above is a cell below, run on each load with a seeded generator. After them, the implementation in dependency order: lens core, SVG microsyntax, source lenses, commands, then the editor.
Deterministic replacement of fast-check: a seeded PRNG (mulberry32
), domain generators (arb
), and forAll
which throws the counterexample on failure. Fixed seeds keep every test cell rerunnable with identical results. arb.anyFinite
draws random 64-bit patterns, so the exact round-trip claims are exercised across the full double range, subnormals and -0 included.
Each test_*
cell property-checks one lens (or one exactness claim) with a fixed seed. The dashboard near the top of the notebook aggregates them.
All printing uses String(n)
, all parsing uses Number(s)
. Lenses are total on their stated
domains and throw outside them (parseable strings, nodes carrying the focused attribute/child,
matrices with det ≠ 0, path data with separated numbers, with no 10-5
abutment or compressed arc flags).
Affine matrices as a flat [a,b,c,d,e,f]
, their product, and the screen-to-user map (getScreenCTM
) that every measurement is taken through.
The transform
attribute as an editable list of operations (translate, rotate, scale) plus the gizmo that rotates or scales about a point held fixed.
The determinant, the matrix inverse and its involution law (a screen delta sent back through an element's own frame), and the small accessors that read an attribute or child out of a parsed node.
The d
attribute parsed into absolute commands and printed back: the lens the pen and vertex tools write a path through.
Everything above focuses inside an attribute. These focus outward, until the source is the JavaScript of the cell itself:
| Lens | Source | View |
|---|---|---|
literalLens(alias) |
||
| a cell definition's source text | the template literal inside its alias(…) call |
|
attrTextLens(i, name, dflt) |
||
| SVG document text | element i 's attribute string (dflt when absent) |
|
cellAttrLens(alias, i, name, dflt) |
||
| cell source | that attribute, addressed from the cell |
literalLens
locates the literal by parsing the definition with acorn and taking byte offsets, the
same technique @tomlarkworthy/sticky
uses for its persistence slot. Its domain is definitions
containing one alias(…)
call whose argument is a template literal with no ${}
interpolations,
and views that contain no backtick, backslash or ${
(they would not survive re-parsing).
Composing outward from a typed view gives a lens from cell source straight to matrix:
compose(cellAttrLens("svgLens", 4, "transform", "matrix(1 0 0 1 0 0)"), transformLens)
That composite is what a drag commits, and test_cellSourceLens_laws
checks its laws.
A forgiving scanner over the SVG text, the tree it builds, and nodeAt
/ pathOfIndex
for addressing an element by a path that survives structural edits.
Reading and splicing a single attribute, the style=""
and unit-bearing-number lenses, setProperty
(which writes where the property already lives), and refsOf
for the url(#id)
a shape points at.
Paint and stroke properties as data, so the panel and the on-canvas chips read one list and adding a property is a line here, not a branch in a form.
The list of an element's children as a lawful view, and the pure document-to-document commands built on it: insert, delete, reorder, group, ungroup, the copy/paste codec, and minting into defs
.
Segments, subdivision and anchor removal for polygons and paths, and the address rebasing that keeps a vertex selected across an edit that changes the command list.
svgLens(node)
returns the node it was given, with pointer handling attached. During a gesture only the live DOM changes: no source is written, so dragging is as cheap as setAttribute
. On release the gesture commits once, through compose(cellAttrLens(…), transformLens)
, and the DOM then adopts the source's exact bytes: if you drag a shape back to where it started, the readable translate(228 128) rotate(-4)
is still there, because GetPut says so.
The new definition is built with realize
(which routes through importShim
on lopecode) and installed with Variable.define
, the same call editor-5
makes when you type in a cell.
Gestures call define
, the same kind of event as typing: the cell is invalidated, recomputes, and produces a new node.
That has a cost (the node the cell handed out is replaced on every commit) so state that must outlive a gesture cannot live in the node's closure. Undo history, the selection and the active tool are held in lensState
, keyed by the Variable
, which define
mutates in place. The selection is restored by path rather than index, which is why it is addressed that way.
Rendering evaluates the template rather than parsing its text, so an interpolated svg<circle r="${r}"/>``
renders the same way a literal one does; what a handle over a hole may write to is a separate question ( §7 Interpolation and holes).
applySource
re-reads _definition
after the await
and abandons the put if it changed underneath, since editor-5
may have rewritten the cell mid-gesture.
svgLens
is wiring and nothing else. The parts it wires:
| Cell | Owns |
|---|---|
svgTarget |
|
which variable this node is, the parameter name the cell calls svgLens by, and the document text |
|
svgWriter |
|
applySource / commit / runCommand : the only code that assigns _definition |
|
svgOverlay |
|
the handle layer, and the isOwn predicate that keeps the renderer off it |
|
svgFocus |
|
| which element is selected and where its handles are drawn | |
svgTools |
|
| the tool registry: move, vertex, transform, marquee, the shape and pen tools, and the affordance dispatcher |
A tool is {id, onPointerDown, onPointerMove, onPointerUp, onDblClick}
. onPointerDown
returns true to claim the gesture; registry order is priority. Tools read the document, preview in the live DOM, and hand a command or a commit to the writer; they never write the source themselves, and none of them knows what a lens is. Adding a tool is adding a cell:
svgTools.push({ id: "rect", onPointerDown(ctx, e) { … } });
viewof svgTools.dispatchEvent(new Event("input"));
Or take the set at the callsite, which pins one drawing to exactly the tools you name: how a test runs a tool in isolation, and how a figure shows a reduced editor. The selection chrome (the affordance chips) is the same kind of registry, so affordances
strips it the same way:
svgLens(node, { tools: [toolVertex] }) // a vertex-only editor
svgLens(node, { tools: (d) => [myTool, ...d] }) // extend without restating the defaults
svgLens(node, { tools: [toolMove], affordances: [] }) // a bare drawing: handles, no chips
The figures above use exactly this: each loads only the tool its point needs and no chips, so the chrome does not distract from the one gesture being shown.
The writer stays ignorant of selection: it reports what it did on the lens-put
event, and the handles follow, refreshed after an attribute edit and cleared after a structural one, because a structural edit shifts every address after it.
The draggable points a selected polygon or path exposes: where the vertex tool grabs.
Per-tag geometry as data (what each shape reads, writes and offers as handles) so a new shape is an entry here rather than a branch in every tool.
The four pieces svgLens
wires: which variable this node is, the only code that assigns _definition
, the handle layer, and which element is selected.
gestureDelta
values and the three functions that consume them (previewDelta
paints, commitDelta
writes once, revertDelta
discards): the one write path every tool goes through.
Each tool claims a gesture through onPointerDown
, previews in the live DOM, and hands a delta to the writer: move, vertex, transform, marquee, scope, the shape and pen tools, and scribble.
A headless fixture that plays a recorded gesture and checks T1–T11 over a random corpus, plus the per-frame budget that catches a dropped selection or a scale flash.
Keyboard/menu commands whose plan
returns a delta or declines, and the chips that surface those same commands on a selection, both writing through commitDelta
.
svgLens
itself: the state that outlives a remount, and the wiring that turns all of the above into an editor attached to one node.