@bygga.dev/editor 1.8.3 → 1.9.3
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/README.md +169 -31
- package/{TextEditor-BY5_EMzN.js → TextEditor-Bl911KUt.js} +722 -618
- package/bygga-editor.js +3191 -2487
- package/element.d.ts +104 -3
- package/package.json +1 -1
- package/{providerKeys-D5U1FqVd.js → providerKeys-ZibeU1i6.js} +2 -2
- package/{urls-BsMrzVdc.js → urls-Chd8ABaa.js} +3539 -3392
package/README.md
CHANGED
|
@@ -105,6 +105,15 @@ assistive technology. Settable as the attribute or the `.locale` property. An
|
|
|
105
105
|
unsupported value warns and falls back to the default. The supported set is exported
|
|
106
106
|
as `SUPPORTED_LOCALES` / `DEFAULT_LOCALE`.
|
|
107
107
|
|
|
108
|
+
### `permission` attribute
|
|
109
|
+
What this user may do with [locked blocks](#locked-blocks): `admin` (may lock, unlock,
|
|
110
|
+
and edit a locked block like any other) or `creator` (a content creator: may edit the
|
|
111
|
+
text inside a locked block and nothing else about it). Reactive; settable as the
|
|
112
|
+
attribute or the `.permission` property. **Absent — or anything unrecognised — is
|
|
113
|
+
`creator`**, the permission that can do least, so an embed that never mentions it offers
|
|
114
|
+
no lock controls at all, and a typo'd `admn` warns rather than handing every user the
|
|
115
|
+
keys.
|
|
116
|
+
|
|
108
117
|
### `config` property *(required)*
|
|
109
118
|
Host callbacks. Assign it **before** connecting the element (the editor throws at
|
|
110
119
|
mount if `uploadImage` is missing). It's a plain object, re-read on assignment, so
|
|
@@ -176,10 +185,11 @@ backend must later resolve every id it sees; see
|
|
|
176
185
|
[Sending and publishing](#sending-and-publishing).
|
|
177
186
|
|
|
178
187
|
### `brand` property
|
|
179
|
-
The colours and fonts your embed permits
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
188
|
+
The colours and fonts your embed permits, the look new blocks start with, values for
|
|
189
|
+
your block plugins, and which block tiles to offer — a **menu, never a rewrite of a
|
|
190
|
+
document**. It bounds and seeds what is added next; it never rejects or edits a
|
|
191
|
+
document, and a value already in a document that the Brand doesn't contain keeps
|
|
192
|
+
rendering. All halves are optional; an absent one is unrestricted.
|
|
183
193
|
|
|
184
194
|
```js
|
|
185
195
|
editor.brand = {
|
|
@@ -224,7 +234,56 @@ never appears. Set `type: 'file'` on those:
|
|
|
224
234
|
The email-safe faces are `arial`, `tahoma`, `trebuchet`, `verdana`, `georgia`,
|
|
225
235
|
`times`, `courier`. Invalid entries (an unparseable colour, a non-http(s) font href)
|
|
226
236
|
are dropped with a `[bygga-editor]` console warning rather than throwing — one typo
|
|
227
|
-
must not take down your editor.
|
|
237
|
+
must not take down your editor.
|
|
238
|
+
|
|
239
|
+
#### Block styles, plugin values and hidden tiles
|
|
240
|
+
|
|
241
|
+
```js
|
|
242
|
+
editor.brand = {
|
|
243
|
+
...brand,
|
|
244
|
+
// The look a newly placed Header, Text or Button block starts with. Every field is
|
|
245
|
+
// optional. `font` is an email-safe face or the `name` of one of your customFonts;
|
|
246
|
+
// `fontSize` is one of 10, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48; colours are hex.
|
|
247
|
+
styles: {
|
|
248
|
+
header: { font: 'georgia', fontSize: 32, color: '#2f2a72', bold: true,
|
|
249
|
+
align: 'center', level: 2, padding: 8, background: '#ffffff',
|
|
250
|
+
margin: { top: 0, right: 0, bottom: 16, left: 0 } },
|
|
251
|
+
text: { font: 'arial', fontSize: 16, color: '#333333', align: 'left',
|
|
252
|
+
paragraphSpacing: 8 },
|
|
253
|
+
button: { background: '#2f2a72', textColor: '#ffffff', font: 'arial',
|
|
254
|
+
fontSize: 16, bold: true, align: 'center',
|
|
255
|
+
padding: { top: 12, right: 24, bottom: 12, left: 24 },
|
|
256
|
+
// One number rounds every corner; name the corners for a shape.
|
|
257
|
+
borderRadius: { topLeft: 0, topRight: 12, bottomRight: 0, bottomLeft: 0 },
|
|
258
|
+
border: { width: 2, color: '#064c70', style: 'solid' } },
|
|
259
|
+
},
|
|
260
|
+
// Anything your block plugins read to style their own blocks (see Block plugins).
|
|
261
|
+
custom: { cardRadius: 8, accent: '#a28c67' },
|
|
262
|
+
// Block tiles not to offer: built-in types and/or plugin names.
|
|
263
|
+
hiddenBlocks: ['html', 'acme.legacy-banner'],
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Each style can also set the block's box — `margin` and `padding` (a number or
|
|
268
|
+
`{ top, right, bottom, left }`), `borderRadius` (a number, or
|
|
269
|
+
`{ topLeft, topRight, bottomRight, bottomLeft }`), and `border`
|
|
270
|
+
(`{ width, color?, style? }`, `style` one of `solid`, `dashed`, `dotted`) — plus
|
|
271
|
+
`underline` for header and text, and `paragraphSpacing` (`0`, `4`, `8`, `12`, `16`,
|
|
272
|
+
`20`, `24` or `32` px) for text.
|
|
273
|
+
|
|
274
|
+
A style is a **starting value, written into the block when it is placed** — exactly as
|
|
275
|
+
if the author had chosen each setting by hand. Revising your Brand changes the blocks
|
|
276
|
+
placed next, never the ones already in your documents, and the compiled email knows
|
|
277
|
+
nothing about Brands. A header's title and a button carry their whole style from the
|
|
278
|
+
moment they appear; a text block is placed with its alignment, background and padding,
|
|
279
|
+
and the face, size, colour and weight are applied to what the author types into an
|
|
280
|
+
empty paragraph (a mark needs text to sit on). The author can change any of it
|
|
281
|
+
afterwards, as with any block.
|
|
282
|
+
|
|
283
|
+
A hidden tile only narrows the menu: a hidden block type already in a document still
|
|
284
|
+
renders and edits. An entry naming neither a built-in type nor a registered plugin is
|
|
285
|
+
warned about, as is any style field the editor can't use — that field is dropped and
|
|
286
|
+
the rest of the style kept. The Transparent option stays available beside a
|
|
228
287
|
restricted palette, since "no fill" is a structural choice rather than a brand colour.
|
|
229
288
|
|
|
230
289
|
A custom face renders in the editor and wherever the compiled HTML is shown in a real
|
|
@@ -235,7 +294,7 @@ carries what it uses, so it keeps rendering even if you later change the registr
|
|
|
235
294
|
### `savedBlocks` property
|
|
236
295
|
The Saved block library this embed offers — see [Saved blocks](#saved-blocks). An
|
|
237
296
|
array property (arrays can't arrive through an attribute). This is the **only** thing
|
|
238
|
-
the Saved
|
|
297
|
+
the Saved blocks section renders; the editor keeps no library of its own.
|
|
239
298
|
|
|
240
299
|
### Events
|
|
241
300
|
Events do not bubble — listen on the element itself.
|
|
@@ -246,6 +305,34 @@ Events do not bubble — listen on the element itself.
|
|
|
246
305
|
editor dirty.
|
|
247
306
|
- **`dirtychange`** — fired when the unsaved-changes state flips; `event.detail[0]` is
|
|
248
307
|
a boolean. Drives, e.g., a Save button's enabled state.
|
|
308
|
+
- **`autosave`** — fired when the author pauses after editing (about five seconds of
|
|
309
|
+
quiet), or after about thirty seconds of editing without a pause; `event.detail[0]`
|
|
310
|
+
is `{ document, digest }`. Never per keystroke (that is `change`), never for a
|
|
311
|
+
document with nothing unsaved, and never twice for the same content. The editor saves
|
|
312
|
+
nothing itself: persist the payload and call `markSaved()`, or call `save()` to store
|
|
313
|
+
it with its compiled HTML — though each `save()` spends a compile from your licence's
|
|
314
|
+
quota. Edits made within the last pause have not autosaved yet, so guard page exits
|
|
315
|
+
with `isDirty()` (a `beforeunload` handler) rather than relying on the event alone.
|
|
316
|
+
|
|
317
|
+
`digest` fingerprints the document's *content* — SHA-256, as lowercase hex, over its
|
|
318
|
+
[RFC 8785](https://www.rfc-editor.org/rfc/rfc8785) canonical JSON — so you can skip a
|
|
319
|
+
write when you already hold that content (another tab saved it, or your last save
|
|
320
|
+
did). Equal content always has an equal digest, whatever order your storage keeps the
|
|
321
|
+
keys in. Keep the digest of what you last stored, or compute it for any document with
|
|
322
|
+
the exported `documentDigest(document)`; a backend can compute the same value with any
|
|
323
|
+
JCS library.
|
|
324
|
+
|
|
325
|
+
```js
|
|
326
|
+
import { documentDigest } from '@bygga.dev/editor'
|
|
327
|
+
|
|
328
|
+
let storedDigest = await documentDigest(storedDocument)
|
|
329
|
+
editor.addEventListener('autosave', async (event) => {
|
|
330
|
+
const [{ document, digest }] = event.detail
|
|
331
|
+
if (digest === storedDigest) return // already saved — nothing changed
|
|
332
|
+
await api.saveDraft(document)
|
|
333
|
+
storedDigest = digest
|
|
334
|
+
})
|
|
335
|
+
```
|
|
249
336
|
- **`licenseerror`** — fired once when the editor refuses to open; `event.detail[0]` is
|
|
250
337
|
`{ reason }`, one of `key`, `domain`, `origin`, `malformed`, `unreachable`,
|
|
251
338
|
`invalid`, `insecure-context`. The panel and a fuller `console.error` diagnostic are
|
|
@@ -321,11 +408,15 @@ editor.savedBlocks = rows.map(parseSavedBlock)
|
|
|
321
408
|
|
|
322
409
|
`id` and `name` are ordinary strings you can key rows by and show in your own admin;
|
|
323
410
|
`content` is an opaque blob to write verbatim and hand back untouched. Resolving
|
|
324
|
-
`saveBlock` does **not** put the block in the
|
|
411
|
+
`saveBlock` does **not** put the block in the library — `savedBlocks` is the only
|
|
325
412
|
truth the editor renders, so reassign it. A malformed or duplicate-id entry is dropped
|
|
326
413
|
with a warning rather than taking down the editor. Entries are offered only in
|
|
327
414
|
documents of the kind they were saved from.
|
|
328
415
|
|
|
416
|
+
The library appears under the block tiles, in view whenever they are. A Saved block
|
|
417
|
+
keeps any [lock](#locked-blocks) it was saved with, so an admin can curate locked
|
|
418
|
+
sections — a masthead, a legal footer — for content creators to drop in.
|
|
419
|
+
|
|
329
420
|
## Block plugins
|
|
330
421
|
|
|
331
422
|
Custom block types — a video embed, a product card — registered per embed through
|
|
@@ -345,6 +436,45 @@ deployment a static-file update that rebuilds nothing. The contract is `BlockPlu
|
|
|
345
436
|
as permanent and namespace it (`acme.product-card`). `email()` must be deterministic
|
|
346
437
|
and `data` must be plain JSON — the editor re-bakes on load and on every change.
|
|
347
438
|
|
|
439
|
+
**The Brand reaches a plugin where it chooses values.** `create(context)` receives
|
|
440
|
+
`context.brand` — your Brand after validation: `colors`, `fonts`, `customFonts`,
|
|
441
|
+
`styles`, `custom` (whatever you put in `brand.custom`), and `css`, the block styles
|
|
442
|
+
as ready-to-use CSS — so a new block can start on your look; the settings contexts
|
|
443
|
+
carry the same `brand`, to offer your palette or a "reset to brand". `css.header`
|
|
444
|
+
is how a custom header of your own takes the look the Brand gives the built-in one:
|
|
445
|
+
`css.header.cssText` is a string like `font-family:Georgia, 'Times New Roman', serif;font-size:32px`
|
|
446
|
+
to write into your heading's `style` (escape it like any attribute value), with each
|
|
447
|
+
property also available on its own. A custom font written there is carried into the
|
|
448
|
+
compiled email automatically, as long as your baked HTML names it. Copy what you use into the block's `data`: `email()` and `preview()`
|
|
449
|
+
deliberately do not receive the Brand, because a block's HTML must follow from its
|
|
450
|
+
stored data alone — otherwise the same document would bake differently in two embeds,
|
|
451
|
+
and revising your Brand would silently rewrite every document it touched.
|
|
452
|
+
|
|
453
|
+
```js
|
|
454
|
+
create(context) {
|
|
455
|
+
return { title: '', accent: context.brand.custom.accent ?? context.brand.colors[0] }
|
|
456
|
+
},
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
## Locked blocks
|
|
460
|
+
|
|
461
|
+
An admin can **lock** a columns block from its settings. For a content creator (the
|
|
462
|
+
`creator` [permission](#permission-attribute)) a locked block is fixed: it can't be
|
|
463
|
+
moved, duplicated, removed, restyled or relaid out, nothing can be added to or taken
|
|
464
|
+
out of it, and its images, buttons and other blocks can't be changed — but **the text
|
|
465
|
+
inside it stays editable** — including a button's label, and whether it is bold,
|
|
466
|
+
through the button's own cut-down settings. A block that *holds* a locked block can't be moved,
|
|
467
|
+
duplicated, removed or hidden (per device or per recipient) by a creator either, since
|
|
468
|
+
that would take the lock with it, and a layout change that would drop a column holding
|
|
469
|
+
one isn't offered. An admin edits
|
|
470
|
+
locked blocks like any other and sees the same "Locked" marker.
|
|
471
|
+
|
|
472
|
+
The lock is stored on the block (`locked: true`) and travels with the document and
|
|
473
|
+
with Saved blocks. It is **guidance for the people editing, not a security boundary**:
|
|
474
|
+
the editor enforces it in its own UI, but you receive the whole document on every
|
|
475
|
+
save, so anything that must hold against a determined user has to be checked where
|
|
476
|
+
you store documents.
|
|
477
|
+
|
|
348
478
|
## AI assistance
|
|
349
479
|
|
|
350
480
|
The editor can offer authors AI help on the text they're writing — but it ships **the UI
|
|
@@ -363,7 +493,7 @@ editor.config = {
|
|
|
363
493
|
// one off means removing it here — there is no separate flag to keep in step.
|
|
364
494
|
actions: ['proofread', 'rewrite', 'shorten'],
|
|
365
495
|
|
|
366
|
-
// Called
|
|
496
|
+
// Called once per paragraph of the author's selection. Answer with its replacement text.
|
|
367
497
|
async run({ action, text, locale }) {
|
|
368
498
|
const res = await fetch('/api/ai', { // YOUR endpoint, your model, your key
|
|
369
499
|
method: 'POST',
|
|
@@ -383,8 +513,11 @@ editor doesn't know is dropped with a console warning, and an empty `actions` ar
|
|
|
383
513
|
means no controls — same as no `ai` at all.
|
|
384
514
|
|
|
385
515
|
**What the author sees:** an AI button in the text toolbar listing exactly your declared
|
|
386
|
-
actions. It runs on their selection, or on the whole block when nothing is selected
|
|
387
|
-
|
|
516
|
+
actions. It runs on their selection, or on the whole block when nothing is selected, one
|
|
517
|
+
paragraph at a time: each paragraph or heading is its own `run` call, all sent at once, so
|
|
518
|
+
a block keeps its paragraphs, heading level, line breaks and styling. The answers land
|
|
519
|
+
together as one undo step, around anything the author typed while waiting. The button is
|
|
520
|
+
disabled while requests are in flight.
|
|
388
521
|
|
|
389
522
|
### Implementing the endpoint
|
|
390
523
|
|
|
@@ -432,29 +565,28 @@ export async function handleAi(req, res) {
|
|
|
432
565
|
if (text.length > 5000) return res.status(413).json({ error: 'selection too long' })
|
|
433
566
|
|
|
434
567
|
const message = await client.messages.parse({
|
|
435
|
-
model: 'claude-opus-5',
|
|
568
|
+
model: 'claude-opus-5-5',
|
|
436
569
|
// These are short transforms on an email selection; a low cap bounds both latency
|
|
437
570
|
// and a runaway bill. Raise it if you allow long selections.
|
|
438
571
|
max_tokens: 4000,
|
|
439
572
|
system: [
|
|
440
573
|
`You edit marketing email copy. ${instruction}`,
|
|
441
|
-
`Reply in the same language as the input (the author
|
|
574
|
+
`Reply in the same language as the input (the author's interface language is "${locale}").`,
|
|
442
575
|
'Return ONLY the edited text — no commentary, no explanation, no quotes around it.',
|
|
443
576
|
'Preserve any {{merge_tag}} placeholders EXACTLY as they appear, including spelling and position.',
|
|
444
|
-
'Return plain text: no Markdown, no HTML.',
|
|
577
|
+
'Return plain text: no Markdown, no HTML. Keep line breaks where they are; add none.',
|
|
445
578
|
].join(' '),
|
|
446
579
|
messages: [{ role: 'user', content: text }],
|
|
447
580
|
output_config: { format: zodOutputFormat(Result) },
|
|
448
581
|
})
|
|
449
582
|
|
|
450
|
-
res.json({ text: message.parsed_output?.text ??
|
|
583
|
+
res.json({ text: message.parsed_output?.text ?? '' })
|
|
451
584
|
}
|
|
452
585
|
```
|
|
453
586
|
|
|
454
587
|
**Clean what you return anyway.** Even with structured output, trim whitespace and strip
|
|
455
|
-
matched surrounding quotes — models add them.
|
|
456
|
-
|
|
457
|
-
returning the input makes that explicit.
|
|
588
|
+
matched surrounding quotes — models add them. An empty answer, or the text unchanged, is
|
|
589
|
+
nothing to do: the editor leaves that paragraph exactly as it was, formatting included.
|
|
458
590
|
|
|
459
591
|
### Security, cost and abuse
|
|
460
592
|
|
|
@@ -463,18 +595,19 @@ returning the input makes that explicit.
|
|
|
463
595
|
- **Authenticate and authorise the endpoint** with the same session your app already
|
|
464
596
|
uses, and check the user may edit that document. Without it you've published an open
|
|
465
597
|
proxy to your model account.
|
|
466
|
-
- **Rate limit per user**, not per IP — this is a button an author can hold down
|
|
467
|
-
per
|
|
468
|
-
-
|
|
469
|
-
|
|
470
|
-
|
|
598
|
+
- **Rate limit per user**, not per IP — this is a button an author can hold down, and one
|
|
599
|
+
click sends one request per paragraph, all at once. Allow a burst (say ten) inside a
|
|
600
|
+
short per-user quota, plus a max input length.
|
|
601
|
+
- **Cap the input**: reject a paragraph beyond a size you're willing to pay for — one long
|
|
602
|
+
paragraph is still one request.
|
|
471
603
|
- **Log for support, not surveillance**: the action, size and outcome are enough to debug
|
|
472
604
|
"the AI button doesn't work"; the copy itself is your customer's content.
|
|
473
605
|
|
|
474
606
|
### Failure and timeouts
|
|
475
607
|
|
|
476
608
|
**Rejecting is a first-class outcome, not a bug.** The editor shows the author an error,
|
|
477
|
-
logs your reason to the console, and leaves their text exactly as it was
|
|
609
|
+
logs your reason to the console, and leaves their text exactly as it was — every paragraph
|
|
610
|
+
of it, since an action's answers land together or not at all. Throw with a
|
|
478
611
|
message worth reading — a quota refusal and a network blip shouldn't look the same to
|
|
479
612
|
whoever supports the author:
|
|
480
613
|
|
|
@@ -512,9 +645,9 @@ ai: {
|
|
|
512
645
|
|
|
513
646
|
| | |
|
|
514
647
|
| --- | --- |
|
|
515
|
-
| `run` receives | `{ action, text, locale }
|
|
516
|
-
| `run` returns | `Promise<string>` — the replacement text |
|
|
517
|
-
| Applied as | plain text
|
|
648
|
+
| `run` receives | `{ action, text, locale }`, once per paragraph in range, all at once — `text` is that paragraph's plain text with merge tags as `{{tagId}}` and line breaks as `\n`; `locale` is the editor's interface language |
|
|
649
|
+
| `run` returns | `Promise<string>` — the paragraph's replacement text |
|
|
650
|
+
| Applied as | plain text in the paragraph's main formatting, with merge-tag chips and line breaks rebuilt; every paragraph in one undo step. **Formatting within a paragraph is flattened**; merge tags survive; an empty or unchanged answer changes nothing |
|
|
518
651
|
| On rejection | author sees an error, console gets your reason, text is unchanged |
|
|
519
652
|
| TypeScript | `AiAssistant`, `AiAction`, `AiRequest` are exported |
|
|
520
653
|
|
|
@@ -684,17 +817,22 @@ and forced-colors modes are handled behind the tokens and keep working under a r
|
|
|
684
817
|
The package augments `HTMLElementTagNameMap`, so
|
|
685
818
|
`document.querySelector('bygga-editor')` is typed as `ByggaEditorElement` — its
|
|
686
819
|
properties, methods, and a typed `addEventListener` for `change` / `dirtychange` /
|
|
687
|
-
`licenseerror` included.
|
|
820
|
+
`autosave` / `licenseerror` included.
|
|
688
821
|
|
|
689
822
|
- **Types:** `Document`, `EditorConfig`, `ByggaEditorElement`, `ByggaEditorEventMap`,
|
|
690
|
-
`
|
|
691
|
-
`
|
|
823
|
+
`AutosaveDetail`,
|
|
824
|
+
`Brand`, `BrandFont`, `BrandCustomFont`, `BrandStyles`, `BrandTextStyle`,
|
|
825
|
+
`BrandHeaderStyle`, `BrandButtonStyle`, `BrandBox`, `BrandCorners`, `BrandBorder`,
|
|
826
|
+
`BrandBlockType`, `Permission`,
|
|
827
|
+
`MergeTags`, `MergeTagEntry`, `MergeTagLabels`, `MergeTagOption`, `SavedBlock`,
|
|
828
|
+
`SavedBlockContent`, `BlockPlugin`, `BlockPluginCreateContext`, `BlockPluginBrand`,
|
|
829
|
+
`BlockPluginStyleCss`,
|
|
692
830
|
`BlockPluginSettingsContext`, `BlockPluginGlobalsContext`,
|
|
693
831
|
`BlockPluginGlobalSettings`, `AiAssistant`, `AiAction`, `AiRequest`, `Locale`,
|
|
694
832
|
`LicenseError`, `LicenseErrorReason`.
|
|
695
833
|
- **Values:** `createEmptyDocument`, `parseDocument`, `parseSavedBlock`,
|
|
696
|
-
`SUPPORTED_LOCALES`, `DEFAULT_LOCALE`, `EDITOR_TAG`, and
|
|
697
|
-
constructor, already registered on import).
|
|
834
|
+
`documentDigest`, `SUPPORTED_LOCALES`, `DEFAULT_LOCALE`, `EDITOR_TAG`, and
|
|
835
|
+
`ByggaEditor` (the element constructor, already registered on import).
|
|
698
836
|
|
|
699
837
|
## Framework integration
|
|
700
838
|
|