@matrajs/mcp 1.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.
@@ -0,0 +1,504 @@
1
+ # Recipes
2
+
3
+ Autosave, read-only, Markdown, a table of contents, search highlighting, and the things people ask for on day one.
4
+
5
+ The things people need on the first day, in the fewest lines that actually work.
6
+
7
+ ## Autosave, without saving on every keystroke
8
+
9
+ ```
10
+ import { autosave } from '@matrajs/core'
11
+
12
+ autosave({
13
+ delay: 800, // after the last keystroke
14
+ save: (doc) => localStorage.setItem('draft', JSON.stringify(doc)),
15
+ restore: () => JSON.parse(localStorage.getItem('draft') ?? 'null'),
16
+ })
17
+
18
+ editor.extensionState('autosave') // { dirty, saving, savedAt, error } for a status line
19
+ editor.commands.save() // now, from a button
20
+ ```
21
+
22
+ Store the JSON, not the HTML. It survives schema changes better and diffs cleanly. A dirty document is also saved when the tab is hidden and when the editor is destroyed, which is where a timer-only autosave loses the last paragraph.
23
+
24
+ ## Read-only
25
+
26
+ ```
27
+ editor.setEditable(false)
28
+ ```
29
+
30
+ The document still renders and decorations still draw, so this is the right way to show a published version rather than rendering the HTML somewhere else and hoping it matches.
31
+
32
+ ## Markdown, on a server
33
+
34
+ ```
35
+ import { fromMarkdown, toMarkdown } from '@matrajs/core'
36
+
37
+ const doc = fromMarkdown(await readFile('post.md', 'utf8'))
38
+ await writeFile('post.md', toMarkdown(doc))
39
+ ```
40
+
41
+ No DOM involved, so this runs in Node, in a worker, and at the edge.
42
+
43
+ ## A table of contents that is never stale
44
+
45
+ ```
46
+ import { tableOfContents } from '@matrajs/core'
47
+
48
+ const entries = tableOfContents(editor.getJSON())
49
+ // [{ level, text, pos, id }]
50
+ entries.forEach((entry) => {
51
+ link.onclick = () => editor.commands.select(entry.pos + 1)
52
+ })
53
+ ```
54
+
55
+ Derived on demand rather than cached, so it cannot disagree with the document · which is how a cached outline ends up pointing at a heading that was renamed.
56
+
57
+ ## A slash menu, like the one on this site
58
+
59
+ The extension finds the trigger and marks the range. What it deliberately does not do is draw a list, so this is the whole of the missing half — and the same code runs on every editable paragraph of this site.
60
+
61
+ ```
62
+ import { activeSuggestion, suggestion } from '@matrajs/core'
63
+
64
+ const editor = createEditor({
65
+ extensions: [...starterKit, suggestion({ char: '/', name: 'slash' })],
66
+ })
67
+
68
+ const ITEMS = [
69
+ { name: 'Heading 1', command: 'toggleHeading', arg: 1 },
70
+ { name: 'Bulleted list', command: 'toggleBulletList' },
71
+ { name: 'Quote', command: 'toggleBlockquote' },
72
+ ]
73
+
74
+ let matches = [], index = 0
75
+
76
+ const sync = () => {
77
+ const active = activeSuggestion(editor, 'slash')
78
+ if (!active) return hide()
79
+ // Offer only what this editor can do: a row for a command that is not
80
+ // installed is a row that does nothing when it is pressed.
81
+ matches = ITEMS
82
+ .filter((item) => typeof editor.commands[item.command] === 'function')
83
+ .filter((item) => item.name.toLowerCase().startsWith(active.query.toLowerCase()))
84
+ index = 0
85
+ show(matches, document.querySelector('.matra-suggestion').getBoundingClientRect())
86
+ }
87
+
88
+ editor.on('change', sync)
89
+ editor.on('selectionChange', sync)
90
+
91
+ // Captured, so the menu gets Enter before the editor splits the block with it.
92
+ document.addEventListener('keydown', (event) => {
93
+ if (!open) return
94
+ if (event.key === 'ArrowDown') { event.preventDefault(); index = (index + 1) % matches.length }
95
+ if (event.key === 'ArrowUp') { event.preventDefault(); index = (index - 1 + matches.length) % matches.length }
96
+ if (event.key !== 'Enter') return
97
+ event.preventDefault()
98
+ event.stopPropagation()
99
+ const item = matches[index]
100
+ const active = activeSuggestion(editor, 'slash')
101
+ hide()
102
+ // Take the "/query" out first: the other order turns the slash into a heading.
103
+ editor.commands.remove(active.range)
104
+ editor.commands[item.command](item.arg)
105
+ }, true)
106
+ ```
107
+
108
+ `Escape` is already bound by the extension and stays closed afterwards, so the next arrow key does not reopen what was just dismissed.
109
+
110
+ ## Search highlighting
111
+
112
+ ```
113
+ const search = (query) => ({
114
+ kind: 'extension',
115
+ name: 'search',
116
+ decorations: (ctx) => matches(ctx.doc, query).map((range) => ({
117
+ type: 'inline', from: range.from, to: range.to,
118
+ attrs: { class: 'hit' },
119
+ })),
120
+ })
121
+ ```
122
+
123
+ Decorations are drawn over the document, so a highlight never ends up in a copy or an export.
124
+
125
+ ## Stable ids for anchoring comments
126
+
127
+ ```
128
+ import { assignIds } from '@matrajs/core'
129
+
130
+ const doc = assignIds(editor.getJSON()) // every block gains a stable id
131
+ editor.setContent(doc)
132
+ ```
133
+
134
+ Call it when you load and when you save, not on every transaction · an editor that rewrites attributes behind your back makes every document dirty and every undo stack strange.
135
+
136
+ ## A character limit
137
+
138
+ ```
139
+ editor.on('change', () => {
140
+ const over = editor.getText().length > 280
141
+ button.disabled = over
142
+ })
143
+ ```
144
+
145
+ Refuse to submit rather than refusing the keystroke. Blocking input mid-word is how you make someone lose a sentence they were pasting.
146
+
147
+ ## Loading HTML you did not write
148
+
149
+ ```
150
+ editor.setContent(untrustedHtml)
151
+ ```
152
+
153
+ Safe by construction. Executable attributes are never set, URL attributes are scheme-checked, undeclared attributes are dropped, and every route into the DOM · JSON, paste, command, decoration · passes the same gate. See [SECURITY.md](https://github.com/amrelaco/matra/blob/main/SECURITY.md).
154
+
155
+ > Note: `getHTML()` output is safe to render in the editor. If you store it and serve it somewhere else, sanitise at that boundary too · defence in depth is the point.
156
+
157
+ ## Find and replace, step by step
158
+
159
+ ```
160
+ import { createEditor, search, searchCSS, starterKit } from '@matrajs/core'
161
+
162
+ // 1. put it in the array
163
+ const editor = createEditor({ extensions: [...starterKit, search()] as const })
164
+
165
+ // 2. paste its CSS once · the editor ships no appearance
166
+ document.head.appendChild(Object.assign(document.createElement('style'), { textContent: searchCSS }))
167
+
168
+ // 3. wire a panel
169
+ input.oninput = () => editor.commands.setSearch({ query: input.value, wholeWord: false })
170
+ next.onclick = () => editor.commands.nextMatch() // selects it, so the view scrolls
171
+ replaceOne.onclick = () => editor.commands.replaceMatch(replacement.value)
172
+ replaceAll.onclick = () => editor.commands.replaceAllMatches(replacement.value) // one undo step
173
+
174
+ // 4. show the count
175
+ editor.on('change', () => {
176
+ const { matches, current } = editor.extensionState('search')
177
+ counter.textContent = matches.length ? `${current + 1} of ${matches.length}` : 'no matches'
178
+ })
179
+ ```
180
+
181
+ Typing while the search is open rescans the paragraph being typed in and nothing else · the other paragraphs' matches are read back from a cache keyed on the block, and the renderer leaves their elements alone.
182
+
183
+ ## Text colour, font and size
184
+
185
+ ```
186
+ import { textStyle } from '@matrajs/core'
187
+
188
+ const editor = createEditor({ extensions: [...starterKit, textStyle] as const })
189
+
190
+ editor.commands.setColor('#c00') // keeps whatever font is already set
191
+ editor.commands.setFontFamily('Georgia, serif')
192
+ editor.commands.setFontSize('1.25em')
193
+ editor.commands.unsetColor() // the font stays
194
+ editor.commands.unsetTextStyle() // everything off
195
+ ```
196
+
197
+ One mark with four attributes rather than four marks, so a coloured, resized word is one `span`. Every value is checked against the shape of a colour, a font list or a length before it reaches a `style` attribute · whatever route it arrived by.
198
+
199
+ ## Tables people can actually edit
200
+
201
+ ```
202
+ import { tableKit } from '@matrajs/core'
203
+
204
+ const editor = createEditor({ extensions: [...starterKit, ...tableKit] as const })
205
+
206
+ editor.commands.insertTable(3, 3) // a header row and two body rows, caret in the first cell
207
+ // Tab moves to the next cell, Shift-Tab back, Tab in the last cell adds a row
208
+ editor.commands.addRowAfter()
209
+ editor.commands.addColumnBefore()
210
+ editor.commands.deleteColumn()
211
+ editor.commands.toggleHeaderRow()
212
+
213
+ // a toolbar knows when to show table buttons
214
+ const inTable = editor.isActive('table')
215
+ ```
216
+
217
+ A cell that spans the boundary a new row or column crosses is widened rather than split, and a cell that spans into a deleted row moves down one shorter · the way a spreadsheet does it.
218
+
219
+ ## Images dropped or pasted, uploaded, and put where they landed
220
+
221
+ ```
222
+ import { fileHandler, image } from '@matrajs/core'
223
+
224
+ const editor = createEditor({
225
+ extensions: [
226
+ ...starterKit,
227
+ image,
228
+ fileHandler({
229
+ accept: ['image/'],
230
+ async onDrop({ editor, files, pos, marker }) {
231
+ for (const file of files) {
232
+ const src = await upload(file) // your endpoint
233
+ // the user kept typing while that ran · the marker says where "here" is now
234
+ editor.commands.insert({ type: 'image', attrs: { src } }, pos && marker.map(pos))
235
+ }
236
+ },
237
+ onPaste: ({ editor, files }) => /* a screenshot from the clipboard arrives here */ void 0,
238
+ }),
239
+ ] as const,
240
+ })
241
+ ```
242
+
243
+ ## A YouTube embed
244
+
245
+ ```
246
+ import { youtube, youtubeCSS } from '@matrajs/core'
247
+
248
+ editor.commands.insertYoutube({ src: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', start: 42 })
249
+ ```
250
+
251
+ Only the id is stored. The frame's address is built from it, on the privacy domain, so a document can never carry a frame pointing anywhere else.
252
+
253
+ ## Callouts and toggles
254
+
255
+ ```
256
+ import { callout, calloutCSS, detailsKit, detailsCSS } from '@matrajs/core'
257
+
258
+ const editor = createEditor({ extensions: [...starterKit, callout, ...detailsKit] as const })
259
+
260
+ editor.commands.toggleCallout('warning') // wraps the block · again to lift it back out
261
+ editor.commands.setCalloutEmoji('⚠️')
262
+
263
+ editor.commands.insertDetails() // the block becomes a toggle with an empty summary
264
+ // Enter in the summary moves into the content; the triangle writes `open` to the document
265
+ editor.commands.toggleDetails()
266
+ editor.commands.unsetDetails()
267
+ ```
268
+
269
+ ## Code with colours
270
+
271
+ ```
272
+ import { codeHighlight, codeHighlightCSS } from '@matrajs/core'
273
+
274
+ // the built-in tokeniser: comments, strings, numbers, shared keywords
275
+ createEditor({ extensions: [...starterKit, codeHighlight()] as const })
276
+
277
+ // or the real thing · Shiki, Prism, lowlight · as a function of the code and its language
278
+ codeHighlight({
279
+ highlight: (code, language) =>
280
+ tokenize(code, language).map((token) => ({ from: token.start, to: token.end, color: token.color })),
281
+ })
282
+ ```
283
+
284
+ The colours are decorations. The document keeps plain text, which is what copying the code out and saving it both want, and a block is only re-tokenised when it changes.
285
+
286
+ ## Links as you type, emoji as you type, Tab to indent
287
+
288
+ ```
289
+ import { autolink, emoji, indent, clearFormatting, focus, trailingNode } from '@matrajs/core'
290
+
291
+ createEditor({
292
+ extensions: [
293
+ ...starterKit,
294
+ autolink(), // https://… followed by a space becomes a link · pasted URLs too
295
+ emoji({ emoticons: true }), // :tada: and :) as you type
296
+ indent(), // Tab and Shift-Tab on paragraphs and headings
297
+ clearFormatting, // Mod-\ · every mark off, back to a paragraph
298
+ focus(), // .has-focus on the block the caret is in
299
+ trailingNode(), // always a paragraph after a table or an image
300
+ ] as const,
301
+ })
302
+ ```
303
+
304
+ Each is one line in the array and nothing else to wire. The ones with input rules are one undo step per replacement, so a wrong guess costs one Mod-Z.
305
+
306
+ ## A template: fixed clauses, blanks, and a mail merge with no editor
307
+
308
+ ```
309
+ import { createEditor, field, fieldsCSS, fillFieldsIn, locked, lockedCSS, starterKit } from '@matrajs/core'
310
+
311
+ // 1. put both in the array
312
+ const editor = createEditor({ extensions: [...starterKit, field, locked()] as const, content })
313
+
314
+ // 2. lock the clauses nobody may edit · the caret in the block, then
315
+ editor.commands.lock() // toggleLock(), unlock() · one undo step each
316
+ editor.can.insert('x') // false inside a locked block, so a button greys out
317
+
318
+ // 3. blanks: type {{name}}, or
319
+ editor.commands.insertField('name', 'Full name')
320
+
321
+ // 4. fill them, here or on a server
322
+ editor.commands.fillFields({ name: 'Ada Lovelace' }) // in the editor
323
+ const letter = fillFieldsIn(template, { name: 'Ada Lovelace' }) // plain JSON, no DOM
324
+
325
+ // 5. the CSS
326
+ document.head.appendChild(Object.assign(document.createElement('style'), { textContent: lockedCSS + fieldsCSS }))
327
+ ```
328
+
329
+ A locked block refuses a keystroke, a paste, a drop, a drag and a command alike, because all of them are changes and the lock is a change filter. Typing beside it, and moving other blocks past it, is fine. A field is an atom: it cannot be half-deleted into {}, and `fieldsIn(doc)` lists what a template still needs.
330
+
331
+ ## Inline completion from any source
332
+
333
+ ```
334
+ import { ghostText, ghostTextCSS } from '@matrajs/core'
335
+
336
+ ghostText({
337
+ delay: 300, // the caret rests this long before asking
338
+ suggest: async ({ before, after }) => {
339
+ const reply = await fetch('/complete', { method: 'POST', body: JSON.stringify({ before, after }) })
340
+ return (await reply.json()).text // or null for nothing
341
+ },
342
+ })
343
+
344
+ // Tab takes it · Escape dismisses it · a word at a time if you like
345
+ editor.commands.acceptGhostWord()
346
+ ```
347
+
348
+ The suggestion is a decoration, never part of the document: not saved, not sent to collaborators, not undone. Any keystroke or caret move dismisses it, and a reply that arrives after the document moved on is dropped, so a slow model never writes into a sentence that has changed underneath it.
349
+
350
+ ## Dictation
351
+
352
+ ```
353
+ import { dictation, dictationCSS, dictationSupported } from '@matrajs/core'
354
+
355
+ const editor = createEditor({ extensions: [...starterKit, dictation({ lang: 'en-GB' })] as const })
356
+
357
+ if (dictationSupported()) button.onclick = () => editor.commands.toggleDictation()
358
+ editor.extensionState('dictation') // { listening, interim, error }
359
+ ```
360
+
361
+ The browser's own recogniser, so nothing is downloaded and nothing is sent anywhere the browser does not already send it. Words the recogniser is still deciding on are drawn after the caret and become text only once it settles; a space is put in front when the caret follows a word. Chrome, Edge and Safari can listen; Firefox cannot yet, and every command returns false there.
362
+
363
+ ## Paste what was meant
364
+
365
+ ```
366
+ import { smartPaste } from '@matrajs/core'
367
+
368
+ createEditor({ extensions: [...starterKit, ...tableKit, smartPaste()] as const })
369
+
370
+ // a spreadsheet copied as text → a table, first row as headers
371
+ // a README copied from a terminal → headings, lists, code fences
372
+ // **bold** in one line → bold, in the sentence the caret is in
373
+ ```
374
+
375
+ Only plain text is looked at; anything with real HTML on the clipboard is left to the parser, which already reads a table copied from a browser. A heading pasted into an editor that has no heading extension stays text, because that editor cannot hold one. Comma-separated text counts too, when every line has the same number of short cells; pass csv: false to turn that off.
376
+
377
+ ## A bubble menu and a floating menu
378
+
379
+ ```
380
+ import { bubbleMenu, floatingMenu } from '@matrajs/core'
381
+
382
+ const bubble = document.querySelector('#bubble') // your element, your buttons, your CSS
383
+ const plus = document.querySelector('#plus')
384
+
385
+ createEditor({
386
+ extensions: [...starterKit, bubbleMenu({ element: bubble }), floatingMenu({ element: plus })] as const,
387
+ })
388
+
389
+ // buttons keep the selection by cancelling mousedown
390
+ bubble.querySelector('button').onmousedown = (event) => { event.preventDefault(); editor.commands.toggleBold() }
391
+ ```
392
+
393
+ The bubble appears over a selection and hides when it collapses or focus leaves both the editor and the menu; the floating one appears on an empty top-level line. Both position the element absolutely against whatever it is positioned in, and both take a shouldShow(editor) when the default is not the rule you want.
394
+
395
+ ## Images that resize
396
+
397
+ ```
398
+ import { image, imageResize, imageResizeCSS } from '@matrajs/core'
399
+
400
+ createEditor({ extensions: [...starterKit, image, imageResize({ min: 48, max: 1200 })] as const })
401
+
402
+ editor.commands.setImageWidth(320, pos) // or drag the handle
403
+ ```
404
+
405
+ The width is an attribute added to the stock image from outside, written to the width attribute so the HTML carries it and a browser honours it before any CSS loads. The handle is a node view from outside too: an editor without the extension renders a plain .
406
+
407
+ ## Columns and page breaks
408
+
409
+ ```
410
+ import { columnsKit, columnsCSS, pageBreak, pageBreakCSS } from '@matrajs/core'
411
+
412
+ createEditor({ extensions: [...starterKit, ...columnsKit, pageBreak] as const })
413
+
414
+ editor.commands.setColumns(3) // the block at the caret becomes the first column
415
+ editor.commands.addColumn() // up to six
416
+ editor.commands.unsetColumns() // every column's blocks, in order, back as blocks
417
+ editor.commands.insertPageBreak() // a labelled line here, a new page in print
418
+ ```
419
+
420
+ The columns are a CSS grid on the list, so the document says how many there are and the page decides how wide each one is. The page break's CSS includes the `@media print` rule, so `window.print()` starts a new page at each one.
421
+
422
+ ## Footnotes and formulas
423
+
424
+ ```
425
+ import { footnotesKit, footnotesCSS, mathKit, mathCSS } from '@matrajs/core'
426
+ import katex from 'katex'
427
+
428
+ createEditor({
429
+ extensions: [
430
+ ...starterKit,
431
+ ...footnotesKit(),
432
+ ...mathKit({ render: (latex, element, display) => katex.render(latex, element, { displayMode: display }) }),
433
+ ] as const,
434
+ })
435
+
436
+ editor.commands.insertFootnote() // a marker here, a note below, the caret in the note
437
+ editor.commands.insertInlineMath('x^2') // or type $x^2$ and a space · $$…$$ on a line for display
438
+ ```
439
+
440
+ Footnote numbers are decorations computed from where the markers stand, so moving a paragraph renumbers everything and the document never stores a number. A formula stores only its source; the renderer is yours, and without one the source shows in a . The exported HTML carries the source as text, so a page with no script still reads it.
441
+
442
+ ## Text case, invisible characters, the other occurrences
443
+
444
+ ```
445
+ import { invisibleCharacters, invisibleCharactersCSS, selectionHighlight, selectionHighlightCSS, textTransform } from '@matrajs/core'
446
+
447
+ createEditor({
448
+ extensions: [...starterKit, textTransform, invisibleCharacters(), selectionHighlight({ wholeWord: true })] as const,
449
+ })
450
+
451
+ editor.commands.sentenceCase() // or uppercase, lowercase, capitalize, toggleCase
452
+ editor.commands.toggleInvisibleCharacters() // a dot on every space, a pilcrow on every block
453
+ ```
454
+
455
+ Case changes work on the selection, or on the word under the caret, and rewrite one text node at a time so a bold word stays bold. Invisible characters and the highlight on other occurrences of a selected word are decorations: never in the HTML, never in the JSON, and cached per block so keeping them on while writing costs the paragraph being written.
456
+
457
+ ## Direction, line height, and a line that stays put
458
+
459
+ ```
460
+ import { lineHeight, textDirection, typewriter } from '@matrajs/core'
461
+
462
+ createEditor({ extensions: [...starterKit, textDirection(), lineHeight(), typewriter({ position: 0.4 })] as const })
463
+
464
+ editor.commands.setTextDirection('rtl') // stored · unset it and the text decides again
465
+ editor.commands.setLineHeight(1.6) // a checked style, round-tripped through HTML
466
+ editor.commands.toggleTypewriter()
467
+ ```
468
+
469
+ A block whose first strong character is Arabic, Hebrew, Syriac, Thaana or NKo is drawn right to left with nothing stored, the way `dir="auto"` would. Typewriter scrolling keeps the caret's line at a fixed height on screen while the editor has focus, measured in the next animation frame rather than in the input path.
470
+
471
+ ## Snippets, hashtags, key names
472
+
473
+ ```
474
+ import { hashtag, hashtagsIn, kbd, snippets } from '@matrajs/core'
475
+
476
+ createEditor({
477
+ extensions: [
478
+ ...starterKit,
479
+ kbd,
480
+ hashtag(),
481
+ snippets([
482
+ { trigger: 'sig', content: '— Nahim' },
483
+ { trigger: 'tbl', content: { type: 'table', content: [/* rows */] } },
484
+ ], { prefix: ';' }),
485
+ ] as const,
486
+ })
487
+
488
+ hashtagsIn(editor.getJSON()) // ['matra', 'release'] · works on saved JSON too
489
+ editor.commands.toggleKbd() // Mod-Alt-K
490
+ ```
491
+
492
+ A snippet fires on the trigger typed as a whole word and a space; text keeps the space, a block stands on its own. A hashtag is a node, like a mention, so it cannot be half-deleted into `#mat` and the document can be asked for its tags without anyone parsing prose.
493
+
494
+ ## Any embed, sandboxed
495
+
496
+ ```
497
+ import { embed, embedCSS } from '@matrajs/core'
498
+
499
+ createEditor({ extensions: [...starterKit, embed({ allow: ['player.vimeo.com', /^https:\/\/www\.figma\.com\//] })] as const })
500
+
501
+ editor.commands.insertEmbed('https://player.vimeo.com/video/1', { aspect: '4/3' })
502
+ ```
503
+
504
+ The allowlist is checked when a command sets the address, when HTML is parsed, and again when the node renders, because a document loaded from JSON skipped the first two. Every frame is sandboxed and only `https:` passes. Left off, a short list of well-known players and tools applies.
@@ -0,0 +1,94 @@
1
+ # Keyboard shortcuts
2
+
3
+ Every shortcut and input rule the starter kit binds.
4
+
5
+ `Mod` is Command on a Mac and Control everywhere else · you bind it once and it is right on both.
6
+
7
+ {
8
+ groups.map(([name, rows]) => (
9
+ <>
10
+
11
+ ##
12
+
13
+ {rows.map(([key, what]) => (
14
+
15
+ ))}
16
+
17
+ ))
18
+ }
19
+
20
+ Only `Escape` in that last group is bound by the extension. The rest is the host's, because the suggestion extension detects the trigger and renders nothing — the menu, and therefore what its arrow keys mean, is yours. The one on this site is about a hundred lines; there is a working copy in [Recipes](https://matrajs.com/docs/recipes).
21
+
22
+ ## Input rules
23
+
24
+ Typed rather than pressed. Each one is a single undo step, so a wrong guess costs one Mod-Z.
25
+
26
+ {
27
+ rules.map(([typed, what]) => (
28
+
29
+ ))
30
+ }
31
+
32
+ ## Binding your own
33
+
34
+ ```
35
+ const spoiler: MarkDef<{ toggleSpoiler: Command }> = {
36
+ kind: 'mark',
37
+ name: 'spoiler',
38
+ commands: { toggleSpoiler: (ctx) => ctx.toggleMark('spoiler') },
39
+ keys: { 'Mod-Shift-S': 'toggleSpoiler' },
40
+ }
41
+ ```
42
+
43
+ A key can name a command or be a function. Later extensions win a conflict, so ordering the array is how you override a built-in binding.
44
+
45
+ .keys {
46
+ display: grid;
47
+ grid-template-columns: 1fr 1fr;
48
+ gap: 0 26px;
49
+ margin: 0 0 18px;
50
+ }
51
+ .row {
52
+ display: flex;
53
+ align-items: baseline;
54
+ gap: 12px;
55
+ padding: 7px 0;
56
+ background-image: var(--dash-h);
57
+ background-size: 100% 1px;
58
+ background-position: 0 100%;
59
+ background-repeat: no-repeat;
60
+ }
61
+ kbd {
62
+ font-family: var(--font-mono);
63
+ font-size: 11.5px;
64
+ background-image: var(--dash-h), var(--dash-h), var(--dash-v), var(--dash-v);
65
+ background-size:
66
+ 100% 1px,
67
+ 100% 1px,
68
+ 1px 100%,
69
+ 1px 100%;
70
+ background-position:
71
+ 0 0,
72
+ 0 100%,
73
+ 0 0,
74
+ 100% 0;
75
+ background-repeat: no-repeat;
76
+ background-color: var(--surface-2);
77
+ padding: 3px 7px;
78
+ white-space: nowrap;
79
+ flex: none;
80
+ min-width: 108px;
81
+ text-align: center;
82
+ }
83
+ kbd.typed {
84
+ color: var(--indigo);
85
+ }
86
+ .row span {
87
+ font-size: 13.5px;
88
+ color: var(--ink-soft);
89
+ }
90
+ @media (max-width: 720px) {
91
+ .keys {
92
+ grid-template-columns: 1fr;
93
+ }
94
+ }
@@ -0,0 +1,82 @@
1
+ # Solid
2
+
3
+ createMatra: the editor, a ref, and a signal that re-runs only what reads it.
4
+
5
+ ```
6
+ npm i @matrajs/core @matrajs/solid
7
+ ```
8
+
9
+ ## An editor
10
+
11
+ ```
12
+ {`import { starterKit } from '@matrajs/core'
13
+ import { createMatra } from '@matrajs/solid'
14
+
15
+ export function Editor() {
16
+ const { mount } = createMatra({
17
+ extensions: starterKit,
18
+ content: '<p>Hello.</p>',
19
+ })
20
+
21
+ return <div ref={mount} />
22
+ }`}
23
+ ```
24
+
25
+ `createMatra()` gives you the `editor`, a `mount` ref, and a `state` accessor. It registers its own `onCleanup`, so disposing the owner destroys the editor and leaves the element safe to reuse.
26
+
27
+ > Note: The editor is created immediately, not in `onMount`. Commands, `getJSON()` and `getText()` all work before anything is on screen — which is what SSR needs, and what a test needs in order not to render at all.
28
+
29
+ ## A toolbar that tells the truth
30
+
31
+ ```
32
+ {`import { starterKit } from '@matrajs/core'
33
+ import { createMatra } from '@matrajs/solid'
34
+
35
+ export function Editor() {
36
+ const { editor, mount, state } = createMatra({ extensions: starterKit })
37
+
38
+ return (
39
+ <>
40
+ <div class="toolbar">
41
+ <button
42
+ onClick={() => editor.commands.toggleBold()}
43
+ aria-pressed={state().isActive('bold')}
44
+ >Bold</button>
45
+
46
+ <button
47
+ onClick={() => editor.commands.toggleHeading(2)}
48
+ aria-pressed={state().isActive('heading', { level: 2 })}
49
+ >H2</button>
50
+
51
+ <span>{state().getText().length} characters</span>
52
+ </div>
53
+
54
+ <div ref={mount} />
55
+ </>
56
+ )
57
+ }`}
58
+ ```
59
+
60
+ Only the expressions that call `state()` re-run. Solid's reactivity is not a render loop, so a document change does not re-render the component — it updates the two attributes and the one text node that asked.
61
+
62
+ ## Why the accessor returns the editor
63
+
64
+ `state()` hands back the editor itself rather than a snapshot. A toolbar asks `isActive` at render time, and cloning a document to answer that would be the expensive way to do nothing.
65
+
66
+ What changes behind it is a version counter, because the editor is one object whose identity never changes — a signal holding it directly would never notify. That is the whole of the binding, and it is the piece worth not writing twice.
67
+
68
+ ## Reaching the editor from elsewhere
69
+
70
+ ```
71
+ {`const EditorContext = createContext()
72
+
73
+ // in the parent
74
+ <EditorContext.Provider value={editor}>{props.children}</EditorContext.Provider>
75
+
76
+ // in any child
77
+ const editor = useContext(EditorContext)`}
78
+ ```
79
+
80
+ ## Styling
81
+
82
+ Matra ships no appearance. [Styling](https://matrajs.com/docs/styling) covers what you style yourself, which extensions bring a stylesheet, and why a slash menu is state rather than a menu.