@vibes.diy/prompts 14.3.83 → 14.3.84
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/llms/access.md +61 -17
- package/llms/index.d.ts +4 -1
- package/llms/index.js +9 -0
- package/llms/index.js.map +1 -1
- package/llms/motion.d.ts +2 -0
- package/llms/motion.js +47 -0
- package/llms/motion.js.map +1 -0
- package/llms/motion.md +601 -0
- package/llms/physics.md +2 -1
- package/llms/richtext.d.ts +2 -0
- package/llms/richtext.js +47 -0
- package/llms/richtext.js.map +1 -0
- package/llms/richtext.md +612 -0
- package/llms/strudel.md +20 -6
- package/llms/worlds.d.ts +2 -0
- package/llms/worlds.js +45 -0
- package/llms/worlds.js.map +1 -0
- package/llms/worlds.md +826 -0
- package/package.json +5 -4
- package/restyle.js +3 -2
- package/restyle.js.map +1 -1
package/llms/richtext.md
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
# Rich text — notebooks, journals, wikis and documents with Tiptap
|
|
2
|
+
|
|
3
|
+
When someone asks for a notebook, a journal, a wiki, a notes app, a document
|
|
4
|
+
editor, a place to write with headings, lists, checklists, links and images,
|
|
5
|
+
a shared page a team edits, meeting notes, a recipe book with real pages, or
|
|
6
|
+
any app whose heart is writing more than a sentence, this is the recipe.
|
|
7
|
+
Tiptap (MIT, on ProseMirror) is the editor: the app mounts it on a plain
|
|
8
|
+
element through a small helper module, keeps each document as Tiptap's JSON
|
|
9
|
+
in Fireproof, saves after typing pauses, loads a document without echoing it
|
|
10
|
+
back as a save, and styles the written text with Tailwind on the host.
|
|
11
|
+
|
|
12
|
+
The sibling boundary: a short field (a title, a comment, a caption) is
|
|
13
|
+
pouf's `Textarea` in a `Field`; a feed of posts is `pouf-feed`; a pipeline
|
|
14
|
+
of stories with stages is `pouf-editorial`. Rich text is the right call when
|
|
15
|
+
the person is writing a page.
|
|
16
|
+
|
|
17
|
+
The worked examples below were built and measured for this doc (Chromium,
|
|
18
|
+
2026-10-07, Tiptap `3.31.4` from esm.sh): a notebook with a list of notes
|
|
19
|
+
and one editor, and a shared page a team edits together.
|
|
20
|
+
|
|
21
|
+
## Project shape
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
App.jsx the notebook: a list of documents, one editor, a toolbar, save state
|
|
25
|
+
lib/editor.js createEditor(host, …): the pinned imports, change events, load, flush, commands, destroy
|
|
26
|
+
access.js who may write which documents
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## One ProseMirror, pinned
|
|
30
|
+
|
|
31
|
+
**Import every Tiptap package by its full esm.sh URL, and every Tiptap
|
|
32
|
+
package is pinned to the same version**, in `lib/editor.js` at the top of
|
|
33
|
+
the file. ProseMirror is unforgiving about copies: two versions of
|
|
34
|
+
`@tiptap/pm` in one editor throw on the first keyed plugin, and nothing in
|
|
35
|
+
the runtime keeps a family of packages on one version (each bare import
|
|
36
|
+
resolves to its own latest). Measured: with `@tiptap/core`, `starter-kit`,
|
|
37
|
+
`extension-placeholder`, `extension-task-list`, `extension-task-item`,
|
|
38
|
+
`extension-link` and `extension-image` all at `3.31.4`, the editor's state
|
|
39
|
+
and view are instances of the one `@tiptap/pm` the pinned URLs share, and
|
|
40
|
+
the whole set arrives in about 2 s cold. The core entry is the one to use:
|
|
41
|
+
`new Editor({ element })` mounts into a ref with no React of its own, so
|
|
42
|
+
the app's one React stays the only one. (`@tiptap/react` exists; the core
|
|
43
|
+
entry does the same job here with one fewer thing to keep in step.)
|
|
44
|
+
|
|
45
|
+
### `lib/editor.js`
|
|
46
|
+
```js app=notebook
|
|
47
|
+
// lib/editor.js — a Tiptap editor behind a small surface: create it on a host element, hear about
|
|
48
|
+
// changes as JSON plus plain text (debounced for saving), load a document without echoing it back
|
|
49
|
+
// as a change, run toolbar commands, and destroy it on unmount. One ProseMirror: every package is
|
|
50
|
+
// pinned to the same version so esm.sh serves one copy of @tiptap/pm.
|
|
51
|
+
import { Editor } from "https://esm.sh/@tiptap/core@3.31.4";
|
|
52
|
+
import StarterKit from "https://esm.sh/@tiptap/starter-kit@3.31.4";
|
|
53
|
+
import Placeholder from "https://esm.sh/@tiptap/extension-placeholder@3.31.4";
|
|
54
|
+
import TaskList from "https://esm.sh/@tiptap/extension-task-list@3.31.4";
|
|
55
|
+
import TaskItem from "https://esm.sh/@tiptap/extension-task-item@3.31.4";
|
|
56
|
+
import Link from "https://esm.sh/@tiptap/extension-link@3.31.4";
|
|
57
|
+
import Image from "https://esm.sh/@tiptap/extension-image@3.31.4";
|
|
58
|
+
|
|
59
|
+
export const EMPTY_DOC = { type: "doc", content: [{ type: "paragraph" }] };
|
|
60
|
+
|
|
61
|
+
/** The first heading's text, else the first words of the body: what a document list shows. */
|
|
62
|
+
export function titleOf(json, fallback = "Untitled") {
|
|
63
|
+
const blocks = json?.content ?? [];
|
|
64
|
+
const heading = blocks.find((b) => b.type === "heading");
|
|
65
|
+
const text = (node) => (node.content ?? []).map((n) => n.text ?? text(n)).join("");
|
|
66
|
+
const fromHeading = heading ? text(heading).trim() : "";
|
|
67
|
+
if (fromHeading) return fromHeading.slice(0, 80);
|
|
68
|
+
const first = blocks.map(text).find((t) => t.trim());
|
|
69
|
+
return first ? first.trim().slice(0, 80) : fallback;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* createEditor(host, { content, placeholder, editable, onChange, debounceMs })
|
|
74
|
+
* `onChange(json, text)` fires after typing pauses for `debounceMs`; `load(json)` replaces the
|
|
75
|
+
* document without firing it, so a document arriving from the database lands as a load, with no save.
|
|
76
|
+
*/
|
|
77
|
+
export function createEditor(host, { content = EMPTY_DOC, placeholder = "Start writing…", editable = true, onChange, debounceMs = 600 } = {}) {
|
|
78
|
+
let timer = 0;
|
|
79
|
+
let loading = false;
|
|
80
|
+
const editor = new Editor({
|
|
81
|
+
element: host,
|
|
82
|
+
editable,
|
|
83
|
+
content,
|
|
84
|
+
extensions: [
|
|
85
|
+
StarterKit,
|
|
86
|
+
Placeholder.configure({ placeholder }),
|
|
87
|
+
TaskList,
|
|
88
|
+
TaskItem.configure({ nested: true }),
|
|
89
|
+
Link.configure({ openOnClick: false, autolink: true }),
|
|
90
|
+
Image.configure({ inline: false }),
|
|
91
|
+
],
|
|
92
|
+
onUpdate: ({ transaction }) => {
|
|
93
|
+
if (loading || !transaction.docChanged || !onChange) return;
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
timer = setTimeout(() => {
|
|
96
|
+
timer = 0; // the pause has landed; flush has nothing left to send
|
|
97
|
+
onChange(editor.getJSON(), editor.getText());
|
|
98
|
+
}, debounceMs);
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
const surface = {
|
|
102
|
+
editor,
|
|
103
|
+
/** Replace the document (from the database); no change event follows. Declined while dirty unless forced. */
|
|
104
|
+
load(json, { force = false } = {}) {
|
|
105
|
+
if (JSON.stringify(json) === JSON.stringify(editor.getJSON())) return true;
|
|
106
|
+
if (timer && !force) return false; // words are waiting to save: the person's keystrokes win
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
timer = 0;
|
|
109
|
+
loading = true;
|
|
110
|
+
editor.commands.setContent(json ?? EMPTY_DOC, { emitUpdate: false });
|
|
111
|
+
loading = false;
|
|
112
|
+
return true;
|
|
113
|
+
},
|
|
114
|
+
/** True while a pause's words are still waiting to be saved. */
|
|
115
|
+
isDirty() {
|
|
116
|
+
return timer !== 0;
|
|
117
|
+
},
|
|
118
|
+
/** Save now, ahead of the debounce (on blur, on switching documents, on unmount). */
|
|
119
|
+
flush() {
|
|
120
|
+
if (!timer) return;
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
timer = 0;
|
|
123
|
+
onChange?.(editor.getJSON(), editor.getText());
|
|
124
|
+
},
|
|
125
|
+
setEditable(on) {
|
|
126
|
+
editor.setEditable(on);
|
|
127
|
+
},
|
|
128
|
+
focus() {
|
|
129
|
+
editor.commands.focus();
|
|
130
|
+
},
|
|
131
|
+
/** Toolbar commands by name, so buttons stay dumb. */
|
|
132
|
+
run(command, value) {
|
|
133
|
+
const c = editor.chain().focus();
|
|
134
|
+
({
|
|
135
|
+
bold: () => c.toggleBold(),
|
|
136
|
+
italic: () => c.toggleItalic(),
|
|
137
|
+
heading: () => c.toggleHeading({ level: value ?? 2 }),
|
|
138
|
+
bullets: () => c.toggleBulletList(),
|
|
139
|
+
numbers: () => c.toggleOrderedList(),
|
|
140
|
+
tasks: () => c.toggleTaskList(),
|
|
141
|
+
quote: () => c.toggleBlockquote(),
|
|
142
|
+
code: () => c.toggleCodeBlock(),
|
|
143
|
+
link: () => (value ? c.setLink({ href: value }) : c.unsetLink()),
|
|
144
|
+
image: () => c.setImage({ src: value }),
|
|
145
|
+
undo: () => c.undo(),
|
|
146
|
+
redo: () => c.redo(),
|
|
147
|
+
})[command]?.().run();
|
|
148
|
+
},
|
|
149
|
+
isActive(name, attrs) {
|
|
150
|
+
return editor.isActive(name, attrs);
|
|
151
|
+
},
|
|
152
|
+
destroy() {
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
editor.destroy();
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
return surface;
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## The notebook
|
|
162
|
+
|
|
163
|
+
### `App.jsx`
|
|
164
|
+
```jsx app=notebook
|
|
165
|
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
166
|
+
import { useFireproof } from "use-fireproof";
|
|
167
|
+
import { useVibe, useViewer } from "use-vibes";
|
|
168
|
+
import { Badge, Button, Card, Heading, IconButton, Row, Skeleton, Spacer, Stack, Text } from "@vibes.diy/pouf";
|
|
169
|
+
import { EMPTY_DOC, createEditor, titleOf } from "./lib/editor.js";
|
|
170
|
+
|
|
171
|
+
// The written page is styled from the host: Tailwind reaches into the editor with arbitrary
|
|
172
|
+
// variants, so the app ships no stylesheet and the JSX stays class-only.
|
|
173
|
+
const PAGE =
|
|
174
|
+
"min-h-64 [&_.tiptap]:outline-none [&_h1]:text-2xl [&_h1]:font-bold [&_h1]:mt-4 [&_h2]:text-xl [&_h2]:font-semibold [&_h2]:mt-3 " +
|
|
175
|
+
"[&_p]:my-2 [&_ul]:list-disc [&_ul]:pl-6 [&_ol]:list-decimal [&_ol]:pl-6 [&_blockquote]:border-l-4 [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground " +
|
|
176
|
+
"[&_a]:underline [&_a]:text-primary [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:rounded [&_img]:max-w-full [&_img]:rounded " +
|
|
177
|
+
"[&_ul[data-type=taskList]]:list-none [&_ul[data-type=taskList]]:pl-0 [&_li[data-type=taskItem]]:flex [&_li[data-type=taskItem]]:gap-2 " +
|
|
178
|
+
"[&_.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.is-editor-empty:first-child::before]:text-muted-foreground [&_.is-editor-empty:first-child::before]:float-left [&_.is-editor-empty:first-child::before]:pointer-events-none";
|
|
179
|
+
|
|
180
|
+
const TOOLS = [
|
|
181
|
+
["bold", "B", "Bold"],
|
|
182
|
+
["italic", "I", "Italic"],
|
|
183
|
+
["heading", "H", "Heading"],
|
|
184
|
+
["bullets", "•", "Bullet list"],
|
|
185
|
+
["tasks", "☑", "Checklist"],
|
|
186
|
+
["quote", "❝", "Quote"],
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
export default function App() {
|
|
190
|
+
const { database, useLiveQuery } = useFireproof("notebook");
|
|
191
|
+
const { me, can, ready } = useVibe("notebook");
|
|
192
|
+
const { requestLogin } = useViewer();
|
|
193
|
+
const { docs } = useLiveQuery("type", { key: "note" });
|
|
194
|
+
const notes = useMemo(() => [...docs].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)), [docs]);
|
|
195
|
+
|
|
196
|
+
const [openId, setOpenId] = useState(null);
|
|
197
|
+
const [saved, setSaved] = useState("idle"); // idle | typing | saved
|
|
198
|
+
const [, bump] = useState(0); // re-render after a command so active states follow the cursor
|
|
199
|
+
const hostRef = useRef(null);
|
|
200
|
+
const surfaceRef = useRef(null);
|
|
201
|
+
const openRef = useRef(null); // the open note as last saved, read by the save callback
|
|
202
|
+
const open = notes.find((n) => n._id === openId) ?? null;
|
|
203
|
+
|
|
204
|
+
// One editor for the page's life: created once, destroyed in the effect's cleanup. The save
|
|
205
|
+
// callback reads the open note through a ref, so one editor serves every note without a rebuild.
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
const surface = createEditor(hostRef.current, {
|
|
208
|
+
placeholder: "Write your note…",
|
|
209
|
+
onChange: async (json, text) => {
|
|
210
|
+
const current = openRef.current;
|
|
211
|
+
if (!current) return;
|
|
212
|
+
// Each save carries its own id, so the copy the live query hands back is recognised as ours
|
|
213
|
+
// whatever the clocks say.
|
|
214
|
+
const next = { ...current, title: titleOf(json), body: json, text, updatedAt: Date.now(), writeId: crypto.randomUUID() };
|
|
215
|
+
openRef.current = next;
|
|
216
|
+
await database.put(next);
|
|
217
|
+
setSaved("saved");
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
surface.editor.on("transaction", () => bump((n) => n + 1));
|
|
221
|
+
surface.editor.on("update", () => setSaved("typing"));
|
|
222
|
+
surfaceRef.current = surface;
|
|
223
|
+
return () => {
|
|
224
|
+
surface.flush();
|
|
225
|
+
surface.destroy();
|
|
226
|
+
surfaceRef.current = null;
|
|
227
|
+
};
|
|
228
|
+
}, []);
|
|
229
|
+
|
|
230
|
+
// A note arrives from the list (opened, or changed elsewhere): load it without a save echo. The
|
|
231
|
+
// app's own save comes back with the writeId it sent and is left alone; another device's save
|
|
232
|
+
// loads when nothing here is waiting to save, and otherwise waits for the next delivery (the
|
|
233
|
+
// words typed here land first; theirs arrive on the round trip after).
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
const surface = surfaceRef.current;
|
|
236
|
+
if (!surface) return;
|
|
237
|
+
if (!open) {
|
|
238
|
+
openRef.current = null;
|
|
239
|
+
surface.load(EMPTY_DOC, { force: true });
|
|
240
|
+
surface.setEditable(false);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const switched = !openRef.current || openRef.current._id !== open._id;
|
|
244
|
+
const theirs = !switched && open.writeId !== openRef.current.writeId;
|
|
245
|
+
if (switched) {
|
|
246
|
+
openRef.current = open;
|
|
247
|
+
surface.load(open.body ?? EMPTY_DOC, { force: true });
|
|
248
|
+
} else if (theirs && surface.load(open.body ?? EMPTY_DOC)) {
|
|
249
|
+
openRef.current = open;
|
|
250
|
+
}
|
|
251
|
+
surface.setEditable(Boolean(ready && can.edit(open).ok));
|
|
252
|
+
}, [open, ready]);
|
|
253
|
+
|
|
254
|
+
async function newNote() {
|
|
255
|
+
const draft = { type: "note", title: "Untitled", body: EMPTY_DOC, text: "", authorHandle: me?.userHandle };
|
|
256
|
+
if (!can.create(draft).ok) return;
|
|
257
|
+
surfaceRef.current?.flush();
|
|
258
|
+
const doc = { ...draft, _id: crypto.randomUUID(), updatedAt: Date.now(), writeId: crypto.randomUUID() };
|
|
259
|
+
await database.put(doc);
|
|
260
|
+
setOpenId(doc._id);
|
|
261
|
+
surfaceRef.current?.focus();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function pick(id) {
|
|
265
|
+
surfaceRef.current?.flush();
|
|
266
|
+
setOpenId(id);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const surface = surfaceRef.current;
|
|
270
|
+
const editable = Boolean(open && ready && can.edit(open).ok);
|
|
271
|
+
|
|
272
|
+
return (
|
|
273
|
+
<main className="min-h-screen bg-background text-foreground p-4 md:p-8">
|
|
274
|
+
<div className="grid gap-4 md:grid-cols-4">
|
|
275
|
+
<div className="md:col-span-1">
|
|
276
|
+
<Card variant="tight">
|
|
277
|
+
<Stack gap={2}>
|
|
278
|
+
<Row gap={2} align="center">
|
|
279
|
+
<Heading level={1}>Notebook</Heading>
|
|
280
|
+
<Spacer />
|
|
281
|
+
{ready && me && <Button size="sm" onClick={newNote}>New</Button>}
|
|
282
|
+
</Row>
|
|
283
|
+
{!ready && <div aria-busy="true"><Skeleton variant="row" count={3} /></div>}
|
|
284
|
+
{ready && !me && <Row gap={2} align="center" wrap><Text muted>Sign in and your notes stay with you.</Text><Button size="sm" variant="quiet" onClick={requestLogin}>Sign in</Button></Row>}
|
|
285
|
+
{notes.map((n) => (
|
|
286
|
+
<Button key={n._id} size="sm" variant={n._id === openId ? "solid" : "quiet"} onClick={() => pick(n._id)}>
|
|
287
|
+
{n.title || "Untitled"}
|
|
288
|
+
</Button>
|
|
289
|
+
))}
|
|
290
|
+
</Stack>
|
|
291
|
+
</Card>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
<div className="md:col-span-3">
|
|
295
|
+
<Card variant="tight">
|
|
296
|
+
<Stack gap={3}>
|
|
297
|
+
<Row gap={1} align="center" wrap>
|
|
298
|
+
{TOOLS.map(([command, glyph, label]) => (
|
|
299
|
+
<IconButton key={command} label={label} tone={surface?.isActive(command === "heading" ? "heading" : command === "bullets" ? "bulletList" : command === "tasks" ? "taskList" : command === "quote" ? "blockquote" : command) ? "blue" : undefined} disabled={!editable} onClick={() => surface?.run(command)}>
|
|
300
|
+
{glyph}
|
|
301
|
+
</IconButton>
|
|
302
|
+
))}
|
|
303
|
+
<Spacer />
|
|
304
|
+
{open && <Badge tone={saved === "saved" ? "up" : "idle"}>{saved === "typing" ? "Typing…" : saved === "saved" ? "Saved" : editable ? "Ready" : "Read only"}</Badge>}
|
|
305
|
+
{open && can.delete(open).ok && <Button size="sm" variant="quiet" tone="down" onClick={() => database.del(open._id).then(() => setOpenId(null))}>Delete</Button>}
|
|
306
|
+
</Row>
|
|
307
|
+
{!open && ready && <Text muted>Pick a note, or start a new one.</Text>}
|
|
308
|
+
<div ref={hostRef} className={PAGE} onBlur={() => surfaceRef.current?.flush()} />
|
|
309
|
+
</Stack>
|
|
310
|
+
</Card>
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
</main>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
### `access.js`
|
|
319
|
+
```js app=notebook
|
|
320
|
+
// note: a page in someone's notebook. Its author reads, edits and removes it.
|
|
321
|
+
export function notebook(doc, oldDoc, user, ctx) {
|
|
322
|
+
if (!user) throw { forbidden: "sign in to keep notes" };
|
|
323
|
+
if (doc.type === "note" || (doc._deleted && oldDoc && oldDoc.type === "note")) {
|
|
324
|
+
const author = oldDoc ? oldDoc.authorHandle : doc.authorHandle;
|
|
325
|
+
if (author !== user.userHandle) throw { forbidden: "notes are kept as yourself" };
|
|
326
|
+
if (oldDoc && !doc._deleted && doc.authorHandle !== oldDoc.authorHandle) throw { forbidden: "a note keeps its author" };
|
|
327
|
+
return { channels: [`notes:${user.userHandle}`], grant: { users: { [user.userHandle]: [`notes:${user.userHandle}`] } } };
|
|
328
|
+
}
|
|
329
|
+
throw { forbidden: "unknown document type" };
|
|
330
|
+
}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
## A saved note is its JSON plus the plain text
|
|
334
|
+
|
|
335
|
+
**A saved note is its JSON plus the plain text: `body` holds
|
|
336
|
+
`editor.getJSON()` (what the editor loads back, marks and attributes
|
|
337
|
+
intact), `text` holds `editor.getText()` (what search, previews and an AI
|
|
338
|
+
summary read), and `title` holds the first heading or the first words.**
|
|
339
|
+
HTML is a rendering, so it is not what gets kept: JSON survives a change of
|
|
340
|
+
extensions, diffs cleanly, and is what `setContent` takes back without a
|
|
341
|
+
parse. The list shows `title`; a search box filters on `text`; `callAI`
|
|
342
|
+
gets `text`. The whole document is one Fireproof document, which is right
|
|
343
|
+
for a page a person edits alone; a page several people edit at once wants
|
|
344
|
+
the shared-page shape below.
|
|
345
|
+
|
|
346
|
+
## Saving waits for typing to pause
|
|
347
|
+
|
|
348
|
+
**Saving waits for typing to pause: the editor's `onUpdate` resets a timer
|
|
349
|
+
and the save runs once the keys have been quiet for 600 ms, with `flush()`
|
|
350
|
+
saving at once on blur, on switching documents and on unmount.** A
|
|
351
|
+
`database.put` on every keystroke floods the server with writes to one
|
|
352
|
+
`_id`, which collide on the document's sequence and get rejected; one save
|
|
353
|
+
per pause keeps writes rare and sequential (measured: three insertions in a
|
|
354
|
+
burst produced one change event, and `flush` produced the next one at
|
|
355
|
+
once). The timer clears itself when it fires, so a blur after a save sends
|
|
356
|
+
nothing twice. The save callback reads the open note through a ref rather
|
|
357
|
+
than a closure, so the editor is created once and switching notes costs
|
|
358
|
+
nothing.
|
|
359
|
+
|
|
360
|
+
## Loading a document lands without a save
|
|
361
|
+
|
|
362
|
+
**A document arriving from the database is loaded with `setContent` and
|
|
363
|
+
`emitUpdate: false`** (Tiptap 3 emits by default; Tiptap 2 did not), and
|
|
364
|
+
the helper also compares the incoming JSON with the editor's own, so a live
|
|
365
|
+
query delivering the note the app just saved is a no-op (measured: loading
|
|
366
|
+
a document produced zero change events, loading the same one again produced
|
|
367
|
+
none, typing after it produced one). The app decides what counts as new: a
|
|
368
|
+
different `_id` is a switch and loads at once; the same `_id` with a
|
|
369
|
+
different `writeId` is another device's save, and it loads only when
|
|
370
|
+
nothing here is waiting to save (`load` returns `false` while a pause's
|
|
371
|
+
words are pending, and the effect tries again on the next delivery). A
|
|
372
|
+
save is recognised by the `writeId` it carried out and back, which holds
|
|
373
|
+
whatever the two devices' clocks say. Words typed here land first; theirs
|
|
374
|
+
arrive on the round trip after, which is the honest order for one person on
|
|
375
|
+
two devices.
|
|
376
|
+
|
|
377
|
+
## Destroy the editor in the effect's cleanup
|
|
378
|
+
|
|
379
|
+
**Create the editor once in `useEffect(() => {…}, [])` and destroy the
|
|
380
|
+
editor in the effect's cleanup**, flushing first so the last pause's words
|
|
381
|
+
land. An editor that outlives its host keeps ProseMirror listeners on a
|
|
382
|
+
detached node and keeps the debounce timer alive; a remount without a
|
|
383
|
+
destroy is two editors on one element. The one editor serves every note:
|
|
384
|
+
the open note travels through a ref, and only the content is swapped.
|
|
385
|
+
|
|
386
|
+
## Styling the page
|
|
387
|
+
|
|
388
|
+
The editor renders real elements (`h1`, `p`, `ul`, `li[data-type=taskItem]`)
|
|
389
|
+
inside a `.tiptap` root with no styles of its own, and a multi-file app
|
|
390
|
+
ships no stylesheet, so **the host element's `className` styles the page
|
|
391
|
+
through Tailwind's arbitrary variants**: `[&_h1]:text-2xl`,
|
|
392
|
+
`[&_ul]:list-disc`, `[&_a]:underline`, and the placeholder through
|
|
393
|
+
`[&_.is-editor-empty:first-child::before]:content-[attr(data-placeholder)]`.
|
|
394
|
+
The JSX stays class-only and the theme tokens (`text-primary`,
|
|
395
|
+
`text-muted-foreground`, `bg-muted`) carry the owner's restyle into the
|
|
396
|
+
written page. Measured: the pouf lint passes a host carrying these classes.
|
|
397
|
+
|
|
398
|
+
## Commands and the toolbar
|
|
399
|
+
|
|
400
|
+
`surface.run("bold")`, `run("heading", 2)`, `run("tasks")`,
|
|
401
|
+
`run("link", href)`, `run("image", src)`, `run("undo")`: the toolbar is
|
|
402
|
+
buttons calling `run`, and `surface.isActive("bold")` colours the one that
|
|
403
|
+
applies at the cursor. Tiptap's own shortcuts work out of the box (`Mod+B`,
|
|
404
|
+
`Mod+I`, `Mod+Shift+8` for a bullet list, `#` then space for a heading,
|
|
405
|
+
`[ ]` then space for a task, `Mod+Z`). A global shortcut of the app's own
|
|
406
|
+
checks `event.target.isContentEditable` first, so typing stays typing. An
|
|
407
|
+
image is `run("image", url)` with a URL from the note's `_files` after a
|
|
408
|
+
save (`doc._files.photo.url`, the stable platform-minted address), so a
|
|
409
|
+
picture is bytes on the document rather than a base64 string in the JSON.
|
|
410
|
+
|
|
411
|
+
## A shared page
|
|
412
|
+
|
|
413
|
+
### `lib/editor.js`
|
|
414
|
+
```js app=page
|
|
415
|
+
// The same helper module as the notebook, kept once per app; shown here in the part the shared
|
|
416
|
+
// page uses. Every Tiptap package is pinned to one version.
|
|
417
|
+
import { Editor } from "https://esm.sh/@tiptap/core@3.31.4";
|
|
418
|
+
import StarterKit from "https://esm.sh/@tiptap/starter-kit@3.31.4";
|
|
419
|
+
import Placeholder from "https://esm.sh/@tiptap/extension-placeholder@3.31.4";
|
|
420
|
+
|
|
421
|
+
export const EMPTY_DOC = { type: "doc", content: [{ type: "paragraph" }] };
|
|
422
|
+
|
|
423
|
+
export function createEditor(host, { content = EMPTY_DOC, placeholder = "Start writing…", editable = true, onChange, debounceMs = 600 } = {}) {
|
|
424
|
+
let timer = 0;
|
|
425
|
+
let loading = false;
|
|
426
|
+
const editor = new Editor({
|
|
427
|
+
element: host,
|
|
428
|
+
editable,
|
|
429
|
+
content,
|
|
430
|
+
extensions: [StarterKit, Placeholder.configure({ placeholder })],
|
|
431
|
+
onUpdate: ({ transaction }) => {
|
|
432
|
+
if (loading || !transaction.docChanged || !onChange) return;
|
|
433
|
+
clearTimeout(timer);
|
|
434
|
+
timer = setTimeout(() => {
|
|
435
|
+
timer = 0; // the pause has landed; flush has nothing left to send
|
|
436
|
+
onChange(editor.getJSON(), editor.getText());
|
|
437
|
+
}, debounceMs);
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
return {
|
|
441
|
+
editor,
|
|
442
|
+
load(json, { force = false } = {}) {
|
|
443
|
+
if (JSON.stringify(json) === JSON.stringify(editor.getJSON())) return true;
|
|
444
|
+
if (timer && !force) return false; // words are waiting to save: the person's keystrokes win
|
|
445
|
+
clearTimeout(timer);
|
|
446
|
+
timer = 0;
|
|
447
|
+
loading = true;
|
|
448
|
+
editor.commands.setContent(json ?? EMPTY_DOC, { emitUpdate: false });
|
|
449
|
+
loading = false;
|
|
450
|
+
return true;
|
|
451
|
+
},
|
|
452
|
+
/** True while a pause's words are still waiting to be saved. */
|
|
453
|
+
isDirty() {
|
|
454
|
+
return timer !== 0;
|
|
455
|
+
},
|
|
456
|
+
flush() {
|
|
457
|
+
if (!timer) return;
|
|
458
|
+
clearTimeout(timer);
|
|
459
|
+
timer = 0;
|
|
460
|
+
onChange?.(editor.getJSON(), editor.getText());
|
|
461
|
+
},
|
|
462
|
+
setEditable(on) {
|
|
463
|
+
editor.setEditable(on);
|
|
464
|
+
},
|
|
465
|
+
destroy() {
|
|
466
|
+
clearTimeout(timer);
|
|
467
|
+
editor.destroy();
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
### `App.jsx`
|
|
474
|
+
```jsx app=page
|
|
475
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
476
|
+
import { useFireproof } from "use-fireproof";
|
|
477
|
+
import { useVibe, useViewer } from "use-vibes";
|
|
478
|
+
import { Badge, Button, Card, Heading, Row, Spacer, Stack, Text } from "@vibes.diy/pouf";
|
|
479
|
+
import { EMPTY_DOC, createEditor } from "./lib/editor.js";
|
|
480
|
+
|
|
481
|
+
const PAGE_ID = "page:home";
|
|
482
|
+
const PAGE =
|
|
483
|
+
"min-h-64 [&_.tiptap]:outline-none [&_h1]:text-2xl [&_h1]:font-bold [&_h2]:text-xl [&_h2]:font-semibold [&_p]:my-2 [&_ul]:list-disc [&_ul]:pl-6 [&_ol]:list-decimal [&_ol]:pl-6 " +
|
|
484
|
+
"[&_.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.is-editor-empty:first-child::before]:text-muted-foreground [&_.is-editor-empty:first-child::before]:float-left";
|
|
485
|
+
|
|
486
|
+
export default function App() {
|
|
487
|
+
const { database, useDocument } = useFireproof("teampage");
|
|
488
|
+
const { me, can, ready } = useVibe("teampage");
|
|
489
|
+
const { requestLogin, ViewerTag } = useViewer();
|
|
490
|
+
const { doc: page } = useDocument({ _id: PAGE_ID, type: "page", body: EMPTY_DOC, text: "" });
|
|
491
|
+
const [saved, setSaved] = useState("idle");
|
|
492
|
+
const hostRef = useRef(null);
|
|
493
|
+
const surfaceRef = useRef(null);
|
|
494
|
+
const pageRef = useRef(page); // the latest page and identity, read by the save callback
|
|
495
|
+
const meRef = useRef(me);
|
|
496
|
+
const lastWriteRef = useRef(null);
|
|
497
|
+
pageRef.current = page;
|
|
498
|
+
meRef.current = me;
|
|
499
|
+
|
|
500
|
+
useEffect(() => {
|
|
501
|
+
const surface = createEditor(hostRef.current, {
|
|
502
|
+
placeholder: "The team's page. Anyone signed in can write here.",
|
|
503
|
+
onChange: async (json, text) => {
|
|
504
|
+
const writeId = crypto.randomUUID();
|
|
505
|
+
lastWriteRef.current = writeId;
|
|
506
|
+
await database.put({ ...pageRef.current, _id: PAGE_ID, type: "page", body: json, text, updatedAt: Date.now(), writeId, editedBy: meRef.current?.userHandle });
|
|
507
|
+
setSaved("saved");
|
|
508
|
+
},
|
|
509
|
+
});
|
|
510
|
+
surface.editor.on("update", () => setSaved("typing"));
|
|
511
|
+
surfaceRef.current = surface;
|
|
512
|
+
return () => {
|
|
513
|
+
surface.flush();
|
|
514
|
+
surface.destroy();
|
|
515
|
+
surfaceRef.current = null;
|
|
516
|
+
};
|
|
517
|
+
}, []);
|
|
518
|
+
|
|
519
|
+
// Someone else's save lands here when nothing of ours is waiting; our own save (the writeId we
|
|
520
|
+
// sent) is left alone.
|
|
521
|
+
useEffect(() => {
|
|
522
|
+
const surface = surfaceRef.current;
|
|
523
|
+
if (!surface || !page) return;
|
|
524
|
+
if (page.writeId && page.writeId !== lastWriteRef.current) surface.load(page.body ?? EMPTY_DOC);
|
|
525
|
+
surface.setEditable(Boolean(ready && me && can.edit(page).ok));
|
|
526
|
+
}, [page, ready, me]);
|
|
527
|
+
|
|
528
|
+
return (
|
|
529
|
+
<main className="min-h-screen bg-background text-foreground p-4 md:p-8">
|
|
530
|
+
<Stack gap={4}>
|
|
531
|
+
<Row gap={2} align="center" wrap>
|
|
532
|
+
<Heading level={1}>Team page</Heading>
|
|
533
|
+
<Spacer />
|
|
534
|
+
{page?.editedBy && <Row gap={1} align="center"><Text size="sm" muted>last edited by</Text><ViewerTag userHandle={page.editedBy} /></Row>}
|
|
535
|
+
<Badge tone={saved === "saved" ? "up" : "idle"}>{saved === "typing" ? "Typing…" : saved === "saved" ? "Saved" : ready && me ? "Ready" : "Read only"}</Badge>
|
|
536
|
+
</Row>
|
|
537
|
+
{ready && !me && <Row gap={2} align="center" wrap><Text muted>Sign in to write on the page.</Text><Button size="sm" variant="quiet" onClick={requestLogin}>Sign in</Button></Row>}
|
|
538
|
+
<Card variant="tight">
|
|
539
|
+
<div ref={hostRef} className={PAGE} onBlur={() => surfaceRef.current?.flush()} />
|
|
540
|
+
</Card>
|
|
541
|
+
</Stack>
|
|
542
|
+
</main>
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
### `access.js`
|
|
548
|
+
```js app=page
|
|
549
|
+
// page: the one shared page. Everyone reads it; anyone signed in writes it and is named as the editor.
|
|
550
|
+
export function teampage(doc, oldDoc, user, ctx) {
|
|
551
|
+
if (doc.type === "page" || (oldDoc && oldDoc.type === "page")) {
|
|
552
|
+
if (!user) throw { forbidden: "sign in to write on the page" };
|
|
553
|
+
if (doc.editedBy !== user.userHandle) throw { forbidden: "a page edit is signed as yourself" };
|
|
554
|
+
return { channels: ["page"], grant: { public: ["page"] } };
|
|
555
|
+
}
|
|
556
|
+
throw { forbidden: "unknown document type" };
|
|
557
|
+
}
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
A shared page is last-writer-wins at the pause: two people typing in the
|
|
561
|
+
same minute each save their whole document, and the later save replaces
|
|
562
|
+
the earlier one, with the other person's editor loading it on the next
|
|
563
|
+
tick. That is honest for a team page edited in turns; say so in the app's
|
|
564
|
+
copy ("saves as you pause; one writer at a time lands"), and for true
|
|
565
|
+
simultaneous editing keep one document per section so two people on
|
|
566
|
+
different sections land independently.
|
|
567
|
+
|
|
568
|
+
## Mobile
|
|
569
|
+
|
|
570
|
+
The editor is a plain `contenteditable`, so the phone keyboard works as it
|
|
571
|
+
does anywhere; keep the toolbar as `IconButton`s in a row that wraps under
|
|
572
|
+
the page, and the page itself full width. Tiptap's input rules (`#` for a
|
|
573
|
+
heading, `-` for a list, `[ ]` for a task) matter more on a phone than the
|
|
574
|
+
toolbar does. Flush on blur, because a phone backgrounds the tab without
|
|
575
|
+
warning.
|
|
576
|
+
|
|
577
|
+
## Shapes a rich text vibe takes
|
|
578
|
+
|
|
579
|
+
- **A notebook** — a list of notes and one editor, each note its own
|
|
580
|
+
document, saved as you pause. The starter.
|
|
581
|
+
- **A journal** — one note per day, `_id: "day:YYYY-MM-DD"`, a calendar
|
|
582
|
+
strip picking the day, yesterday's page one tap away.
|
|
583
|
+
- **A shared page or wiki** — one document per page, everyone reads,
|
|
584
|
+
signed-in people write, last editor named. The second example; several
|
|
585
|
+
pages are several `_id`s with a page list.
|
|
586
|
+
- **Meeting notes with action items** — a checklist in the page, and a
|
|
587
|
+
query over `text` for lines that start with a box, listed in a sidebar.
|
|
588
|
+
- **A recipe book** — a page per recipe with a heading, an ingredients
|
|
589
|
+
checklist and numbered steps, the list filtered by `text`.
|
|
590
|
+
- **A writing app with an AI pass** — `callAI` over `text` for a summary or
|
|
591
|
+
a continuation, inserted with `editor.commands.insertContent` at the end.
|
|
592
|
+
|
|
593
|
+
## Gotchas
|
|
594
|
+
|
|
595
|
+
- **The editor throws on the first key, about a keyed plugin** — two
|
|
596
|
+
copies of ProseMirror, from Tiptap packages at different versions. One
|
|
597
|
+
version across every import.
|
|
598
|
+
- **Every keystroke writes to the database** — the save sits in
|
|
599
|
+
`onUpdate`. The debounce and `flush`.
|
|
600
|
+
- **Opening a note saves it again, forever** — `setContent` emitted an
|
|
601
|
+
update (Tiptap 3's default) and the save loaded it back. `emitUpdate:
|
|
602
|
+
false` in `load`.
|
|
603
|
+
- **The list shows "Untitled" for every note** — the title is waiting to be
|
|
604
|
+
derived. `titleOf(json)` on every save.
|
|
605
|
+
- **Typing is lost when switching notes** — the debounce had not fired.
|
|
606
|
+
`flush()` before changing the open note.
|
|
607
|
+
- **Two editors after a hot reload** — the effect's cleanup did not destroy
|
|
608
|
+
the editor.
|
|
609
|
+
- **The page has no styles** — Tiptap ships none, and a multi-file app
|
|
610
|
+
ships no stylesheet. The host's arbitrary-variant classes.
|
|
611
|
+
- **A pasted image makes the document huge** — a base64 image in the JSON.
|
|
612
|
+
Bytes go in `_files`; the JSON holds the URL.
|
package/llms/strudel.md
CHANGED
|
@@ -31,8 +31,14 @@ the platform rewrites bare specifiers only in the top-of-file import region,
|
|
|
31
31
|
so a lazy import carries the complete versioned URL. Laziness is the point —
|
|
32
32
|
first paint owes nothing to a 165 KB engine plus a drum kit, and the
|
|
33
33
|
AudioContext wants a user gesture anyway, which the play button is.
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
**The loader makes the `AudioContext` itself, synchronously, before its
|
|
35
|
+
first `await`**, resumes it, and hands it to the engine with
|
|
36
|
+
`setAudioContext` ahead of `initStrudel`. The engine's own first-click
|
|
37
|
+
unlock is registered only once the download finishes, by which time the
|
|
38
|
+
press that started it has passed; a context made and resumed inside that
|
|
39
|
+
press is what iOS asks of sound (measured 2026-10-07: the handed-in context
|
|
40
|
+
is the one the engine plays through, `running`, events firing after a
|
|
41
|
+
1.7 s load).
|
|
36
42
|
|
|
37
43
|
Strudel is AGPL-licensed. The app links to the engine at its own address the
|
|
38
44
|
way a browser links to any page, so the two programs stay separate:
|
|
@@ -78,12 +84,17 @@ const DRUM_KIT_URL = "https://cdn.jsdelivr.net/gh/tidalcycles/Dirt-Samples@maste
|
|
|
78
84
|
const DRUM_MACHINES_URL = "https://cdn.jsdelivr.net/gh/felixroos/dough-samples@main/tidal-drum-machines.json";
|
|
79
85
|
|
|
80
86
|
let enginePromise;
|
|
87
|
+
let audioContext;
|
|
81
88
|
const listeners = new Set();
|
|
82
89
|
|
|
83
90
|
/** One fetch of the engine and the drum kit, however many times play is pressed. */
|
|
84
91
|
export function loadStrudel() {
|
|
85
92
|
enginePromise ??= (async () => {
|
|
93
|
+
// Runs inside the press that called play(): the context is made and resumed in the gesture.
|
|
94
|
+
audioContext = new AudioContext();
|
|
95
|
+
audioContext.resume();
|
|
86
96
|
const web = await import(ENGINE_URL);
|
|
97
|
+
web.setAudioContext(audioContext);
|
|
87
98
|
const repl = await web.initStrudel({
|
|
88
99
|
prebake: () => Promise.all([web.samples(DRUM_KIT_URL), web.samples(DRUM_MACHINES_URL)]),
|
|
89
100
|
onEvalError: (error) => {
|
|
@@ -107,6 +118,8 @@ export function onPatternError(fn) {
|
|
|
107
118
|
* the `false` keeps the engine's own sound output in place beside the callback.
|
|
108
119
|
*/
|
|
109
120
|
export async function play(code, onEvent) {
|
|
121
|
+
// Still inside the press: a context the browser suspended while the tab was away resumes here.
|
|
122
|
+
if (audioContext?.state === "suspended") audioContext.resume();
|
|
110
123
|
const { web, repl } = await loadStrudel();
|
|
111
124
|
const pattern = await web.evaluate(code, false);
|
|
112
125
|
if (!pattern) return null;
|
|
@@ -219,10 +232,11 @@ export default function App() {
|
|
|
219
232
|
|
|
220
233
|
async function handlePlay(source = code) {
|
|
221
234
|
setProblem(null);
|
|
222
|
-
|
|
235
|
+
const wasPlaying = state === "playing"; // read once: `state` is this render's value
|
|
236
|
+
if (!wasPlaying) setState("loading");
|
|
223
237
|
const pattern = await play(source);
|
|
224
238
|
if (pattern) setState("playing");
|
|
225
|
-
else if (
|
|
239
|
+
else if (!wasPlaying) setState("idle");
|
|
226
240
|
}
|
|
227
241
|
|
|
228
242
|
async function handleStop() {
|
|
@@ -636,8 +650,8 @@ the kits: `samples({ kick: "https://…/kick.wav", snare: "…" })` inside
|
|
|
636
650
|
|
|
637
651
|
Play is a large button near the top; nothing sounds until it is pressed. A
|
|
638
652
|
grid's steps are `IconButton`s whose label names the row, the step and its
|
|
639
|
-
state, and a tempo slider sits beside play rather than in a settings drawer. The
|
|
640
|
-
|
|
653
|
+
state, and a tempo slider sits beside play rather than in a settings drawer. The loader makes
|
|
654
|
+
and resumes the audio context inside the tap, which is what iOS asks of sound.
|
|
641
655
|
|
|
642
656
|
## Shapes a Strudel vibe takes
|
|
643
657
|
|