@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.
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/dist/cli.js +362 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +240 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +72 -0
- package/dist/index.d.ts +72 -0
- package/dist/index.js +236 -0
- package/dist/index.js.map +1 -0
- package/docs/benchmarks-full.md +207 -0
- package/docs/changelog.md +222 -0
- package/docs/contributing.md +87 -0
- package/docs/design.md +107 -0
- package/docs/docs-ai.md +90 -0
- package/docs/docs-api.md +149 -0
- package/docs/docs-benchmarks.md +104 -0
- package/docs/docs-collab.md +128 -0
- package/docs/docs-commands.md +162 -0
- package/docs/docs-document-model.md +103 -0
- package/docs/docs-extensions.md +168 -0
- package/docs/docs-first-editor.md +136 -0
- package/docs/docs-frameworks.md +138 -0
- package/docs/docs-index.md +66 -0
- package/docs/docs-installation.md +50 -0
- package/docs/docs-mcp.md +66 -0
- package/docs/docs-position-mapping.md +112 -0
- package/docs/docs-react.md +91 -0
- package/docs/docs-recipes.md +504 -0
- package/docs/docs-shortcuts.md +94 -0
- package/docs/docs-solid.md +82 -0
- package/docs/docs-styling.md +61 -0
- package/docs/docs-svelte.md +86 -0
- package/docs/docs-versions.md +103 -0
- package/docs/docs-vue.md +126 -0
- package/docs/engine.md +315 -0
- package/docs/index.json +205 -0
- package/docs/readme.md +604 -0
- package/docs/releasing.md +65 -0
- package/docs/security.md +72 -0
- package/package.json +54 -0
|
@@ -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.
|