efficient-easel 0.0.0 → 0.96.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/LICENSE +10 -0
- package/README.md +723 -4
- package/dist/autosave/index.d.ts +101 -0
- package/dist/autosave/index.js +1 -0
- package/dist/blocks/index.d.ts +295 -0
- package/dist/blocks/index.js +0 -0
- package/dist/buckets-1vRlQceY.d.ts +13 -0
- package/dist/canvas/index.d.ts +194 -0
- package/dist/canvas/index.js +1 -0
- package/dist/chrome/chrome.css +570 -0
- package/dist/chrome/index.d.ts +219 -0
- package/dist/chrome/index.js +21 -0
- package/dist/chunk-2LUSK32B.js +1 -0
- package/dist/chunk-2OAZYSWJ.js +1 -0
- package/dist/chunk-67VZRQLG.js +13 -0
- package/dist/chunk-6JDII7N3.js +1 -0
- package/dist/chunk-6OPSJGAI.js +1 -0
- package/dist/chunk-6WY6ZXJG.js +1 -0
- package/dist/chunk-7A7O2JWQ.js +1 -0
- package/dist/chunk-BZ5ASXOL.js +1 -0
- package/dist/chunk-CERTFXCV.js +13 -0
- package/dist/chunk-EFTTSIMK.js +1 -0
- package/dist/chunk-FI23ALVJ.js +1 -0
- package/dist/chunk-FJE4IDBQ.js +1 -0
- package/dist/chunk-GGTPNGFJ.js +1 -0
- package/dist/chunk-H4B72XCJ.js +1 -0
- package/dist/chunk-H6DDIBQ4.js +1 -0
- package/dist/chunk-KDIVA6WD.js +1 -0
- package/dist/chunk-LPRQDDQO.js +1 -0
- package/dist/chunk-MCLBHENY.js +1 -0
- package/dist/chunk-MFIRYGHW.js +1 -0
- package/dist/chunk-NKB3KVAB.js +1 -0
- package/dist/chunk-O2NLQBFX.js +1 -0
- package/dist/chunk-OGZFDJ6G.js +1 -0
- package/dist/chunk-OWBO4WKA.js +0 -0
- package/dist/chunk-QB3BZIRA.js +5 -0
- package/dist/chunk-RE4LP46F.js +1 -0
- package/dist/chunk-STFMZJKU.js +14 -0
- package/dist/chunk-SUO5627P.js +1 -0
- package/dist/chunk-TDA4HH54.js +1 -0
- package/dist/chunk-WK3AFPXY.js +1 -0
- package/dist/chunk-YIMRTIBN.js +3 -0
- package/dist/chunk-YNZZYHA4.js +450 -0
- package/dist/chunk-ZAOM6PFH.js +1 -0
- package/dist/chunk-ZNCEOSXO.js +1 -0
- package/dist/copy/copy.css +282 -0
- package/dist/copy/index.d.ts +336 -0
- package/dist/copy/index.js +10 -0
- package/dist/data/index.d.ts +208 -0
- package/dist/data/index.js +1 -0
- package/dist/drag/drag.css +360 -0
- package/dist/drag/index.d.ts +379 -0
- package/dist/drag/index.js +18 -0
- package/dist/edit-lock/index.d.ts +354 -0
- package/dist/edit-lock/index.js +1 -0
- package/dist/editor/editor.css +58 -0
- package/dist/editor/index.d.ts +361 -0
- package/dist/editor/index.js +9 -0
- package/dist/fields/fields.css +207 -0
- package/dist/fields/index.d.ts +273 -0
- package/dist/fields/index.js +46 -0
- package/dist/gold/gold.css +196 -0
- package/dist/gold/index.d.ts +280 -0
- package/dist/gold/index.js +1 -0
- package/dist/icons/index.d.ts +474 -0
- package/dist/icons/index.js +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +1 -0
- package/dist/markup/index.d.ts +452 -0
- package/dist/markup/index.js +1 -0
- package/dist/menu/index.d.ts +49 -0
- package/dist/menu/index.js +15 -0
- package/dist/menu/menu.css +254 -0
- package/dist/node-ops/index.d.ts +91 -0
- package/dist/node-ops/index.js +1 -0
- package/dist/panel/index.d.ts +1088 -0
- package/dist/panel/index.js +16 -0
- package/dist/panel/panel.css +1460 -0
- package/dist/pick/index.d.ts +153 -0
- package/dist/pick/index.js +14 -0
- package/dist/pick/pick.css +235 -0
- package/dist/plan-DtNw2VuI.d.ts +88 -0
- package/dist/rail/index.d.ts +145 -0
- package/dist/rail/index.js +1 -0
- package/dist/rail/rail.css +175 -0
- package/dist/render/index.d.ts +117 -0
- package/dist/render/index.js +5 -0
- package/dist/resolve-DpH8YQOA.d.ts +57 -0
- package/dist/shell/index.d.ts +159 -0
- package/dist/shell/index.js +1 -0
- package/dist/shell/shell.css +122 -0
- package/dist/strings/catalogue.d.ts +141 -0
- package/dist/strings/catalogue.js +1 -0
- package/dist/strings/index.d.ts +56 -0
- package/dist/strings/index.js +2 -0
- package/dist/style-clipboard/index.d.ts +184 -0
- package/dist/style-clipboard/index.js +1 -0
- package/dist/tree/index.d.ts +173 -0
- package/dist/tree/index.js +1 -0
- package/dist/tree/tree.css +618 -0
- package/dist/types-CEqUFH2j.d.ts +129 -0
- package/dist/types-CuNiSnX6.d.ts +204 -0
- package/dist/types-DNrn_w4V.d.ts +88 -0
- package/dist/types-DziKCwsN.d.ts +105 -0
- package/dist/types-_Vayx8ov.d.ts +153 -0
- package/dist/types-pGBXevBU.d.ts +131 -0
- package/dist/useNodeOps-6IAej9jy.d.ts +68 -0
- package/dist/watch-DmasQA61.d.ts +60 -0
- package/dist/wordFit-9B9nqyf3.d.ts +35 -0
- package/dist/words-B2YLaKEr.d.ts +30 -0
- package/dist/words-B87GIFWH.d.ts +86 -0
- package/dist/words-BK-npta-.d.ts +27 -0
- package/dist/words-CD3WADyb.d.ts +51 -0
- package/dist/words-CsRWXkY4.d.ts +129 -0
- package/dist/words-D9ChvbNC.d.ts +52 -0
- package/dist/words-DOhF-4D4.d.ts +35 -0
- package/dist/words-DagJdwIA.d.ts +78 -0
- package/dist/words-DfxEp_RH.d.ts +142 -0
- package/package.json +202 -3
package/README.md
CHANGED
|
@@ -1,6 +1,725 @@
|
|
|
1
1
|
# efficient-easel
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
**Efficient Easel** is the engine under every EFFICIENT editor. An easel holds
|
|
4
|
+
the canvas and has no say in what is painted on it: the engine never learns
|
|
5
|
+
what a section is, and each editor brings its own blocks and its own data.
|
|
6
|
+
|
|
7
|
+
The EFFICIENT editors' shared engine and chrome — the campaign designer
|
|
8
|
+
(`efficient-admin-portal`) and the website editor (`efficient-shop`) draw from
|
|
9
|
+
one source instead of hand-rolling and drifting — and the engine both run on,
|
|
10
|
+
which replaced Puck (`@measured/puck`) layer by layer and has carried none of
|
|
11
|
+
it since 0.93.0.
|
|
12
|
+
|
|
13
|
+
> **Published as `efficient-easel`** on public npm from 0.96.0 (it was
|
|
14
|
+
> `efficient-editor` from 0.69.0 to 0.95.0, and `editor-shell` up to 0.66.0).
|
|
15
|
+
> Released by CI from a GitHub Release — see [PUBLISHING.md](./PUBLISHING.md).
|
|
16
|
+
> Moving from the old name without touching an import:
|
|
17
|
+
> `"efficient-editor": "npm:efficient-easel@^0.96.0"`.
|
|
18
|
+
|
|
19
|
+
## Why
|
|
20
|
+
|
|
21
|
+
The storefront editor (`efficient-shop`, Next 16 + Puck) and the campaign
|
|
22
|
+
designer (`efficient-admin-portal`, Vite/React) each hand-rolled their own narrow
|
|
23
|
+
left icon-rail. They had already drifted — button 34px vs 36px, icon 18px vs
|
|
24
|
+
20px, radius 9 vs 8, a CSS-var accent vs a Tailwind one. `EditorRail` is the one
|
|
25
|
+
rail they converge on.
|
|
26
|
+
|
|
27
|
+
## The leaf
|
|
28
|
+
|
|
29
|
+
The storefront's editor chrome renders in **Next-16 server components**, so the
|
|
30
|
+
rail is a **self-contained leaf** at `efficient-easel/rail`: it imports nothing heavy
|
|
31
|
+
and touches no `document` / `window` / `localStorage` at import or render, so a
|
|
32
|
+
server component can pull it in safely. `tests/rail-leaf-safety.test.tsx` proves
|
|
33
|
+
it.
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
// A client component in any editor:
|
|
37
|
+
'use client';
|
|
38
|
+
import {
|
|
39
|
+
EditorRail,
|
|
40
|
+
PagesIcon, LayersIcon, StylesIcon, MediaIcon, SitemapIcon, AddIcon,
|
|
41
|
+
} from 'efficient-easel/rail';
|
|
42
|
+
import 'efficient-easel/rail.css'; // once, from a client/global entry
|
|
43
|
+
|
|
44
|
+
export function LeftRail({ view, onView, onAdd }) {
|
|
45
|
+
return (
|
|
46
|
+
<EditorRail
|
|
47
|
+
ariaLabel="Left panel views"
|
|
48
|
+
items={[
|
|
49
|
+
{ id: 'pages', label: 'Pages', icon: <PagesIcon />, active: view === 'pages', onSelect: () => onView('pages') },
|
|
50
|
+
{ id: 'layers', label: 'Layers', icon: <LayersIcon />, active: view === 'layers', onSelect: () => onView('layers') },
|
|
51
|
+
{ id: 'styles', label: 'Styles', icon: <StylesIcon />, active: view === 'styles', onSelect: () => onView('styles') },
|
|
52
|
+
{ id: 'media', label: 'Media', icon: <MediaIcon />, onSelect: onOpenMedia },
|
|
53
|
+
{ id: 'add', label: 'Add page', icon: <AddIcon />, onSelect: onAdd },
|
|
54
|
+
]}
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## API
|
|
61
|
+
|
|
62
|
+
### `<EditorRail items ariaLabel? className? orientation? />`
|
|
63
|
+
|
|
64
|
+
A **pure presentational** toolbar. It owns the look, never the app state — you
|
|
65
|
+
decide which item is `active` and what each `onSelect` does.
|
|
66
|
+
|
|
67
|
+
| prop | type | default | notes |
|
|
68
|
+
|---|---|---|---|
|
|
69
|
+
| `items` | `EditorRailItem[]` | — | buttons, in render order |
|
|
70
|
+
| `ariaLabel` | `string` | `"Editor"` | names the toolbar for assistive tech |
|
|
71
|
+
| `className` | `string` | — | merged onto `.es-rail` |
|
|
72
|
+
| `orientation` | `'column' \| 'row'` | `'column'` | which way the buttons run |
|
|
73
|
+
|
|
74
|
+
#### `orientation`
|
|
75
|
+
|
|
76
|
+
`'column'` is the rail every editor draws today: a fixed 46px strip wearing its
|
|
77
|
+
own card. It is the default, and it renders exactly what it rendered before this
|
|
78
|
+
prop existed.
|
|
79
|
+
|
|
80
|
+
`'row'` lays the same buttons left to right for a top bar — **no fixed width**
|
|
81
|
+
(as wide as its buttons) and **no card** (no background, radius or shadow),
|
|
82
|
+
because the bar already has its own surface. The button size, icon size, gap and
|
|
83
|
+
every hover/active colour are identical in both directions; only the container
|
|
84
|
+
changes. A row is 34px tall, so a 52px bar centres it with 9px to spare.
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
// The same rail, in the top bar's empty middle.
|
|
88
|
+
<EditorRail orientation="row" ariaLabel="Editor views" items={items} />
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`aria-orientation` follows the layout (`vertical` / `horizontal`); the `toolbar`
|
|
92
|
+
role does not change, because a rail that mixes view toggles with plain actions
|
|
93
|
+
is a toolbar whichever way it runs.
|
|
94
|
+
|
|
95
|
+
### `EditorRailItem`
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
interface EditorRailItem {
|
|
99
|
+
id: string; // React key + selection correlation
|
|
100
|
+
label: string; // aria-label + hover title
|
|
101
|
+
icon: React.ReactNode; // an 18px currentColor glyph — use the shipped icons
|
|
102
|
+
active?: boolean; // present ⇒ view toggle (renders aria-pressed); omit for actions
|
|
103
|
+
disabled?: boolean;
|
|
104
|
+
onSelect?: () => void;
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
A **view item** passes `active: true | false` and renders as a toggle
|
|
109
|
+
(`aria-pressed`, `.is-active` highlight). An **action item** (the quick-add "+")
|
|
110
|
+
omits `active` and renders as a plain button.
|
|
111
|
+
|
|
112
|
+
`EditorRailButton` — the single-button building block `EditorRail` maps to — is
|
|
113
|
+
exported too, for bespoke layouts.
|
|
114
|
+
|
|
115
|
+
## Tokens
|
|
116
|
+
|
|
117
|
+
The same values, two ways — `railTokens` (JS) and `rail.css` (CSS custom
|
|
118
|
+
properties on `.es-rail`). Taken verbatim from the storefront editor's `.sf-rail`
|
|
119
|
+
(the visual reference).
|
|
120
|
+
|
|
121
|
+
| token | value | CSS var |
|
|
122
|
+
|---|---|---|
|
|
123
|
+
| rail width | `46px` | `--es-rail-w` |
|
|
124
|
+
| rail padding (y) | `8px` | `--es-rail-pad-y` |
|
|
125
|
+
| button gap | `4px` | `--es-rail-gap` |
|
|
126
|
+
| rail radius | `12px` | `--es-rail-radius` |
|
|
127
|
+
| button size | `34px` | `--es-rail-btn-size` |
|
|
128
|
+
| button radius | `9px` | `--es-rail-btn-radius` |
|
|
129
|
+
| icon size | `18px` | `--es-rail-icon-size` |
|
|
130
|
+
| background | `#ffffff` | `--es-rail-bg` |
|
|
131
|
+
| idle icon | `#6b7280` | `--es-rail-fg` |
|
|
132
|
+
| hover bg / fg | `#f3f4f6` / `#111827` | `--es-rail-hover-bg` / `--es-rail-hover-fg` |
|
|
133
|
+
| accent (active) | `#2563eb` | `--es-rail-accent` |
|
|
134
|
+
| accent tint (active bg) | `#eff6ff` | `--es-rail-accent-tint` |
|
|
135
|
+
|
|
136
|
+
`orientation="row"` adds **no new token**. It re-points three of the ones above
|
|
137
|
+
— `--es-rail-bg` to `transparent`, `--es-rail-shadow` to `none`,
|
|
138
|
+
`--es-rail-radius` to `0` — so the card look stays declared in one place, and it
|
|
139
|
+
reuses `--es-rail-pad-y` rotated onto the row's ends and `--es-rail-gap` as-is.
|
|
140
|
+
|
|
141
|
+
Re-theme without new CSS by overriding a var on the element:
|
|
142
|
+
`style={{ ['--es-rail-accent']: brand }}`.
|
|
143
|
+
|
|
144
|
+
## The gold layer — type in place
|
|
145
|
+
|
|
146
|
+
`GoldTextInput` (subpath `efficient-easel/gold`, added in 0.3.0) is the surface a
|
|
147
|
+
merchant types INTO on the page, with the markup formatting appearing as they
|
|
148
|
+
type. A textarea whose own text is transparent sits on top of a layer holding the
|
|
149
|
+
same characters, painted; what you read is the layer, what the caret walks is the
|
|
150
|
+
box. Both inherit their type from whatever the host renders them inside, so the
|
|
151
|
+
glyphs land on top of each other.
|
|
152
|
+
|
|
153
|
+
It is **controlled and writes nothing** — no document, no sections, no Puck. The
|
|
154
|
+
host owns the string and decides what a finished edit means.
|
|
155
|
+
|
|
156
|
+
```tsx
|
|
157
|
+
'use client';
|
|
158
|
+
import { GoldTextInput } from 'efficient-easel/gold';
|
|
159
|
+
import { STOREFRONT_MARKUP } from 'efficient-easel/markup';
|
|
160
|
+
import 'efficient-easel/gold.css'; // once, from a client/global entry
|
|
161
|
+
|
|
162
|
+
<GoldTextInput
|
|
163
|
+
value={draft}
|
|
164
|
+
rules={STOREFRONT_MARKUP}
|
|
165
|
+
placeholder="Add a heading"
|
|
166
|
+
ariaLabel="Section heading"
|
|
167
|
+
onInput={setDraft}
|
|
168
|
+
onCommit={(value) => save(value)} // blur, or Enter on a single-line field
|
|
169
|
+
onCancel={(restored) => setDraft(restored)}
|
|
170
|
+
/>;
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
| prop | type | default | notes |
|
|
174
|
+
|---|---|---|---|
|
|
175
|
+
| `value` | `string` | — | the authored string, delimiters and all |
|
|
176
|
+
| `onInput` | `(value: string) => void` | — | every keystroke; the layer repaints from what you send back |
|
|
177
|
+
| `onCommit` | `(value: string) => void` | — | blur, or Enter when `multiline` is false. Once per session, never if nothing was typed |
|
|
178
|
+
| `onCancel` | `(restored: string) => void` | — | Escape. The argument is the value held at focus time; the host puts it back |
|
|
179
|
+
| `multiline` | `boolean` | `false` | what Enter means, **not** how the text wraps — both modes wrap |
|
|
180
|
+
| `placeholder` | `string` | — | visible until the first keystroke |
|
|
181
|
+
| `className` | `string` | — | merged onto `.es-gold` |
|
|
182
|
+
| `rules` | `readonly InlineRule[]` | `STANDARD_MARKUP` | pass your product's set — `STOREFRONT_MARKUP` / `CAMPAIGN_MARKUP` |
|
|
183
|
+
| `ariaLabel` | `string` | — | in-place editing has no visible label |
|
|
184
|
+
|
|
185
|
+
**The grammar is ours, and there is one.** Runs are parsed by
|
|
186
|
+
`efficient-easel/markup`, which this package owns since 2026-09-28 (it started as
|
|
187
|
+
`efficient-shell/markup`). The published page and the campaign email render from
|
|
188
|
+
the same module, so a toolbar button, a page and this box can never disagree
|
|
189
|
+
about what a delimiter means. It is framework-free and imports nothing. See
|
|
190
|
+
[the text grammar](#the-text-grammar--efficient-easelmarkup) below.
|
|
191
|
+
|
|
192
|
+
**Two deliberate limits.** The asterisks are FADED, never removed. They go to
|
|
193
|
+
nothing while the box is unfocused, so a merchant who is not typing does not read
|
|
194
|
+
`**` in their own page text, and they come back dimmed while the caret is in the
|
|
195
|
+
box — but the characters stay in the layer at their full width in both states.
|
|
196
|
+
Actually removing them needs a second text engine, and is out of scope. And the
|
|
197
|
+
layer only paints what cannot move a glyph: colour, opacity, decoration, a
|
|
198
|
+
faux-bold shadow. A real `font-weight: 600` is wider, so a marked line would wrap
|
|
199
|
+
earlier than the transparent box on top of it and every following line would be
|
|
200
|
+
drawn over the wrong text. The real weight and slant appear on the page the
|
|
201
|
+
moment the merchant clicks away.
|
|
202
|
+
|
|
203
|
+
## The text grammar — `efficient-easel/markup`
|
|
204
|
+
|
|
205
|
+
The ONE rule for copy a merchant types, in both editors, on the page and in the
|
|
206
|
+
email. Framework-free: no React, no DOM, no imports.
|
|
207
|
+
|
|
208
|
+
| Mark | Means | Written by a button |
|
|
209
|
+
|---|---|---|
|
|
210
|
+
| `**x**` | **Strong** (bold) | yes, ⌘B |
|
|
211
|
+
| `==x==` | **Emphasis** (the brand accent) | yes |
|
|
212
|
+
| `^^x^^` | **Quiet** | yes |
|
|
213
|
+
| `_x_` | italic, only at a word edge | yes |
|
|
214
|
+
| `~~x~~` | strikethrough | yes |
|
|
215
|
+
| `[words](href)` | a link — `http`, `https`, `mailto`, `tel` or no scheme only | yes |
|
|
216
|
+
| `- ` at the start of a line | a bullet line, one level | yes |
|
|
217
|
+
| `@@x@@`, `++x++` | Emphasis, Strong (the storefront's old word styles) | no — read only |
|
|
218
|
+
| `*x*`, `#x#`, `[x]` | legacy accent, bold, italic (per product set) | no — read only |
|
|
219
|
+
|
|
220
|
+
The three names a merchant sees are in `MARK_NAMES`. A run never crosses a line
|
|
221
|
+
break, and a run may hold one more run or link (`**bold _with italic_**`); a third
|
|
222
|
+
level stays as typed.
|
|
223
|
+
|
|
224
|
+
| Export | Notes |
|
|
225
|
+
|---|---|
|
|
226
|
+
| `parseMarkup(value, rules)` → `MarkupLine[]` | The whole grammar as a tree. Each run keeps the delimiters that were saved. |
|
|
227
|
+
| `serializeMarkup(lines)` | The inverse: `serializeMarkup(parseMarkup(s, rules)) === s`, for every string. Pinned by `tests/markup-round-trip.test.ts` (made-up strings in the shape of saved copy). All 917 saved production strings were checked once, off-repo, on 2026-09-29 (editor-shell #89). |
|
|
228
|
+
| `isSafeLinkHref(href)` | The link rule. Call it before writing any `href`. |
|
|
229
|
+
| `tokenizeInline(value, rules)`, `stripInline` | The flat reading older renderers use: runs only, no links, no nesting. |
|
|
230
|
+
| `applyMark(text, start, end, style)`, `MARKUP_TOOLS`, `markupTools`, `COPY_FIELD_TOOLS` | The writer behind a toolbar button, and the button descriptors. |
|
|
231
|
+
| `STANDARD_MARKUP`, `STOREFRONT_MARKUP`, `CAMPAIGN_MARKUP` | The rule sets. The last two add each product's legacy runs. |
|
|
232
|
+
|
|
233
|
+
## The edit lock — two operators, one document
|
|
234
|
+
|
|
235
|
+
`efficient-easel/edit-lock` (added in 0.32.0) is the answer to "is the save path
|
|
236
|
+
allowed to run", shared by every editor in the house. It is pure TypeScript: no
|
|
237
|
+
React, no browser global, no dependency at all, so a server component may import
|
|
238
|
+
it.
|
|
239
|
+
|
|
240
|
+
It exists because the storefront editor and the campaign designer had written
|
|
241
|
+
the same rules twice, in two repos, and the two copies had already drifted — the
|
|
242
|
+
take-over half only existed on one side and the claim driver only on the other.
|
|
243
|
+
Both are here now, and neither editor keeps a copy.
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
import { createEditLock, canWriteDocument, documentPermissions } from 'efficient-easel/edit-lock';
|
|
247
|
+
|
|
248
|
+
// The only two things that differ between one editor and the next.
|
|
249
|
+
export const pageEditLock = createEditLock({
|
|
250
|
+
readFailure: (err) =>
|
|
251
|
+
err instanceof ApiError ? { status: err.status, data: err.data } : null,
|
|
252
|
+
conflictCode: 'page_changed_elsewhere',
|
|
253
|
+
});
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
The plain half needs no binding: `canWriteDocument`, `holderOf`,
|
|
257
|
+
`readLockHolder`, `accessForNewDocument`, `documentPermissions`, `describeAge`,
|
|
258
|
+
`nameOrSomeone`, `newEditorClientId`, `shouldReleaseHeldWrite`,
|
|
259
|
+
`shouldReloadAfterTakeover`, `documentVersionMoved`, `reloadIfVersionMoved`.
|
|
260
|
+
The bound half —
|
|
261
|
+
`accessAfterClaimFailure`, `accessAfterTakeoverFailure`,
|
|
262
|
+
`accessAfterHeartbeatFailure`, `isAuthRejection`, `readSaveConflict`,
|
|
263
|
+
`readLockTaken`, `makeClaimBeat` — comes back from `createEditLock`.
|
|
264
|
+
|
|
265
|
+
**A take-over stops the other tab at its next save** (0.77.0). A document write
|
|
266
|
+
that carries the tab's `client_id` (the one its claim sends) is refused with
|
|
267
|
+
`EDIT_LOCK_TAKEN` once somebody else has taken the document from that tab.
|
|
268
|
+
`readLockTaken` reads the holder out of that refusal: set `takenover` and HOLD
|
|
269
|
+
the edit, never retry it as a failed save. The other half is the taker's: when
|
|
270
|
+
a take-over is granted and nothing of theirs is waiting (`shouldReloadAfterTakeover`,
|
|
271
|
+
or `makeClaimBeat`'s optional `reloadIfMoved`), load the stored copy if it moved
|
|
272
|
+
while they could only look (`reloadIfVersionMoved`, which re-checks that nothing
|
|
273
|
+
was typed while the version was being read).
|
|
274
|
+
|
|
275
|
+
**Two things this module will not do.** It never takes the ability to edit away
|
|
276
|
+
because a courtesy call failed: every claim failure that is not a 409 naming a
|
|
277
|
+
holder falls back to `editing`, because the save-time version check is what
|
|
278
|
+
actually protects the work. And a failed TAKE-OVER is a different question from a
|
|
279
|
+
failed first claim — it keeps the state it was pressed from, so a take-over that
|
|
280
|
+
did not land never puts two people in one document with the holder untold.
|
|
281
|
+
|
|
282
|
+
## The editor frame — top bar, panels, inspector head
|
|
283
|
+
|
|
284
|
+
`efficient-easel/chrome` (added in 0.41.0) is the frame both editors wear: the 52px
|
|
285
|
+
top bar and its three clusters, the icon-only device switch, the save pill, flat
|
|
286
|
+
side panels with drag grips, and the inspector head. Every part is controlled —
|
|
287
|
+
the editor owns the device, the save status, the widths and which panels are open.
|
|
288
|
+
|
|
289
|
+
```tsx
|
|
290
|
+
import {
|
|
291
|
+
EditorTopBar, TopBarCluster, TopBarDivider, EditorIconButton, DeviceSwitch,
|
|
292
|
+
SaveState, PanelResizeHandle, InspectorHead, DEFAULT_PANEL_W, panelWidthVars,
|
|
293
|
+
} from 'efficient-easel/chrome';
|
|
294
|
+
import 'efficient-easel/chrome.css'; // once, after shell.css
|
|
295
|
+
|
|
296
|
+
<div className="es-editor" style={panelWidthVars(widths)}> {/* the vars go on the ROOT */}
|
|
297
|
+
<EditorTopBar>
|
|
298
|
+
<TopBarCluster kind="rail"><EditorRail orientation="row" items={items} /></TopBarCluster>
|
|
299
|
+
<TopBarCluster>
|
|
300
|
+
<EditorIconButton label="Undo" onClick={undo}><UndoIcon /></EditorIconButton>
|
|
301
|
+
<TopBarDivider />
|
|
302
|
+
<DeviceSwitch devices={devices} value={device} onChange={setDevice} />
|
|
303
|
+
</TopBarCluster>
|
|
304
|
+
<TopBarCluster kind="right">
|
|
305
|
+
<SaveState tone="saved" label="" />
|
|
306
|
+
<button className="es-btn-primary">Done</button>
|
|
307
|
+
</TopBarCluster>
|
|
308
|
+
</EditorTopBar>
|
|
309
|
+
…
|
|
310
|
+
</div>
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
## The Layers tree — one row at every depth
|
|
314
|
+
|
|
315
|
+
`efficient-easel/tree` (added in 0.42.0) is the Layers tree both editors draw. Every
|
|
316
|
+
row has the same shape; only `Subtree` says depth.
|
|
317
|
+
|
|
318
|
+
```tsx
|
|
319
|
+
import { LayersTree, TreeGroupLabel, Subtree, TreeRow, TreeRowAction } from 'efficient-easel/tree';
|
|
320
|
+
import 'efficient-easel/tree.css';
|
|
321
|
+
|
|
322
|
+
<LayersTree>
|
|
323
|
+
<TreeGroupLabel>Body</TreeGroupLabel>
|
|
324
|
+
<TreeRow icon={<SectionGlyph />} name="Section" preview="FUSION FORGED · GTS-8"
|
|
325
|
+
selected fold={{ open, label: 'section', onToggle }}
|
|
326
|
+
actions={<TreeRowAction kind="eye" label="Hide Section" onClick={hide}><EyeIcon /></TreeRowAction>} />
|
|
327
|
+
<Subtree>
|
|
328
|
+
<TreeRow icon={<TextLinesIcon />} name="Text" preview="BACK IN STOCK NOW" />
|
|
329
|
+
</Subtree>
|
|
330
|
+
</LayersTree>
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
## Copy style, paste style — one rule for both editors
|
|
334
|
+
|
|
335
|
+
`efficient-easel/style-clipboard` (added in 0.50.0) is the right-click menu's
|
|
336
|
+
**Copy style** / **Paste style from …** pair (⌥⌘C / ⌥⌘V, Ctrl+Alt+C / Ctrl+Alt+V).
|
|
337
|
+
The host says which of a node's rows are Style or Layout and saves the new props
|
|
338
|
+
through its own op; everything else is here, once:
|
|
339
|
+
|
|
340
|
+
```ts
|
|
341
|
+
import { copyStyle, heldStyle, planPaste, pasteMessage, styleMenuItems, styleShortcut, fieldKind, withStyleKind } from 'efficient-easel/style-clipboard';
|
|
342
|
+
|
|
343
|
+
copyStyle({ scope: `site:${siteId}`, type, label, rows, props }); // rows: { path, kind }[]
|
|
344
|
+
const held = heldStyle(`site:${siteId}`); // null in any other scope
|
|
345
|
+
const plan = planPaste(held, { type, rows, props }); // → { props, pasted, changed }
|
|
346
|
+
toast(pasteMessage(plan, held.sourceLabel, label)); // "Pasted 4 settings from Heading."
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
A row pastes only when the **key and the kind** match (`fieldKind`: a tagged
|
|
350
|
+
factory's kind, `number`/`text`, or a choice row's control plus the values it
|
|
351
|
+
offers). Two **choice** rows of the same control are the same setting, and the
|
|
352
|
+
value decides: it pastes when the target offers it (0.51.0). A row whose kind is
|
|
353
|
+
unknown pastes only between two components of the same type. An empty
|
|
354
|
+
(inherited) value pastes as empty, so the target inherits too.
|
|
355
|
+
|
|
356
|
+
## The document — `efficient-easel/data`
|
|
357
|
+
|
|
358
|
+
`efficient-easel/data` (added in 0.67.0) is the bottom layer of the engine that
|
|
359
|
+
replaces Puck (the blueprint's step 1.1): the saved document as pure
|
|
360
|
+
functions. It imports nothing and touches no browser global, so the editor's
|
|
361
|
+
store, a Next server component, a Node script and a test all call the same
|
|
362
|
+
code.
|
|
363
|
+
|
|
364
|
+
**The format does not change.** A document is exactly what Puck 0.20 saves —
|
|
365
|
+
`{ root: { props }, content: [...], zones: {} }`, with a block's children
|
|
366
|
+
inline in its slot props — so no campaign or page migrates when Puck goes.
|
|
367
|
+
|
|
368
|
+
```ts
|
|
369
|
+
import { normalizeData, find, insert, move, remove, duplicate, setProps, setRoot, createNode, walk, ROOT_LIST } from 'efficient-easel/data';
|
|
370
|
+
|
|
371
|
+
const doc = normalizeData(saved, config); // what the editor does on open
|
|
372
|
+
const at = find(doc, config, id); // { node, place, path } | null
|
|
373
|
+
let next = insert(doc, config, { ...ROOT_LIST, index: 2 }, createNode(config, 'EmailSection'));
|
|
374
|
+
next = move(next, config, id, { parentId: sectionId, slot: 'col2', index: 0 });
|
|
375
|
+
next = duplicate(next, config, id, { strip: releaseBakedImage });
|
|
376
|
+
next = setProps(next, config, id, { level: 3 });
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
| Export | Notes |
|
|
380
|
+
|---|---|
|
|
381
|
+
| `normalizeData(data, config)` | Puck's load: missing top-level slots become `[]`, `zones` becomes `{}`, the root's `defaultProps` fill what is missing. Older formats (props written on `root`, children parked in `zones`) are converted the way Puck's `migrate()` converts them. **A document already in shape comes back as the same object.** |
|
|
382
|
+
| `find`, `walk`, `slotsOf`, `getList` | Read. `walk` visits parent before children, slots in declaration order; returning a node from the visitor replaces it. |
|
|
383
|
+
| `insert`, `move`, `remove`, `duplicate` | Change the tree. `move` within one list takes the index AFTER the move (Puck's rule). |
|
|
384
|
+
| `setProps`, `setRoot` | Merge props. A key keeps its place; a patch that changes nothing returns the same document. |
|
|
385
|
+
| `createNode(config, type, props?)`, `newId(type)` | A new block the way the Add panel makes one: `{ ...defaultProps, id }`, ids `<Type>-<uuid>`. |
|
|
386
|
+
| `zoneKey`, `parseZoneKey`, `ROOT_LIST` | A place is `{ parentId, slot, index }`; the top-level list is `{ parentId: 'root', slot: 'default-zone' }` — the two halves of Puck's zone key. |
|
|
387
|
+
|
|
388
|
+
`config` is the app's block config: only `fields` (which props are slots, including
|
|
389
|
+
slots inside `object` / `array` fields) and `defaultProps` are read, so a Puck
|
|
390
|
+
`Config` works as it is. A type the config does not know is kept whole.
|
|
391
|
+
|
|
392
|
+
Every edit copies only the path to the change, so an untouched block is the
|
|
393
|
+
same object before and after. An edit that names a missing node or slot, an
|
|
394
|
+
index off the end, a move into the node's own subtree, or an insert that
|
|
395
|
+
would put a second copy of an id into the document throws a `DataError` with a
|
|
396
|
+
`code`.
|
|
397
|
+
|
|
398
|
+
Key order is not part of the saved format — the backend stores these in
|
|
399
|
+
Postgres `jsonb` — but an edited key keeps its place and a new one goes on the
|
|
400
|
+
end, so a save diffs as the lines that changed.
|
|
401
|
+
|
|
402
|
+
## Block writing — `efficient-easel/blocks`
|
|
403
|
+
|
|
404
|
+
`efficient-easel/blocks` (added in 0.92.0) holds the types a block is written
|
|
405
|
+
with: Puck 0.20's `Config`, `ComponentConfig`, `Fields`, `Field`,
|
|
406
|
+
`CustomField`, `ComponentData`, `Data`, `Slot` and `SlotComponent`, with the
|
|
407
|
+
same generics. An app that typed its blocks with Puck's imports them from here
|
|
408
|
+
instead, and no block changes. It is types only: nothing in it exists at
|
|
409
|
+
runtime.
|
|
410
|
+
|
|
411
|
+
```ts
|
|
412
|
+
import type { Config, CustomField, Slot } from 'efficient-easel/blocks';
|
|
413
|
+
|
|
414
|
+
type HeroProps = { title: string; content: Slot };
|
|
415
|
+
const config: Config<{ Hero: HeroProps }> = {
|
|
416
|
+
components: {
|
|
417
|
+
Hero: {
|
|
418
|
+
fields: { title: { type: 'text' }, content: { type: 'slot' } },
|
|
419
|
+
render: ({ title, content: Content }) => (
|
|
420
|
+
<section><h1>{title}</h1><Content /></section>
|
|
421
|
+
),
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
## The published renderer — `efficient-easel/render`
|
|
428
|
+
|
|
429
|
+
`efficient-easel/render` (added in 0.68.0) draws a saved document the way the
|
|
430
|
+
published site does — the drop-in for Puck's `<Render>` (the blueprint's step
|
|
431
|
+
1.2). Same config, same document, **the same HTML**: the specs hold it to
|
|
432
|
+
Puck 0.20.2's markup, recorded from Puck's own `<Render>`, byte for byte.
|
|
433
|
+
|
|
434
|
+
```tsx
|
|
435
|
+
import { Render } from 'efficient-easel/render';
|
|
436
|
+
|
|
437
|
+
<Render config={config} data={page} metadata={{ site }} />;
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
It is a plain function: **no hook, no context, no `'use client'`**. It runs in a
|
|
441
|
+
server component, a client component or a script, and `Render(props)` called
|
|
442
|
+
directly returns the element tree. (Puck's reads the document from a context
|
|
443
|
+
and builds props in hooks, so it only runs as a client component.) Whether the
|
|
444
|
+
blocks themselves can run on the server is up to the blocks.
|
|
445
|
+
|
|
446
|
+
The block authoring shape is Puck's, so an app passes the config it has: a
|
|
447
|
+
block's `render` gets its props, `puck`, and each slot as a component —
|
|
448
|
+
`render: ({ content: Content }) => <Content className="grid" />` — which draws
|
|
449
|
+
`<div className style>` around its blocks. The top-level list is wrapped in one
|
|
450
|
+
plain `<div>`, and `puck` carries exactly what Puck's client `<Render>` gives at
|
|
451
|
+
each level. A type the config does not know draws nothing.
|
|
452
|
+
|
|
453
|
+
`className` (0.95.0) is an opt-in class for that top-level wrapper, for a host
|
|
454
|
+
whose stylesheet has to reach it: `<Render … className="page" />`. Left out,
|
|
455
|
+
the wrapper is bare and the markup is Puck's.
|
|
456
|
+
|
|
457
|
+
## The canvas — `efficient-easel/canvas`
|
|
458
|
+
|
|
459
|
+
`efficient-easel/canvas` (added in 0.70.0, blueprint step 2.1) is the one way
|
|
460
|
+
into the editor's canvas iframe — `#preview-frame`, the engine's own
|
|
461
|
+
same-origin canvas frame (Puck's, until the editors left it). It replaces the
|
|
462
|
+
editors' own polling loops, host-head style injection and hand-written
|
|
463
|
+
coordinate maths.
|
|
464
|
+
|
|
465
|
+
```tsx
|
|
466
|
+
import { useCanvasFrame, useCanvasEvent, useCanvasStyle, useCanvasLayout, frameRectToHost, isTypingTarget, nodeIdAt, BLOCK_SELECTOR } from 'efficient-easel/canvas';
|
|
467
|
+
|
|
468
|
+
const canvas = useCanvasFrame(editorRootRef, { readySelector: BLOCK_SELECTOR });
|
|
469
|
+
useCanvasStyle(canvas, 'hidden-blocks', hiddenBlockCss(ids)); // canvas document only
|
|
470
|
+
useCanvasEvent(canvas, 'contextmenu', (e) => open(nodeIdAt(e.target))); // follows a document swap
|
|
471
|
+
const tick = useCanvasLayout(canvas); // re-measure when this changes
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
| Export | What it answers |
|
|
475
|
+
|---|---|
|
|
476
|
+
| `useCanvasFrame(rootRef, { selector?, readySelector? })` | `{ frame, document, window }` or `null`. Found by a MutationObserver and the frame's `load` event — never a timer. `readySelector` waits for the blocks to arrive in a loaded-but-empty document. |
|
|
477
|
+
| `useCanvasEvent(canvas, type, handler, { on?, capture? })` | A listener on the canvas document (or window) that moves to a replacement document and always calls the latest handler, without re-binding on every render. |
|
|
478
|
+
| `useCanvasStyle(canvas, key, css)` | A `<style>` in the canvas document's head only — re-injected into a replacement, put back if something empties the head (Puck's AutoFrame does, once), removed on unmount, never written to the host page. |
|
|
479
|
+
| `useCanvasLayout(canvas)` | A number that changes when the canvas scrolls, resizes, is replaced, or the host scrolls or resizes (plus two settle passes). |
|
|
480
|
+
| `frameRectToHost`, `framePointToHost`, `frameScale` | A box or point measured inside the frame, on the host page — through the frame's SCALE (a phone view drawn small). |
|
|
481
|
+
| `fitCanvas({ available, width, maxScale })` | The canvas width and the scale that fits it into the pane. |
|
|
482
|
+
| `isTypingTarget(target)` | Is this key pressed somewhere a person types (text inputs, textarea, select, editable regions)? A shortcut must stay out. |
|
|
483
|
+
| `nodeIdAt(target, attribute?)` | The block a pointer event landed on. |
|
|
484
|
+
| `watchCanvasFrame(root, onChange, options)` | The watcher the hook wraps, for code outside React. |
|
|
485
|
+
| `setCanvasNonce(document, nonce)` | The CSP nonce every `<style>` created in that canvas document carries (0.95.0). `CanvasFrame`'s `nonce` prop sets it; a host that builds its own frame calls it. |
|
|
486
|
+
|
|
487
|
+
## Node operations — `efficient-easel/node-ops`
|
|
488
|
+
|
|
489
|
+
`efficient-easel/node-ops` (added in 0.71.0, blueprint step 2.2) is what can be
|
|
490
|
+
done TO a block, once for both editors: the operations, the right-click rows,
|
|
491
|
+
the clipboard, and the "up one level" answer.
|
|
492
|
+
|
|
493
|
+
```tsx
|
|
494
|
+
import { useNodeOps, nodeMenuItems, createNodeClipboard, toItemSelector } from 'efficient-easel/node-ops';
|
|
495
|
+
import { InspectorUpLevel } from 'efficient-easel/chrome';
|
|
496
|
+
|
|
497
|
+
const clipboard = createNodeClipboard(); // once, at module scope
|
|
498
|
+
|
|
499
|
+
const ops = useNodeOps({
|
|
500
|
+
data, config, clipboard, readOnly,
|
|
501
|
+
commit: (next, { select }) => store.write(next, select), // ONE write per action
|
|
502
|
+
strip: releaseBakedImage, afterRemove: 'none', stepBounds: bodyBand,
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
const items = nodeMenuItems({
|
|
506
|
+
title: 'Heading',
|
|
507
|
+
actions: { duplicate: () => ops.duplicate(id), delete: () => ops.remove(id) /* … only what is allowed */ },
|
|
508
|
+
style: styleMenuItems({ … }),
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
const up = ops.parentOf(selection); // null → render nothing
|
|
512
|
+
{up && <InspectorUpLevel name={nameOf(up)} onSelect={() => select(up)} icon={<UpGlyph />} />}
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
- **One action, one commit.** Every operation (remove, duplicate, move, step,
|
|
516
|
+
setProps, cut, paste) is planned whole on `efficient-easel/data` and handed
|
|
517
|
+
to `host.commit` ONCE, with the selection it leaves — so it is one undo step.
|
|
518
|
+
Two operations in one handler chain correctly: the second is planned on the
|
|
519
|
+
first's result.
|
|
520
|
+
- **Selection is by id** (or a slot, for the campaign designer's columns),
|
|
521
|
+
never by position. After a delete: nothing (default) or a neighbour — never
|
|
522
|
+
whatever slid into the gap. `toItemSelector` / `fromItemSelector` translate
|
|
523
|
+
to and from `itemSelector` (`{ zone, index }`, the shape the store's
|
|
524
|
+
`ui.itemSelector` reads in), and a stale selector reads as `null`, not a throw.
|
|
525
|
+
- **Rows are offered by presence.** `nodeMenuItems` fixes the order and groups
|
|
526
|
+
(`title | pick | make | style | change | remove`); the host passes the
|
|
527
|
+
actions it allows and nothing else. Labels, glyphs and shortcuts can be
|
|
528
|
+
overridden per row, so each editor keeps its own words.
|
|
529
|
+
- **Read-only** makes every write a no-op; Copy still works.
|
|
530
|
+
|
|
531
|
+
The plain functions (`planRemove`, `planDuplicate`, `planMove`, `planStep`,
|
|
532
|
+
`planPaste`, `planSetProps`, `canStep`, `parentStep`) are exported too, and
|
|
533
|
+
`efficient-easel/data` gains `copyNode` — a block with fresh ids, what a paste
|
|
534
|
+
inserts.
|
|
535
|
+
|
|
536
|
+
## Autosave — `efficient-easel/autosave`
|
|
537
|
+
|
|
538
|
+
`efficient-easel/autosave` (added in 0.72.0, blueprint step 2.5) is how both
|
|
539
|
+
editors save: a second after the operator stops, silently when it works, and
|
|
540
|
+
without losing the edit when it does not.
|
|
541
|
+
|
|
542
|
+
```tsx
|
|
543
|
+
import { useAutosave } from 'efficient-easel/autosave';
|
|
544
|
+
import { EditNotice, SaveState } from 'efficient-easel/chrome';
|
|
545
|
+
|
|
546
|
+
const autosave = useAutosave<Data>({
|
|
547
|
+
save: (doc) => api.saveDocument(id, doc, version), // read at call time
|
|
548
|
+
onFailure: (_err, attempt) => attempt === 0 && toast.error('Could not save — retrying'),
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
onChange={(doc) => canWrite && autosave.push(doc)}
|
|
552
|
+
<SaveState {...autosave.indicator} />
|
|
553
|
+
{autosave.state.failed && <button onClick={() => void autosave.retryNow()}>Retry now</button>}
|
|
554
|
+
<EditNotice access={access} conflict={conflict} noun="page"
|
|
555
|
+
onTakeOver={takeOver} onViewReadOnly={viewReadOnly} onReload={reload} />
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
- **One save at a time, the newest edit.** A save that lands never clears an
|
|
559
|
+
edit made while it was out; that edit goes next.
|
|
560
|
+
- **1 s after the last edit, at least every 3 s while typing.** So "Unsaved
|
|
561
|
+
changes" — shown only once the oldest unsaved edit is 5 s old — never appears
|
|
562
|
+
on a healthy connection.
|
|
563
|
+
- **A failed save retries at 2 / 5 / 15 / 30 s, then every 30 s, for ever.**
|
|
564
|
+
New edits do not bring the retry forward; `retryNow()` does, and restarts the
|
|
565
|
+
backoff.
|
|
566
|
+
- **`pause()` holds, `resume()` catches up.** Paused, nothing is written on a
|
|
567
|
+
timer, on tab-away or on unmount; `flush()` still writes, because someone
|
|
568
|
+
asked.
|
|
569
|
+
- **`stop()` on a refused save.** Call it where your save reads the version
|
|
570
|
+
conflict, then rethrow: nothing more is written and the edit is kept, so the
|
|
571
|
+
close-tab warning still fires. `resume()` does not undo it; a reload does.
|
|
572
|
+
- **`reset()` on a document switch.** An editor that changes documents without
|
|
573
|
+
remounting drops the old document's pending edit, failure and refusal here
|
|
574
|
+
(after saving or discarding it); a save still out for the old document can
|
|
575
|
+
no longer change the new one's state.
|
|
576
|
+
|
|
577
|
+
`createAutosave` is the same engine without React, with the clock passed in —
|
|
578
|
+
the specs run minutes of debounce and backoff on a fake one.
|
|
579
|
+
|
|
580
|
+
`EditNotice` (in `efficient-easel/chrome`) is what to TELL the operator when the
|
|
581
|
+
save cannot go: a bar, never a dialog, with a refused save outranking the lock.
|
|
582
|
+
|
|
583
|
+
## Drag-and-drop — `efficient-easel/drag`
|
|
584
|
+
|
|
585
|
+
`efficient-easel/drag` (added in 0.74.0, blueprint step 3.1) is the one drag
|
|
586
|
+
engine for both editors. The host keeps what is its own: whether a drop may
|
|
587
|
+
land (`canDrop`) and what it writes (`onDrop`, `onStep`).
|
|
588
|
+
|
|
589
|
+
```tsx
|
|
590
|
+
import { useTreeDrag, gapToMoveIndex } from 'efficient-easel/drag';
|
|
591
|
+
import { LayersTree, TreeRow, TreeSeam } from 'efficient-easel/tree';
|
|
592
|
+
|
|
593
|
+
const drag = useTreeDrag<Item>({
|
|
594
|
+
keyOf: (item) => item.id,
|
|
595
|
+
canDrop: (item, to) => rules.canDrop(item, to), // refused: no line, no drop
|
|
596
|
+
onDrop: (item, to) => ops.move(item.id, { ...to, index: gapToMoveIndex(item.place, to) }),
|
|
597
|
+
onStep: (item, dir) => ops.step(item.id, dir), // ⌥↑ / ⌥↓
|
|
598
|
+
canStep: (item, dir) => ops.canStep(item.id, dir),
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
<LayersTree rootProps={drag.rootProps()}>
|
|
602
|
+
<TreeSeam {...drag.seam({ place: { ...list, index: 0 } })} onAdd={() => openAdd(list, 0)} label="Add block here" />
|
|
603
|
+
<TreeRow {...drag.row({ item, place, into: containerPlace, pinned })} icon={…} name={…} onSelect={…} />
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
- **The whole row is picked up**, and the drag picture is the row, raised.
|
|
607
|
+
- **One hit rule:**
|
|
608
|
+
- a seam (the gap) is "between";
|
|
609
|
+
- a container's whole row is "into", at the place you name (its start or
|
|
610
|
+
end is your call);
|
|
611
|
+
- an ordinary row splits in half;
|
|
612
|
+
- a `pinned: 'first' | 'last'` row never moves, and a drop on it lands on its
|
|
613
|
+
inner side.
|
|
614
|
+
- **One drop line**, in the middle of the gap, wherever the pointer is.
|
|
615
|
+
- **⌥↑ / ⌥↓** step a row. The list **scrolls at its edges**.
|
|
616
|
+
- **A seam is the gap:** seams in every gap of a list, or in none. The list's
|
|
617
|
+
spacing does not change either way.
|
|
618
|
+
|
|
619
|
+
**The canvas and the Add list** (0.75.0, step 3.2) join the same drag through a
|
|
620
|
+
shared session, so a thing picked up on one surface can land on another:
|
|
621
|
+
|
|
622
|
+
```tsx
|
|
623
|
+
const session = useMemo(() => createDragSession<Item>(), []);
|
|
624
|
+
const drag = useTreeDrag({ …, session }); // the tree
|
|
625
|
+
|
|
626
|
+
<div style={{ position: 'relative' }}> // holds the canvas frame
|
|
627
|
+
<CanvasFrame … />
|
|
628
|
+
<CanvasDragLayer canvas={canvas} session={session} model={toModel}
|
|
629
|
+
canDrop={canDrop} onDrop={onDrop} onSeamAdd={openAddAt} onZoneAdd={openAddAt} />
|
|
630
|
+
</div>
|
|
631
|
+
|
|
632
|
+
<AddPanel groups={blocks} session={session} itemFor={(type) => ({ kind: 'new', type })}
|
|
633
|
+
onInsert={insertAtOpenPlace} at={openPlace && { label: 'Adding to Column 2', onClear }} />
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
- **The canvas measures itself:** `measureCanvas` reads the canvas's lists and
|
|
637
|
+
blocks. Your `model` says what may be picked up (leave out what cannot move)
|
|
638
|
+
and where "+ Add section" goes (`seamsBetween`).
|
|
639
|
+
- **One handle,** on the smallest box under the pointer, never unmounted
|
|
640
|
+
mid-drag.
|
|
641
|
+
- **The line** runs across a list, or down between columns (a slot laid out
|
|
642
|
+
across).
|
|
643
|
+
- **An empty slot** is a dashed "+ Add block" cell that also takes drops.
|
|
644
|
+
- Styles: `import 'efficient-easel/drag.css'`.
|
|
645
|
+
|
|
646
|
+
## The engine's words — `efficient-easel/strings`
|
|
647
|
+
|
|
648
|
+
`efficient-easel/strings` (added in 0.95.0) is every word the engine itself
|
|
649
|
+
draws or returns — visible text, `aria-label`s, tooltips, placeholders — as one
|
|
650
|
+
typed catalogue, so a host in another language can translate it. A host that
|
|
651
|
+
does nothing sees the English it always saw.
|
|
652
|
+
|
|
653
|
+
```tsx
|
|
654
|
+
import { EditorStringsProvider, useEditorStrings, type DeepPartial, type EditorStrings } from 'efficient-easel/strings';
|
|
655
|
+
|
|
656
|
+
const german: DeepPartial<EditorStrings> = {
|
|
657
|
+
save: { failed: 'Speichern fehlgeschlagen' },
|
|
658
|
+
lock: { minutesAgo: ({ count }) => `vor ${count} Min.` }, // a value or a plural: a function
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
<EditorStringsProvider value={german} locale="de">
|
|
662
|
+
<Editor />
|
|
663
|
+
</EditorStringsProvider>;
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
- **`EditorStrings`** is grouped by the module that says the word (`notice`,
|
|
667
|
+
`lock`, `save`, `chrome`, `rail`, `tree`, `panel`, `fields`, `pick`, `drag`,
|
|
668
|
+
`menu`, `styleClipboard`, `copy`, `canvas`). A plain word is a `string`;
|
|
669
|
+
anything with a value in it is a function, so the host places the value and
|
|
670
|
+
does its own plural. `DEFAULT_EDITOR_STRINGS` is the English.
|
|
671
|
+
- **What is left out falls back** (`mergeEditorStrings`), and a provider inside
|
|
672
|
+
another lays its words over the outer one's.
|
|
673
|
+
- **The provider reaches the parts that have hooks:** the edit notice, the device
|
|
674
|
+
switch, "up one level", the panel grip and the top-bar rail; the copy box, its
|
|
675
|
+
bar and its link window; `Inspector`, the number box, the dropdown and the list
|
|
676
|
+
field; the canvas frame's title.
|
|
677
|
+
- **The leaves are handed their words.** The rail, the settings-panel
|
|
678
|
+
primitives and the Layers tree are hook-free; the pickers, the Add panel and
|
|
679
|
+
the canvas layer may import only their own folder. None of them reads the
|
|
680
|
+
provider. Each takes its group as a prop, and the plain functions as an
|
|
681
|
+
argument:
|
|
682
|
+
|
|
683
|
+
```tsx
|
|
684
|
+
const strings = useEditorStrings();
|
|
685
|
+
|
|
686
|
+
<EditorRail items={items} ariaLabel={strings.rail.label} />
|
|
687
|
+
<TreeRow … words={strings.tree} />
|
|
688
|
+
<ColourPicker … words={strings.pick.colour} />
|
|
689
|
+
<ImageBox … words={strings.pick.image} />
|
|
690
|
+
<AddPanel … words={strings.drag.add} />
|
|
691
|
+
<CanvasDragLayer … words={strings.drag.canvas} />
|
|
692
|
+
|
|
693
|
+
editNotice(access, conflict, noun, strings);
|
|
694
|
+
saveIndicator(snapshot, strings.save);
|
|
695
|
+
nodeMenuItems({ …, words: strings.menu });
|
|
696
|
+
styleMenuItems({ …, words: strings.styleClipboard });
|
|
697
|
+
pasteMessage(plan, source, target, strings.styleClipboard);
|
|
698
|
+
```
|
|
699
|
+
- **`useEditorLocale()`** is the provider's `locale` (`'en'` with none), for a
|
|
700
|
+
host's own blocks and fields. The engine does not read it.
|
|
701
|
+
- **A server file may import it.** Importing the entry runs nothing — the React
|
|
702
|
+
context is made on first use. `efficient-easel/strings/catalogue` is the
|
|
703
|
+
catalogue, its types and `mergeEditorStrings` with no React in its graph at
|
|
704
|
+
all: what a host's own test imports to check its languages against the
|
|
705
|
+
engine's keys.
|
|
706
|
+
|
|
707
|
+
What a host supplies was never the engine's and is not in the catalogue: a
|
|
708
|
+
block's name, a field's label, a tile's caption, the document's `noun`.
|
|
709
|
+
|
|
710
|
+
## Develop
|
|
711
|
+
|
|
712
|
+
Node is native on the dev Mac (v26.8.1) — run these in your own worktree; a
|
|
713
|
+
shared checkout's node_modules are linux-only. Docker stays as the fallback:
|
|
714
|
+
|
|
715
|
+
```bash
|
|
716
|
+
docker run --rm -v "$PWD":/w -w /w node:26-slim \
|
|
717
|
+
sh -lc "npm install && npm run typecheck && npm test && npm run build"
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
- `npm run typecheck` — `tsc` over `src` and the specs.
|
|
721
|
+
- `npm test` — the leaf-safety, gold-layer and rail-shape gates. The gold-layer
|
|
722
|
+
spec is the one that needs a DOM (focus, keys, blur); it stands up a single
|
|
723
|
+
jsdom window in `tests/_dom-env.ts` and runs in its own process.
|
|
724
|
+
- `npm run build` — `tsup` → `dist/` (root + the `rail`, `shell` and `gold`
|
|
725
|
+
subpaths) + the copied CSS.
|