@mocanvas/sync 1.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +421 -0
- package/BENCHMARK.md +519 -0
- package/CLEAN_ROOM.md +50 -0
- package/COMPAT.md +282 -0
- package/CUSTOM_SHAPES.md +880 -0
- package/LICENSE +110 -16
- package/MIGRATION.md +807 -0
- package/README.md +164 -35
- package/UI.md +256 -0
- package/dist/index.d.ts +215 -19
- package/dist/index.js +614 -39
- package/dist/index.js.map +1 -1
- package/package.json +14 -16
package/README.md
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# @mocanvas/sync
|
|
2
2
|
|
|
3
3
|
Multiplayer for a mocanvas store: document changes travel as record diffs,
|
|
4
|
-
cursors and selections travel as presence records.
|
|
4
|
+
cursors and selections travel as presence records. Conflicting edits are merged
|
|
5
|
+
field by field by a small CRDT (`src/crdt.ts`), so two people editing different
|
|
6
|
+
properties of one shape both keep their work and every replica converges.
|
|
5
7
|
|
|
6
|
-
Part of
|
|
8
|
+
Part of mocanvas; the canvas itself is `@mocanvas/mocanvas`.
|
|
7
9
|
|
|
8
10
|
## Install
|
|
9
11
|
|
|
@@ -53,51 +55,159 @@ Every message is one JSON object on the wire.
|
|
|
53
55
|
| message | fields | meaning |
|
|
54
56
|
| ---------- | ---------------------------- | ---------------------------------------------------- |
|
|
55
57
|
| `hello` | `clientId`, `version` | I joined. Established peers answer with a `snapshot`. |
|
|
56
|
-
| `snapshot` | `records`
|
|
57
|
-
| `diff` | `clientId`, `seq`, `diff` | One squashed
|
|
58
|
+
| `snapshot` | `records`, `state` | Every document record, plus the sender's CRDT stamps. |
|
|
59
|
+
| `diff` | `clientId`, `seq`, `diff` | One squashed store diff, stamped field by field. |
|
|
58
60
|
| `presence` | `clientId`, `record` | The sender's `instance_presence` record. |
|
|
59
61
|
| `bye` | `clientId` | I am leaving; drop my presence. |
|
|
60
62
|
|
|
63
|
+
`encodeMessage` stamps `version: PROTOCOL_VERSION` (currently 3) onto every
|
|
64
|
+
message. `decodeMessage` returns `null` for anything malformed and a synthetic
|
|
65
|
+
`{ type: "unsupported", version }` for a message from another protocol version,
|
|
66
|
+
so a peer on a different build is reported through `onError` rather than
|
|
67
|
+
half-parsed. Nothing on the wire can crash this client.
|
|
68
|
+
|
|
61
69
|
- Outgoing diffs come from `store.listen(cb, { source: "user", scope: "document" })`,
|
|
62
70
|
so nothing applied from a peer is ever echoed back and nothing in `session`
|
|
63
|
-
or `presence` scope is persisted into the document stream.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
+
or `presence` scope is persisted into the document stream. Each one is
|
|
72
|
+
stamped by the CRDT before it goes out, whether or not the transport is up.
|
|
73
|
+
- Incoming messages are merged field by field and only the fields that won are
|
|
74
|
+
applied, inside `store.mergeRemoteChanges(() => store.applyDiff(winning))`,
|
|
75
|
+
which marks them `source: "remote"`: they do not enter the local undo stack
|
|
76
|
+
and do not re-broadcast.
|
|
77
|
+
- A replica with **no stamps at all** — a tab that has just loaded and has not
|
|
78
|
+
been edited — takes the first `snapshot` it is offered whole, records and
|
|
79
|
+
stamps together, and sends nothing before that. One stamp is enough to make
|
|
80
|
+
it merge instead: a replica with work of its own never has that work replaced
|
|
81
|
+
by a peer's body.
|
|
82
|
+
- Local changes are stamped by the store listener, which is wired up when the
|
|
83
|
+
client is built and stays wired until `dispose()`. An edit made before
|
|
84
|
+
`connect()`, or after `disconnect()`, is therefore stamped **when it happens**
|
|
85
|
+
and goes out on the next connection. It is not silently unstamped work that
|
|
86
|
+
would lose to every stamped write in the room.
|
|
87
|
+
- On a reconnect the client re-sends `hello` and its own snapshot, so work done
|
|
88
|
+
while the transport was down flows both ways.
|
|
89
|
+
- If the store rewrites a merged record — a validator clamping a value, a side
|
|
90
|
+
effect touching another record — that rewrite is stamped as a local write and
|
|
91
|
+
broadcast. Otherwise it would exist on one replica only, since changes made
|
|
92
|
+
inside `mergeRemoteChanges` are reported as `remote` and never leave.
|
|
71
93
|
- Presence records are derived from the editor's camera,
|
|
72
94
|
`inputs.currentPagePoint` and selection, throttled to at most 30 Hz, and
|
|
73
95
|
re-sent on a heartbeat. A collaborator is dropped on `bye` or after 10s of
|
|
74
96
|
silence.
|
|
75
|
-
- `decodeMessage` returns `null` for anything malformed, so a peer running a
|
|
76
|
-
different version cannot crash this one. `PROTOCOL_VERSION` is checked on
|
|
77
|
-
`hello`.
|
|
78
|
-
|
|
79
|
-
## Conflict policy: last writer wins, per record
|
|
80
97
|
|
|
81
|
-
|
|
82
|
-
|
|
98
|
+
## Conflict policy: a CRDT over the record fields
|
|
99
|
+
|
|
100
|
+
Every leaf field of every record is its own last-writer-wins register. A local
|
|
101
|
+
change carries one Lamport stamp — `{ lamport, client }` — and a field is
|
|
102
|
+
overwritten only when the incoming stamp is greater than the one already held
|
|
103
|
+
for that exact path. Equal `lamport` values are broken by comparing the client
|
|
104
|
+
ids, so the comparison is a total order and both sides of a race reach the same
|
|
105
|
+
answer from the same pair. Paths are dotted: `x`, `props.w`, `meta.notes.title`.
|
|
106
|
+
|
|
107
|
+
A stamp covers a **subtree**, not just a leaf. Writing `meta` — setting it, or
|
|
108
|
+
deleting the key — dominates `meta.a`: a claim loses to any stamp at or above
|
|
109
|
+
its own path, and a write that drops a subtree keeps the descendants stamped
|
|
110
|
+
later than itself. Comparing `meta` and `meta.a` as unrelated registers is how
|
|
111
|
+
"delete the key" racing "edit the key's child" ends up different on each side.
|
|
112
|
+
|
|
113
|
+
The result is that merging is **idempotent** — the same message twice changes
|
|
114
|
+
nothing the second time — and **commutative**: two messages in either order
|
|
115
|
+
reach the same state, because the outcome per path is the maximum stamp and a
|
|
116
|
+
maximum does not care about arrival order. Replicas converge no matter how the
|
|
117
|
+
transport reorders or duplicates messages. `src/fuzz.test.ts` is the evidence:
|
|
118
|
+
a seeded generator builds random schedules of creates, nested edits, deletes,
|
|
119
|
+
re-creates, reordered, duplicated and dropped messages, disconnects, reconnects
|
|
120
|
+
and snapshot joins over an in-memory network, then quiesces and demands that
|
|
121
|
+
every replica's document be byte-identical under a key-sorted serialisation.
|
|
83
122
|
|
|
84
123
|
What that means in practice:
|
|
85
124
|
|
|
86
|
-
- Two people dragging **different** shapes
|
|
87
|
-
- Two people
|
|
88
|
-
other
|
|
89
|
-
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
-
|
|
93
|
-
|
|
94
|
-
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
to
|
|
125
|
+
- Two people dragging **different** shapes: fine, as before.
|
|
126
|
+
- Two people editing **different fields of the same shape** — one moves it while
|
|
127
|
+
the other recolours it — now keep both edits. This is the case the old
|
|
128
|
+
per-record policy lost.
|
|
129
|
+
- Two people editing the **same** field pick the same winner on every replica.
|
|
130
|
+
The loser's value is gone; there is no merging inside a single value.
|
|
131
|
+
- **Creating** the same record id on two peers merges field by field, exactly
|
|
132
|
+
like an edit.
|
|
133
|
+
- **Deleting** leaves a stamped tombstone rather than a hole, so an older edit
|
|
134
|
+
arriving late cannot resurrect the record. A delete beats a concurrent edit
|
|
135
|
+
only when its stamp is the greater one; an edit with a greater stamp keeps
|
|
136
|
+
the record alive.
|
|
137
|
+
- **A record kept alive by an edit that outranks a delete comes back whole.**
|
|
138
|
+
The tombstone takes the provenance of every field it outranks, so the fields
|
|
139
|
+
written *before* the delete have nothing left to settle them and each replica
|
|
140
|
+
would otherwise keep whatever leftover it happened to hold. The replica that
|
|
141
|
+
keeps the record re-claims its whole body under a fresh stamp and sends it,
|
|
142
|
+
and that body — not a leftover — is what every replica ends up with.
|
|
143
|
+
- Peers that were offline while others edited keep their work: on reconnect the
|
|
144
|
+
two sides exchange state and merge both ways. That holds for a client that
|
|
145
|
+
was `disconnect()`ed as much as for one whose socket dropped.
|
|
146
|
+
|
|
147
|
+
### What is one register, and what that costs
|
|
148
|
+
|
|
149
|
+
- **Arrays are opaque.** `props.points`, `selectedShapeIds` and every other
|
|
150
|
+
array is one register holding the whole array, not one register per element.
|
|
151
|
+
Two concurrent edits to one array pick a winner instead of merging. Splitting
|
|
152
|
+
an array into per-element registers needs element identity, which the record
|
|
153
|
+
data does not carry, and would produce interleaved nonsense — a half-merged
|
|
154
|
+
draw stroke is worse than one of the two strokes.
|
|
155
|
+
- **The fractional `index` is one register too.** A concurrent reorder of the
|
|
156
|
+
same shape therefore lands on one of the two orders deterministically rather
|
|
157
|
+
than merging them. Two people reordering *different* shapes is fine: those
|
|
158
|
+
are different records.
|
|
159
|
+
- **An object is recursed into**, so `meta.a` and `meta.b` are separate
|
|
160
|
+
registers, but an empty object is itself a leaf. Two replicas that each add a
|
|
161
|
+
different key to one object agree on the keys and their values; the order the
|
|
162
|
+
keys sit in the JSON may differ, and object key order is not part of the value.
|
|
163
|
+
Tests compare documents key-sorted for that reason, and only for that reason.
|
|
164
|
+
- **A record body is one register of its own.** A put carries `base`: the stamp
|
|
165
|
+
its whole record was the sender's current view as of. A receiver that has no
|
|
166
|
+
stamp at all for some path — because it never saw the record created, only an
|
|
167
|
+
edit to one of its fields — takes that path from the body carrying the
|
|
168
|
+
greatest `base`, so two such receivers cannot settle on different bodies.
|
|
169
|
+
|
|
170
|
+
### Tombstones are bounded
|
|
171
|
+
|
|
172
|
+
A tombstone for a record that is gone is kept for one hour, and at most 5000 of
|
|
173
|
+
them are kept at once (`tombstoneMaxAgeMs`, `tombstoneLimit` on
|
|
174
|
+
`createSyncClient`); the oldest go first. Once a tombstone has been collected,
|
|
175
|
+
an edit older than it has nothing left to lose against and **resurrects the
|
|
176
|
+
record**. The bound is therefore a statement about how long a peer may be
|
|
177
|
+
partitioned: within it, deletes are safe against arbitrarily late messages;
|
|
178
|
+
past it, a straggler's edit can bring a deleted record back. Live records keep
|
|
179
|
+
their field stamps for as long as the document holds them.
|
|
180
|
+
|
|
181
|
+
### What it is not, and what it does not promise
|
|
182
|
+
|
|
183
|
+
There is still no operational transform, no server-authoritative log, and no
|
|
184
|
+
character-level text merge: concurrent edits to one text field pick a winner
|
|
185
|
+
rather than interleaving. The store remains the source of truth — the CRDT owns
|
|
186
|
+
stamps, never values — so nothing here needs Yjs or Automerge, and adding one
|
|
187
|
+
would mean replacing the store rather than sitting under it.
|
|
188
|
+
|
|
189
|
+
Four limits are real, and none of them is a bug to be fixed later:
|
|
190
|
+
|
|
191
|
+
- **Two writes to one field cannot both survive.** The register holds one value
|
|
192
|
+
and the greater stamp takes it. Same for the interior of an array or a string:
|
|
193
|
+
there is no identity inside a value to merge along.
|
|
194
|
+
- **A message that is never delivered is not merged.** A put carries stamps for
|
|
195
|
+
the paths it claims, not for the whole record, so a replica that missed a
|
|
196
|
+
record's creation and then sees an edit to one field adopts the rest of that
|
|
197
|
+
body without provenance. It is repaired by the next snapshot exchange, which
|
|
198
|
+
every `hello`, `connect` and reconnect triggers, from any peer that does hold
|
|
199
|
+
those stamps. Between the loss and that exchange, it can be stale.
|
|
200
|
+
- **A replica that has never connected starts its clock at zero.** Its offline
|
|
201
|
+
edits are stamped when they happen, so they are real writes and its own new
|
|
202
|
+
records always survive; but an edit to a field the room has already written
|
|
203
|
+
several times can sort below what is there. A replica that has been in the
|
|
204
|
+
room and comes back has observed the room's clock and does not have this
|
|
205
|
+
problem, which is the case the offline requirement is about.
|
|
206
|
+
- **A side effect that is not a function of its inputs cannot converge.** Store
|
|
207
|
+
rewrites are stamped and broadcast (see the protocol notes above), so one that
|
|
208
|
+
rewrites the same way everywhere settles in a single round: peers apply a
|
|
209
|
+
value they already hold and produce nothing further. One that rewrites
|
|
210
|
+
differently on each replica has no fixed point, here or in any other design.
|
|
101
211
|
|
|
102
212
|
## Transports
|
|
103
213
|
|
|
@@ -139,4 +249,23 @@ pnpm --filter @mocanvas/sync test
|
|
|
139
249
|
|
|
140
250
|
## License
|
|
141
251
|
|
|
142
|
-
|
|
252
|
+
**Source-available, not open source.** Free to use for:
|
|
253
|
+
|
|
254
|
+
- personal, non-commercial projects;
|
|
255
|
+
- non-profit organisations;
|
|
256
|
+
- development, evaluation, testing and staging — including inside a for-profit
|
|
257
|
+
company, so you can try it and build against it before committing;
|
|
258
|
+
- teaching and academic research.
|
|
259
|
+
|
|
260
|
+
**Shipping it in a commercial product, service or website needs a written
|
|
261
|
+
agreement with us.** That includes anything sold, anything that earns revenue
|
|
262
|
+
directly or through advertising, and internal tools running a for-profit
|
|
263
|
+
business.
|
|
264
|
+
|
|
265
|
+
To arrange one, or if you are unsure which side of the line you are on, write to
|
|
266
|
+
**mocanvas@symbio.agency** — we would rather answer the question than have you
|
|
267
|
+
guess.
|
|
268
|
+
|
|
269
|
+
The full terms are in `LICENSE`, shipped in this package. Versions released
|
|
270
|
+
earlier under MIT stay available under MIT, on the terms they were released
|
|
271
|
+
with.
|
package/UI.md
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# The default UI
|
|
2
|
+
|
|
3
|
+
`<Mocanvas />` ships a small, opinionated interface: a toolbar, a zoom bar, a
|
|
4
|
+
style panel, an optional statistics chip, and the chrome the editor draws on the
|
|
5
|
+
canvas itself. All of it is optional and all of it is themeable from CSS.
|
|
6
|
+
|
|
7
|
+
Source lives in `packages/mocanvas/src/ui/`:
|
|
8
|
+
|
|
9
|
+
| File | What it holds |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| `DefaultUi.tsx` | The toolbar, the zoom bar, the stats chip, and the `DefaultUi` wrapper |
|
|
12
|
+
| `StylePanel.tsx` | The style panel and `getStylePanelSections`, the rule for which rows appear |
|
|
13
|
+
| `icons.tsx` | The whole icon set, plus the geo icons generated from canvas geometry |
|
|
14
|
+
| `overlays.tsx` | The shared tooltip and popover layers, and `placeNear` |
|
|
15
|
+
| `ui.css` | Every design token and every rule |
|
|
16
|
+
| `useKeyboardShortcuts.ts` | The default key bindings |
|
|
17
|
+
|
|
18
|
+
## Design tokens
|
|
19
|
+
|
|
20
|
+
Tokens are declared on `.mocanvas` (the editor container), on `.mocanvas-panel`,
|
|
21
|
+
and on `.mocanvas-layer` (the floating tooltip and popover, which sit outside the
|
|
22
|
+
panels in the DOM). Override any of them on `.mocanvas`, on a wrapper, or on a
|
|
23
|
+
single panel.
|
|
24
|
+
|
|
25
|
+
### Surfaces and colour
|
|
26
|
+
|
|
27
|
+
| Token | Light | Dark | Used for |
|
|
28
|
+
| --- | --- | --- | --- |
|
|
29
|
+
| `--mocanvas-ui-panel` | `#ffffff` | `#1b1d22` | Panel, popover and picker background |
|
|
30
|
+
| `--mocanvas-ui-panel-border` | `#d6d9e0` | `#3c404a` | Panel hairline, dividers, group separators |
|
|
31
|
+
| `--mocanvas-ui-control` | `rgba(16,24,40,.06)` | `rgba(255,255,255,.08)` | Recessed background of a segmented button |
|
|
32
|
+
| `--mocanvas-ui-text` | `#16181d` | `#eceef3` | Icon and label colour |
|
|
33
|
+
| `--mocanvas-ui-muted` | `#5c6070` | `#a4a9b4` | Row labels, stats text |
|
|
34
|
+
| `--mocanvas-ui-accent` | `#2563eb` | `#6ea8fe` | Selected control, focus ring |
|
|
35
|
+
| `--mocanvas-ui-accent-fg` | `#ffffff` | `#0e1116` | Icon on an accent fill |
|
|
36
|
+
| `--mocanvas-ui-accent-soft` | 12% accent | 18% accent | Hover on a segmented button, open disclosure |
|
|
37
|
+
| `--mocanvas-ui-hover` | 6% ink | 9% white | Hover on a bare button |
|
|
38
|
+
| `--mocanvas-ui-active` | 13% ink | 18% white | Pointer-down on a bare button |
|
|
39
|
+
| `--mocanvas-ui-shadow` | — | — | Panel elevation |
|
|
40
|
+
| `--mocanvas-ui-tip-bg` / `-fg` / `-muted` | — | — | Tooltip pill, its label, its shortcut |
|
|
41
|
+
|
|
42
|
+
Every text-on-background pair is at or above 4.5:1 in both palettes. Measured on
|
|
43
|
+
the shipped values, the tightest are `accent` on a segmented control (4.58 light,
|
|
44
|
+
5.54 dark) and `muted` on the panel (6.25 light, 7.16 dark). Disabled controls
|
|
45
|
+
render at 35% opacity, which WCAG exempts.
|
|
46
|
+
|
|
47
|
+
### Metrics
|
|
48
|
+
|
|
49
|
+
| Token | Value | Meaning |
|
|
50
|
+
| --- | --- | --- |
|
|
51
|
+
| `--mocanvas-ui-btn` | `40px` | Every hit target — buttons, swatches, popover cells |
|
|
52
|
+
| `--mocanvas-ui-icon` | `20px` | Icon size inside a button |
|
|
53
|
+
| `--mocanvas-ui-gap` | `2px` | Gap between adjacent controls |
|
|
54
|
+
| `--mocanvas-ui-pad` | `4px` | Padding of a bar |
|
|
55
|
+
| `--mocanvas-ui-pad-lg` | `10px` | Padding of the style panel, gap between its groups |
|
|
56
|
+
| `--mocanvas-ui-inset` | `12px` | Distance from a panel to the viewport edge |
|
|
57
|
+
| `--mocanvas-ui-radius` / `-sm` | `12px` / `8px` | Panel radius / control radius |
|
|
58
|
+
| `--mocanvas-ui-dock` | `300px` | Width the bottom-left and bottom-right docks reserve beside the centred toolbar |
|
|
59
|
+
| `--mocanvas-ui-bottom-dock` | `62px` (`124px` under 1290px) | Height the bottom edge occupies; the style panel stops above it |
|
|
60
|
+
| `--mocanvas-ui-font` / `-mono` | system stacks | Panel type / stats chip |
|
|
61
|
+
|
|
62
|
+
`--mocanvas-ui-dock` is the mechanism that keeps the three bottom panels apart:
|
|
63
|
+
the toolbar is centred on the viewport but may not grow into the reservation, so
|
|
64
|
+
it wraps rather than collide. Shrink it if your zoom bar is narrower than the
|
|
65
|
+
default one.
|
|
66
|
+
|
|
67
|
+
### Canvas chrome
|
|
68
|
+
|
|
69
|
+
These are read by the editor's indicator layer (`Canvas.tsx`), not by the panels,
|
|
70
|
+
so they apply even with `hideUi`. They are deliberately **not** redefined for the
|
|
71
|
+
dark UI: the canvas keeps its own background, which does not follow
|
|
72
|
+
`prefers-color-scheme`.
|
|
73
|
+
|
|
74
|
+
| Token | Value | Used for |
|
|
75
|
+
| --- | --- | --- |
|
|
76
|
+
| `--mocanvas-selection` | `#2f6fe4` | Selection and hover outlines, handle strokes, brush border |
|
|
77
|
+
| `--mocanvas-selection-fg` | `#ffffff` | Fill behind a solid handle |
|
|
78
|
+
| `--mocanvas-brush-fill` | 12% selection | Brush rectangle interior |
|
|
79
|
+
| `--mocanvas-snap` | `#cf3fe0` | Snap lines and their end markers |
|
|
80
|
+
|
|
81
|
+
The two stroke colours clear 3:1 against a light canvas (`#f9fafb`: 4.45 and
|
|
82
|
+
3.69) and against a dark one (`#1b1d22`: 3.63 and 4.37). If you render a dark
|
|
83
|
+
canvas, swap `--mocanvas-selection-fg` for a dark value so handles stay filled
|
|
84
|
+
with the canvas colour rather than white.
|
|
85
|
+
|
|
86
|
+
Handles are drawn at 9px (corners), 6px (shape handles) and 5.5px (rotate) in
|
|
87
|
+
screen space, with a 1.5px stroke. Their hit radius is a separate editor
|
|
88
|
+
constant, `HANDLE_HIT_RADIUS` in `packages/editor/src/editor/selectionHandles.ts`.
|
|
89
|
+
|
|
90
|
+
## Icon grid rules
|
|
91
|
+
|
|
92
|
+
Icons are original artwork on a 24×24 viewBox, painted with `currentColor`.
|
|
93
|
+
|
|
94
|
+
- **Grid.** 24×24. Ink, stroke included, stays inside it.
|
|
95
|
+
- **Weight.** Stroke 1.75, round caps and joins. Only texture marks deviate and
|
|
96
|
+
say so at the call site: the fill hatching (1.25), the dotted rule (2.6 with a
|
|
97
|
+
zero-length dash, so the caps draw the dots), the mono rails (1.4).
|
|
98
|
+
- **Centring.** Ink is optically centred on (12, 12), within half a grid unit.
|
|
99
|
+
The only exceptions are semantic: `valign-top` and `valign-bottom` sit high and
|
|
100
|
+
low on purpose.
|
|
101
|
+
- **Extent.** The longest ink dimension lands between 16 and 18.25 units — about
|
|
102
|
+
17.75 for a full-bleed form — so no icon reads heavier than its neighbour. The
|
|
103
|
+
`size-*` ramp is exempt: its whole job is to differ in size.
|
|
104
|
+
- **Distinctness.** No two icons may draw the same artwork; `icons.test.tsx`
|
|
105
|
+
enforces this. It is why the handwriting font is a script `a` rather than a
|
|
106
|
+
fourth capital A, and why `oval` gets a wider box than `ellipse`.
|
|
107
|
+
- **Geo icons** are generated from `getGeoGeometry`, the same code the canvas
|
|
108
|
+
draws with, so a toolbar button always matches the shape it creates. Each kind
|
|
109
|
+
is fitted into the box `getGeoIconBox(kind)` returns: square at `GEO_BOX` (16)
|
|
110
|
+
by default, and flatter or narrower for kinds whose name implies a proportion
|
|
111
|
+
(`rectangle`, `oval`, the four arrows). The longest side is always `GEO_BOX`.
|
|
112
|
+
|
|
113
|
+
Render one with `<Icon name="select" size={20} />`. `size` sets the SVG's
|
|
114
|
+
attributes; buttons additionally take their icon size from `--mocanvas-ui-icon`,
|
|
115
|
+
so inline marks like the "mixed" badge keep the size they ask for.
|
|
116
|
+
|
|
117
|
+
## Overriding the UI
|
|
118
|
+
|
|
119
|
+
### Turn it off
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
<Mocanvas hideUi />
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
This drops the toolbar, zoom bar, style panel and stats chip. `ui.css` still
|
|
126
|
+
loads, so the canvas chrome tokens above keep working. Default keyboard
|
|
127
|
+
shortcuts are wired by `<Mocanvas />` itself and are unaffected; call
|
|
128
|
+
`useKeyboardShortcuts(editor)` yourself if you build on `<Canvas />` directly.
|
|
129
|
+
|
|
130
|
+
### Replace the canvas chrome
|
|
131
|
+
|
|
132
|
+
```tsx
|
|
133
|
+
<Mocanvas components={{ Indicators: MyIndicators, Brush: MyBrush, Background: MyGrid }} />
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`components` is forwarded to `<Canvas />`. `Indicators` draws selection and hover
|
|
137
|
+
outlines and handles, `Brush` the marquee, `Background` a layer behind the canvas.
|
|
138
|
+
Each receives `{ editor }` and renders into the SVG overlay (`Background` into a
|
|
139
|
+
plain DOM layer). Omit one to keep the default.
|
|
140
|
+
|
|
141
|
+
### Rebuild the panels
|
|
142
|
+
|
|
143
|
+
Compose your own from the exported parts:
|
|
144
|
+
|
|
145
|
+
```tsx
|
|
146
|
+
import { Mocanvas, Toolbar, ZoomBar, StylePanel, UiTooltip, Icon } from "mocanvas"
|
|
147
|
+
|
|
148
|
+
<Mocanvas hideUi>
|
|
149
|
+
<Toolbar />
|
|
150
|
+
<MyOwnInspector />
|
|
151
|
+
<UiTooltip />
|
|
152
|
+
</Mocanvas>
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
`Toolbar`, `ZoomBar`, `StylePanel`, `DebugStats`, `Popover`, `UiTooltip`, `Icon`
|
|
156
|
+
and `TOOLBAR_GROUPS` are all exported. Children of `<Mocanvas>` render above the
|
|
157
|
+
canvas inside the editor container, so the tokens apply to them too.
|
|
158
|
+
|
|
159
|
+
### Restyle it
|
|
160
|
+
|
|
161
|
+
```css
|
|
162
|
+
.mocanvas {
|
|
163
|
+
--mocanvas-ui-accent: #12b886;
|
|
164
|
+
--mocanvas-ui-radius: 6px;
|
|
165
|
+
--mocanvas-ui-btn: 44px;
|
|
166
|
+
--mocanvas-selection: #12b886;
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## The panels
|
|
171
|
+
|
|
172
|
+
### Toolbar — bottom centre
|
|
173
|
+
|
|
174
|
+
Tools in four groups separated by dividers: select and hand; draw and eraser; the
|
|
175
|
+
five common geo kinds plus a disclosure for the other fifteen; text, note, and
|
|
176
|
+
whichever of arrow, line and frame the app registered. Entries whose tool is not
|
|
177
|
+
registered disappear, so a cut-down `tools` prop yields a cut-down bar. The
|
|
178
|
+
active tool is `aria-pressed`; a geo button is pressed only when its own kind is
|
|
179
|
+
active. The disclosure opens a 5-column popover of the remaining kinds and is
|
|
180
|
+
itself marked pressed when one of them is active. The bar is centred on the
|
|
181
|
+
viewport, wraps to more rows rather than growing into the docks, and moves to a
|
|
182
|
+
row of its own below 1290px.
|
|
183
|
+
|
|
184
|
+
### Zoom bar — bottom left
|
|
185
|
+
|
|
186
|
+
Zoom out, the current percentage (a button that resets to 100%), zoom in, zoom to
|
|
187
|
+
fit, then a divider and undo/redo. Undo and redo are `disabled` when there is
|
|
188
|
+
nothing to undo or redo, at 35% opacity and without a tooltip.
|
|
189
|
+
|
|
190
|
+
### Style panel — top right
|
|
191
|
+
|
|
192
|
+
Appears when the selection carries at least one style, or when a drawing tool is
|
|
193
|
+
active with nothing selected. Rows are grouped, and separated by a hairline:
|
|
194
|
+
|
|
195
|
+
1. **Shape** — the geo kind, as one button showing the current shape that opens a
|
|
196
|
+
20-cell popover.
|
|
197
|
+
2. **Colour** — the stroke colour, and the label colour when the selection can
|
|
198
|
+
carry one; twelve swatches each, six to a row.
|
|
199
|
+
3. **Stroke and fill** — Fill, Dash and Size, four choices each.
|
|
200
|
+
4. **Text** — Font, then horizontal and vertical alignment sharing one row.
|
|
201
|
+
5. **Opacity** — a slider; only with a selection, since it edits shapes rather
|
|
202
|
+
than a style.
|
|
203
|
+
|
|
204
|
+
Which rows appear is decided by `getStylePanelSections(editor)`, which reads
|
|
205
|
+
`editor.getSharedStyles()` for a selection and
|
|
206
|
+
`editor.getStylePropsForType(toolId)` otherwise. A selection of two lines
|
|
207
|
+
therefore shows Colour, Dash and Size and nothing else. A row whose selected
|
|
208
|
+
shapes disagree shows a dashed "mixed" badge beside its label. The panel scrolls
|
|
209
|
+
when it is taller than the space above the bottom dock, with a fade and a shadow
|
|
210
|
+
at whichever edge has more content behind it. Below 560px it spans the width and
|
|
211
|
+
is capped at 42% of the height.
|
|
212
|
+
|
|
213
|
+
### Statistics chip — bottom right
|
|
214
|
+
|
|
215
|
+
Shape counts, drawn versus culled, and milliseconds per frame. Toggled with
|
|
216
|
+
`⌥D`, or with the `showStats` prop. It moves above the zoom bar below 560px.
|
|
217
|
+
|
|
218
|
+
### Tooltip and popover
|
|
219
|
+
|
|
220
|
+
Both are single, `position: fixed`, viewport-clamped layers (`overlays.tsx`).
|
|
221
|
+
A tooltip labels any element with `data-tooltip`, adding `data-shortcut` in a
|
|
222
|
+
muted weight; it appears after a 500ms rest, immediately on keyboard focus, sits
|
|
223
|
+
above its control so it never covers it, and flips below when there is no room.
|
|
224
|
+
`placeNear` does the arithmetic and is unit-tested. Popovers dismiss on outside
|
|
225
|
+
pointer-down and on Escape, which also returns focus to the button.
|
|
226
|
+
|
|
227
|
+
## States
|
|
228
|
+
|
|
229
|
+
| State | Bare button | Segmented button | Swatch |
|
|
230
|
+
| --- | --- | --- | --- |
|
|
231
|
+
| Rest | transparent | `--mocanvas-ui-control` | transparent |
|
|
232
|
+
| Hover | `--mocanvas-ui-hover` | `--mocanvas-ui-accent-soft` | `--mocanvas-ui-hover` |
|
|
233
|
+
| Pointer down | `--mocanvas-ui-active`, scaled 0.94 | as hover, scaled 0.94 | scaled 0.92 |
|
|
234
|
+
| Selected | accent fill, accent-fg icon | accent fill, accent-fg icon | double ring in accent |
|
|
235
|
+
| Selected + hover | accent fill plus an inset ring | same | same |
|
|
236
|
+
| Focus (keyboard) | 2px accent outline, 2px offset | same | same |
|
|
237
|
+
| Disabled | 35% opacity, no tooltip, default cursor | — | — |
|
|
238
|
+
|
|
239
|
+
The focus ring is offset by 2px so a ring of panel colour separates it from an
|
|
240
|
+
accent-filled button; without that gap, focus would be invisible on the active
|
|
241
|
+
tool. `prefers-reduced-motion` removes every transition.
|
|
242
|
+
|
|
243
|
+
## Pointer targets
|
|
244
|
+
|
|
245
|
+
Selection handles use a 24x24 px pointer target (`HANDLE_HIT_RADIUS = 12` in
|
|
246
|
+
`packages/editor/src/editor/selectionHandles.ts`), which is the WCAG 2.2
|
|
247
|
+
minimum. Two rules keep that from swallowing small shapes:
|
|
248
|
+
|
|
249
|
+
- Edge handles (top, right, bottom, left) only appear once that edge is at
|
|
250
|
+
least `4 * HANDLE_HIT_RADIUS` long on screen; below that the two corners
|
|
251
|
+
already cover the whole edge.
|
|
252
|
+
- On a selection smaller than six handles across, `getHandleHitRadius` scales
|
|
253
|
+
the target down (never below 4 px) so the shape's interior stays draggable.
|
|
254
|
+
|
|
255
|
+
Both measure the *screen-space* edge lengths of the transformed corners, so a
|
|
256
|
+
rotated selection behaves the same as an upright one.
|