@panphora/clayjs 1.8.2 → 1.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/clay.standalone.js +1183 -260
- package/package.json +8 -4
- package/src/core/host-attrs.js +11 -0
- package/src/core/host-meta.js +47 -12
- package/src/core/is-edit-mode.js +5 -2
- package/src/events/onpagemutation.js +4 -2
- package/src/lib/root-attrs.js +6 -1
- package/src/loader-logic.js +4 -1
- package/src/loader.js +11 -3
- package/src/plugins/people.js +424 -0
- package/src/plugins/sortable.js +68 -0
- package/src/plugins/upload.js +6 -0
- package/src/ui/bevel-controls.js +70 -10
- package/src/ui/bevel-dialog.js +70 -21
- package/src/ui/dialogs.js +16 -8
- package/src/ui/index.js +3 -0
- package/src/ui/modal.js +18 -3
- package/src/ui/theme-parts.js +122 -0
- package/src/ui/theme.js +277 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* people.js — who is editing, and who wrote what (spec §9, People).
|
|
3
|
+
*
|
|
4
|
+
* clay.me { id, name, initials, color } or null
|
|
5
|
+
* clay.people.get(id) always a record; an unknown id is "Unknown person"
|
|
6
|
+
* clay.people.list() the people this document names
|
|
7
|
+
* await clay.people.available() the host's team for a picker, or null
|
|
8
|
+
* clay.people.add(person) record a person in the document; returns the record
|
|
9
|
+
* clay.people.remove(id) forget a person the document no longer names
|
|
10
|
+
* clay.people.canRename() true when my name was chosen in this browser
|
|
11
|
+
* clay.people.rename(name) change that name here and in this page
|
|
12
|
+
* await clay.author(el, attr?) stamp el with my id (asks my name once if needed)
|
|
13
|
+
* event clay:people the viewer, the team or the registry changed
|
|
14
|
+
*
|
|
15
|
+
* An id is attribution, never authorization: anyone who can edit the page can
|
|
16
|
+
* write any id. Names on screen prefer the host's live answer; the copy stored in
|
|
17
|
+
* the document changes only when that person next writes (author or add), so
|
|
18
|
+
* opening a page never changes its bytes.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { hostMeta, hostMetaOutcome } from "../core/host-meta.js";
|
|
22
|
+
import { isEditMode } from "../core/is-edit-mode.js";
|
|
23
|
+
|
|
24
|
+
const editing = () => (window.clay && "isEditMode" in window.clay ? !!window.clay.isEditMode : isEditMode);
|
|
25
|
+
import { bevelDialog, dismissOf, keepFocusIn } from "../ui/bevel-dialog.js";
|
|
26
|
+
import { bevelBox, bevelButton, bevelInput, bevelText } from "../ui/bevel-controls.js";
|
|
27
|
+
import { resolveTheme } from "../ui/theme.js";
|
|
28
|
+
|
|
29
|
+
const ID = /^[A-Za-z0-9_-]{8,64}$/;
|
|
30
|
+
const STORE = "clay:people:me";
|
|
31
|
+
const BOOT_WAIT_MS = 2000;
|
|
32
|
+
const COLORS = 8;
|
|
33
|
+
const MAX_NAME = 120;
|
|
34
|
+
const EMAIL = /\S+@\S+\.\S+/;
|
|
35
|
+
|
|
36
|
+
let host = { supports: false, me: null, members: null, unavailable: false };
|
|
37
|
+
let outcome = null;
|
|
38
|
+
let local;
|
|
39
|
+
let cache = null;
|
|
40
|
+
let asking = null;
|
|
41
|
+
let askingRoot = null;
|
|
42
|
+
let cancelAsking = null;
|
|
43
|
+
let lastRegistry = "";
|
|
44
|
+
|
|
45
|
+
function clean(person) {
|
|
46
|
+
if (!person || typeof person !== "object") return null;
|
|
47
|
+
const id = typeof person.id === "string" ? person.id : "";
|
|
48
|
+
const name = typeof person.name === "string" ? person.name.trim().replace(/\s+/g, " ") : "";
|
|
49
|
+
if (!ID.test(id) || !name || name.length > MAX_NAME || EMAIL.test(name)) return null;
|
|
50
|
+
return { id, name };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function initialsOf(name) {
|
|
54
|
+
const letters = name.split(" ").filter(Boolean).slice(0, 2).map((word) => Array.from(word)[0]);
|
|
55
|
+
return letters.join("").toUpperCase() || "?";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function colorOf(id) {
|
|
59
|
+
let hash = 0;
|
|
60
|
+
for (const ch of String(id)) hash = (hash * 31 + ch.codePointAt(0)) >>> 0;
|
|
61
|
+
return hash % COLORS;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function record({ id, name }) {
|
|
65
|
+
return Object.freeze({ id, name, initials: initialsOf(name), color: colorOf(id) });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function emit() {
|
|
69
|
+
document.dispatchEvent(new CustomEvent("clay:people", { detail: { me: currentMe() } }));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function registry() {
|
|
73
|
+
return document.querySelector("[clay-people]");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Every registry counts: two editors whose first comments race can each create one, and a
|
|
77
|
+
// merge may keep both.
|
|
78
|
+
function entries() {
|
|
79
|
+
return [...document.querySelectorAll("[clay-people] data[value]")];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function saved() {
|
|
83
|
+
if (cache) return cache;
|
|
84
|
+
cache = new Map();
|
|
85
|
+
for (const el of entries()) {
|
|
86
|
+
const person = clean({ id: el.getAttribute("value"), name: el.textContent });
|
|
87
|
+
if (person && !cache.has(person.id)) cache.set(person.id, record(person));
|
|
88
|
+
}
|
|
89
|
+
return cache;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readLocal() {
|
|
93
|
+
if (local === undefined) {
|
|
94
|
+
local = null;
|
|
95
|
+
try {
|
|
96
|
+
local = clean(JSON.parse(window.localStorage.getItem(STORE) || "null"));
|
|
97
|
+
} catch (_) {}
|
|
98
|
+
}
|
|
99
|
+
return local;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function writeLocal(person) {
|
|
103
|
+
local = person;
|
|
104
|
+
try {
|
|
105
|
+
window.localStorage.setItem(STORE, JSON.stringify(person));
|
|
106
|
+
} catch (_) {}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function mint() {
|
|
110
|
+
const bytes = new Uint8Array(16);
|
|
111
|
+
crypto.getRandomValues(bytes);
|
|
112
|
+
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Only an editor is anyone: a read-only page has no me, so it shows no compose UI. The
|
|
116
|
+
// host's answer wins when it names you. A host that failed to answer has not said you are
|
|
117
|
+
// nobody, so no local name stands in for a known account. Otherwise (no discovery, a host
|
|
118
|
+
// without people, or a guest it cannot name) an editor is whoever this browser says it is.
|
|
119
|
+
export function currentMe() {
|
|
120
|
+
if (!editing()) return null;
|
|
121
|
+
if (host.me) return record(host.me);
|
|
122
|
+
// The host knows who you are but cannot say right now: not a reason to become whoever
|
|
123
|
+
// this browser last was.
|
|
124
|
+
if (host.unavailable) return null;
|
|
125
|
+
if (outcome !== "ok" && outcome !== "none") return null;
|
|
126
|
+
const mine = readLocal();
|
|
127
|
+
return mine ? record(mine) : null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function applyMeta(meta) {
|
|
131
|
+
const nextOutcome = hostMetaOutcome(meta);
|
|
132
|
+
// Once the host has named people, a later answer that is not a capability document is a
|
|
133
|
+
// blip (a deploy, a proxy), not news that it forgot everyone: the last good answer stands.
|
|
134
|
+
if (nextOutcome !== "ok" && host.supports) return;
|
|
135
|
+
const people = meta.document?.people;
|
|
136
|
+
const supports = meta.extensions.includes("people") && !!people && typeof people === "object";
|
|
137
|
+
const next = {
|
|
138
|
+
supports,
|
|
139
|
+
me: supports ? clean(people.me) : null,
|
|
140
|
+
members: supports && Array.isArray(people.members) ? people.members.map(clean).filter(Boolean) : null,
|
|
141
|
+
unavailable: supports && people.unavailable === true,
|
|
142
|
+
};
|
|
143
|
+
const changed = nextOutcome !== outcome || JSON.stringify(next) !== JSON.stringify(host);
|
|
144
|
+
host = next;
|
|
145
|
+
outcome = nextOutcome;
|
|
146
|
+
if (changed) emit();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function refresh(fresh) {
|
|
150
|
+
applyMeta(await hostMeta({ fresh }));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function get(id) {
|
|
154
|
+
const key = String(id ?? "");
|
|
155
|
+
const live = (host.me?.id === key && host.me) || host.members?.find((person) => person.id === key);
|
|
156
|
+
if (live) return record(live);
|
|
157
|
+
const mine = readLocal();
|
|
158
|
+
if (mine?.id === key) return record(mine);
|
|
159
|
+
return saved().get(key) || Object.freeze({ id: key, name: "Unknown person", initials: "?", color: colorOf(key) });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function list() {
|
|
163
|
+
return [...saved().keys()].map(get);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function available() {
|
|
167
|
+
await refresh(outcome !== "none");
|
|
168
|
+
return host.members ? host.members.map(record) : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function add(person) {
|
|
172
|
+
const p = clean(person);
|
|
173
|
+
if (!p) throw new TypeError("clay.people.add: needs an id of 8 to 64 letters, digits, _ or -, and a name of 1 to 120 characters");
|
|
174
|
+
let root = registry();
|
|
175
|
+
if (!root) {
|
|
176
|
+
root = document.createElement("div");
|
|
177
|
+
root.setAttribute("clay-people", "");
|
|
178
|
+
root.hidden = true;
|
|
179
|
+
document.body.append(root);
|
|
180
|
+
}
|
|
181
|
+
const same = entries().filter((el) => el.getAttribute("value") === p.id);
|
|
182
|
+
if (same.length) {
|
|
183
|
+
if (same[0].textContent !== p.name) same[0].textContent = p.name;
|
|
184
|
+
for (const extra of same.slice(1)) extra.remove();
|
|
185
|
+
} else {
|
|
186
|
+
const el = document.createElement("data");
|
|
187
|
+
el.setAttribute("value", p.id);
|
|
188
|
+
el.textContent = p.name;
|
|
189
|
+
root.append(el);
|
|
190
|
+
}
|
|
191
|
+
cache = null;
|
|
192
|
+
return record(p);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Forget a person the document no longer names, so their name stops travelling with it.
|
|
196
|
+
function remove(id) {
|
|
197
|
+
const key = String(id ?? "");
|
|
198
|
+
for (const el of entries()) if (el.getAttribute("value") === key) el.remove();
|
|
199
|
+
cache = null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Only a name this browser chose can change here; a host's name is the host's to change.
|
|
203
|
+
function canRename() {
|
|
204
|
+
return editing() && !host.me && (outcome === "ok" || outcome === "none") && !!readLocal() && !host.unavailable;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Change the name this browser uses, and this page's record of it. Other pages keep the
|
|
209
|
+
* old name until this person next writes there. Returns the new record.
|
|
210
|
+
*/
|
|
211
|
+
function rename(name) {
|
|
212
|
+
if (!canRename()) throw Object.assign(new Error("clay.people.rename: only a name chosen in this browser can be changed here"), { code: "not-renamable" });
|
|
213
|
+
const mine = readLocal();
|
|
214
|
+
const next = clean({ id: mine.id, name });
|
|
215
|
+
if (!next) throw new TypeError("clay.people.rename: needs a name of 1 to 120 characters that is not an email address");
|
|
216
|
+
writeLocal(next);
|
|
217
|
+
if (entries().some((el) => el.getAttribute("value") === next.id)) add(next);
|
|
218
|
+
emit();
|
|
219
|
+
return record(next);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function unavailable() {
|
|
223
|
+
return Object.assign(new Error("Can't reach the host to check who you are. Try again in a moment."), { code: "people-unavailable" });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Stamp `el` with the current person's id, recording them in the document.
|
|
228
|
+
* Resolves the person's record, or null when they cancel the name prompt.
|
|
229
|
+
* Rejects with code "people-unavailable" when the host failed to answer.
|
|
230
|
+
*/
|
|
231
|
+
export async function author(el, attr = "data-by") {
|
|
232
|
+
if (!window.clay?.isEditMode) throw new Error("clay.author: this page is not in edit mode");
|
|
233
|
+
if (!(el instanceof Element)) throw new TypeError("clay.author: needs an element");
|
|
234
|
+
// A host that names people can change who you are while the page is open (a sign-out,
|
|
235
|
+
// a rename in the app), so a stamp asks it again first. If it cannot answer now, nothing
|
|
236
|
+
// is stamped: the last answer might name someone who is no longer here.
|
|
237
|
+
if (host.supports) {
|
|
238
|
+
const meta = await hostMeta({ fresh: true });
|
|
239
|
+
const fresh = hostMetaOutcome(meta);
|
|
240
|
+
if (fresh === "none") throw Object.assign(new Error("This page lost its connection to the host. Reload it to keep editing."), { code: "people-unavailable" });
|
|
241
|
+
if (fresh !== "ok") throw unavailable();
|
|
242
|
+
applyMeta(meta);
|
|
243
|
+
if (host.unavailable) throw Object.assign(new Error("The app hosting this file can't name you right now. Check its Profile settings, then try again."), { code: "people-unavailable" });
|
|
244
|
+
} else if (outcome !== "ok" && outcome !== "none") {
|
|
245
|
+
await refresh(outcome === "failed");
|
|
246
|
+
}
|
|
247
|
+
if (outcome === "failed") throw unavailable();
|
|
248
|
+
let me = currentMe();
|
|
249
|
+
if (!me) {
|
|
250
|
+
const chosen = await askName();
|
|
251
|
+
if (!chosen) return null;
|
|
252
|
+
writeLocal(chosen);
|
|
253
|
+
me = record(chosen);
|
|
254
|
+
emit();
|
|
255
|
+
}
|
|
256
|
+
add(me);
|
|
257
|
+
el.setAttribute(attr, me.id);
|
|
258
|
+
return me;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function askName() {
|
|
262
|
+
if (asking && askingRoot?.isConnected) return asking;
|
|
263
|
+
if (asking) cancelAsking();
|
|
264
|
+
asking = new Promise((resolve) => {
|
|
265
|
+
const theme = resolveTheme();
|
|
266
|
+
const { root, overlay, panel, heading, body, footer, close } = bevelDialog({
|
|
267
|
+
zIndex: "2147483001", width: "420px", closable: true, titled: true, theme, surface: "people",
|
|
268
|
+
});
|
|
269
|
+
askingRoot = root;
|
|
270
|
+
cancelAsking = () => finish(null);
|
|
271
|
+
heading.textContent = "What name should appear on your changes?";
|
|
272
|
+
panel.setAttribute("aria-label", "Your name");
|
|
273
|
+
|
|
274
|
+
const input = bevelInput("input", { rules: ["display:block", "width:100%", "margin:0"], theme, parts: ["dialog.input", "people.input"] });
|
|
275
|
+
input.setAttribute("aria-label", "Your name");
|
|
276
|
+
input.maxLength = MAX_NAME;
|
|
277
|
+
input.autocomplete = "name";
|
|
278
|
+
const hint = bevelText("p", ["display:block", "margin:10px 0 0", "font-size:13px", "opacity:.8"],
|
|
279
|
+
"Saved in this browser. Everyone who can read this page will see it.", { theme, parts: ["dialog.hint", "people.hint"], role: "mutedText" });
|
|
280
|
+
const hintText = hint.textContent;
|
|
281
|
+
body.append(input, hint);
|
|
282
|
+
|
|
283
|
+
const known = list().slice(0, 8);
|
|
284
|
+
if (known.length) {
|
|
285
|
+
const label = bevelText("p", ["display:block", "margin:18px 0 8px", "font-size:13px"], "Or continue as someone already in this page:", { theme, parts: ["people.choiceLabel"], role: "mutedText" });
|
|
286
|
+
const row = bevelBox("div", ["display:flex", "flex-wrap:wrap", "gap:8px"], { theme, parts: ["people.choices"] });
|
|
287
|
+
for (const person of known) {
|
|
288
|
+
row.append(bevelButton(person.name, { small: true, onClick: () => finish({ id: person.id, name: person.name }), theme, parts: ["dialog.button", "people.button", "people.choice"], labelParts: ["dialog.buttonLabel", "people.buttonLabel"] }));
|
|
289
|
+
}
|
|
290
|
+
body.append(label, row);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const cancel = bevelButton("Cancel", { onClick: () => finish(null), theme, parts: ["dialog.button", "people.button"], labelParts: ["dialog.buttonLabel", "people.buttonLabel"] });
|
|
294
|
+
const ok = bevelButton("Continue", { variant: "primary", onClick: () => submit(), theme, parts: ["dialog.button", "people.button"], labelParts: ["dialog.buttonLabel", "people.buttonLabel"] });
|
|
295
|
+
footer.append(cancel, ok);
|
|
296
|
+
|
|
297
|
+
const submit = () => {
|
|
298
|
+
const name = input.value.trim().replace(/\s+/g, " ");
|
|
299
|
+
if (!name) {
|
|
300
|
+
input.focus();
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (EMAIL.test(name)) {
|
|
304
|
+
hint.textContent = "Use a name, not an email address. Everyone who can read this page will see it.";
|
|
305
|
+
input.focus();
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
finish({ id: mint(), name: name.slice(0, MAX_NAME) });
|
|
309
|
+
};
|
|
310
|
+
input.addEventListener("input", () => {
|
|
311
|
+
if (hint.textContent !== hintText) hint.textContent = hintText;
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
panel.addEventListener("submit", (event) => {
|
|
315
|
+
event.preventDefault();
|
|
316
|
+
submit();
|
|
317
|
+
});
|
|
318
|
+
close?.addEventListener("click", () => finish(null));
|
|
319
|
+
let pressedOnBackdrop = false;
|
|
320
|
+
overlay.addEventListener("mousedown", (event) => { pressedOnBackdrop = event.target === overlay; });
|
|
321
|
+
overlay.addEventListener("click", (event) => {
|
|
322
|
+
if (pressedOnBackdrop && event.target === overlay) finish(null);
|
|
323
|
+
pressedOnBackdrop = false;
|
|
324
|
+
});
|
|
325
|
+
const onKey = (event) => {
|
|
326
|
+
if (event.key === "Tab") {
|
|
327
|
+
event.stopPropagation();
|
|
328
|
+
keepFocusIn(panel, event);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (event.key === "Escape") {
|
|
332
|
+
event.preventDefault();
|
|
333
|
+
event.stopPropagation();
|
|
334
|
+
finish(null);
|
|
335
|
+
} else if (event.key === "Enter" && event.target === input) {
|
|
336
|
+
event.preventDefault();
|
|
337
|
+
event.stopPropagation();
|
|
338
|
+
submit();
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
document.addEventListener("keydown", onKey, true);
|
|
342
|
+
|
|
343
|
+
let done = false;
|
|
344
|
+
function finish(person) {
|
|
345
|
+
if (done) return;
|
|
346
|
+
done = true;
|
|
347
|
+
document.removeEventListener("keydown", onKey, true);
|
|
348
|
+
root.remove();
|
|
349
|
+
// Recorded before the next caller can look, so an author() in the same task finds a me
|
|
350
|
+
// instead of opening a second prompt.
|
|
351
|
+
if (person) writeLocal(person);
|
|
352
|
+
asking = null;
|
|
353
|
+
askingRoot = null;
|
|
354
|
+
cancelAsking = null;
|
|
355
|
+
resolve(person);
|
|
356
|
+
}
|
|
357
|
+
dismissOf.set(root, () => finish(null));
|
|
358
|
+
|
|
359
|
+
root.setAttribute("aria-hidden", "false");
|
|
360
|
+
modalHost().append(root);
|
|
361
|
+
input.focus();
|
|
362
|
+
});
|
|
363
|
+
return asking;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// A page's own modal <dialog> makes everything outside it inert, so the prompt goes inside
|
|
367
|
+
// the topmost one, or it could not be clicked or typed into.
|
|
368
|
+
function modalHost() {
|
|
369
|
+
const open = [...document.querySelectorAll("dialog[open]")].filter((d) => d.matches(":modal"));
|
|
370
|
+
return open[open.length - 1] || document.body;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function touchesRegistry(node) {
|
|
374
|
+
return node.nodeType === 1 && (node.matches("[clay-people]") || !!node.querySelector("[clay-people]"));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function registrySignature() {
|
|
378
|
+
return JSON.stringify([...saved().values()].map(({ id, name }) => [id, name]));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function observe() {
|
|
382
|
+
lastRegistry = registrySignature();
|
|
383
|
+
new MutationObserver((records) => {
|
|
384
|
+
for (const rec of records) {
|
|
385
|
+
const target = rec.target.nodeType === 1 ? rec.target : rec.target.parentElement;
|
|
386
|
+
// A removed clay-people attribute no longer matches the selector, so it is caught by name.
|
|
387
|
+
if (rec.attributeName === "clay-people" ||
|
|
388
|
+
target?.closest("[clay-people]") ||
|
|
389
|
+
[...rec.addedNodes].some(touchesRegistry) ||
|
|
390
|
+
[...rec.removedNodes].some(touchesRegistry)) {
|
|
391
|
+
cache = null;
|
|
392
|
+
// Only a real change is news; a listener that re-renders the registry must not loop.
|
|
393
|
+
const next = registrySignature();
|
|
394
|
+
if (next !== lastRegistry) {
|
|
395
|
+
lastRegistry = next;
|
|
396
|
+
emit();
|
|
397
|
+
}
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}).observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ["value", "clay-people"] });
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function init() {
|
|
405
|
+
observe();
|
|
406
|
+
// A reader is nobody and picks nobody, so a read-only page never asks the host.
|
|
407
|
+
if (!editing()) return;
|
|
408
|
+
window.addEventListener("storage", (event) => {
|
|
409
|
+
if (event.key !== STORE) return;
|
|
410
|
+
local = undefined;
|
|
411
|
+
emit();
|
|
412
|
+
});
|
|
413
|
+
document.addEventListener("visibilitychange", () => {
|
|
414
|
+
if (document.visibilityState === "visible" && outcome !== "none") refresh(true);
|
|
415
|
+
});
|
|
416
|
+
const first = refresh(false);
|
|
417
|
+
await Promise.race([first, new Promise((resolve) => setTimeout(resolve, BOOT_WAIT_MS))]);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export const people = Object.freeze({ get, list, available, add, remove, canRename, rename });
|
|
421
|
+
|
|
422
|
+
export const ready = init();
|
|
423
|
+
|
|
424
|
+
export default people;
|
package/src/plugins/sortable.js
CHANGED
|
@@ -17,9 +17,37 @@
|
|
|
17
17
|
import { isEditMode } from "../core/is-edit-mode.js";
|
|
18
18
|
import Mutation from "../lib/mutation.js";
|
|
19
19
|
import { capabilitySelector } from "../lib/region-capabilities.js";
|
|
20
|
+
import { onSnapshot } from "../core/snapshot.js";
|
|
20
21
|
|
|
21
22
|
const EDITOR_UI_SELECTOR = capabilitySelector('history');
|
|
22
23
|
|
|
24
|
+
// Sortable marks the picked-up item and its links and images draggable="false" on press, and
|
|
25
|
+
// leaves style="" behind after a drag. Those attributes would be saved into the document, so a
|
|
26
|
+
// mere click would change the file. Record them when an item is chosen and put them back after.
|
|
27
|
+
const RUNTIME_ATTRS = ['draggable', 'style'];
|
|
28
|
+
|
|
29
|
+
const pressed = new Set();
|
|
30
|
+
|
|
31
|
+
function recordRuntimeAttrs(item) {
|
|
32
|
+
return [item, ...item.querySelectorAll('*')].map(el => [el, RUNTIME_ATTRS.map(name => el.getAttribute(name))]);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function restoreRuntimeAttrs(recorded) {
|
|
36
|
+
for (const [el, values] of recorded) {
|
|
37
|
+
RUNTIME_ATTRS.forEach((name, i) => {
|
|
38
|
+
const before = values[i];
|
|
39
|
+
const now = el.getAttribute(name);
|
|
40
|
+
if (now === before) return;
|
|
41
|
+
if (before === null) {
|
|
42
|
+
if (name === 'style' && now) return;
|
|
43
|
+
el.removeAttribute(name);
|
|
44
|
+
} else {
|
|
45
|
+
el.setAttribute(name, before);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
23
51
|
function makeSortable(sortableElem, Sortable) {
|
|
24
52
|
let options = {};
|
|
25
53
|
const childSelector = /^(UL|OL)$/.test(sortableElem.tagName) ? 'li' : '*';
|
|
@@ -87,6 +115,27 @@ function makeSortable(sortableElem, Sortable) {
|
|
|
87
115
|
sortableElem.dispatchEvent(new Event('input', { bubbles: true }));
|
|
88
116
|
};
|
|
89
117
|
|
|
118
|
+
// Record on the capture phase, before Sortable's own press handler changes anything, and
|
|
119
|
+
// restore once the press ends: a plain click never fires Sortable's unchoose.
|
|
120
|
+
const ENDS = ['pointerup', 'pointercancel', 'dragend', 'drop'];
|
|
121
|
+
let recorded = null;
|
|
122
|
+
const restoreSoon = () => {
|
|
123
|
+
const pending = recorded;
|
|
124
|
+
recorded = null;
|
|
125
|
+
pressed.delete(pending);
|
|
126
|
+
ENDS.forEach(type => document.removeEventListener(type, restoreSoon, true));
|
|
127
|
+
if (pending) setTimeout(() => restoreRuntimeAttrs(pending), 0);
|
|
128
|
+
};
|
|
129
|
+
sortableElem.addEventListener('pointerdown', (event) => {
|
|
130
|
+
let item = event.target instanceof Element ? event.target : null;
|
|
131
|
+
while (item && item.parentElement !== sortableElem) item = item.parentElement;
|
|
132
|
+
if (!item || recorded) return;
|
|
133
|
+
recorded = recordRuntimeAttrs(item);
|
|
134
|
+
pressed.add(recorded);
|
|
135
|
+
ENDS.forEach(type => document.addEventListener(type, restoreSoon, true));
|
|
136
|
+
}, true);
|
|
137
|
+
options.onUnchoose = restoreSoon;
|
|
138
|
+
|
|
90
139
|
Sortable.create(sortableElem, options);
|
|
91
140
|
}
|
|
92
141
|
|
|
@@ -99,6 +148,25 @@ async function init() {
|
|
|
99
148
|
const mod = await import('../vendor/Sortable.vendor.js');
|
|
100
149
|
const Sortable = window.Sortable || mod.default;
|
|
101
150
|
|
|
151
|
+
// A save taken mid-drag would otherwise write Sortable's classes and attributes into the
|
|
152
|
+
// file. Strip them from the snapshot clone only, so the live drag carries on untouched.
|
|
153
|
+
onSnapshot((root, { original }) => {
|
|
154
|
+
for (const el of root.querySelectorAll("[sortable] .sortable-chosen, [sortable] .sortable-ghost, [sortable] .sortable-drag")) {
|
|
155
|
+
el.classList.remove("sortable-chosen", "sortable-ghost", "sortable-drag");
|
|
156
|
+
if (!el.classList.length) el.removeAttribute("class");
|
|
157
|
+
}
|
|
158
|
+
if (!pressed.size) return;
|
|
159
|
+
const before = new Map();
|
|
160
|
+
for (const recording of pressed) for (const [el, values] of recording) before.set(el, values);
|
|
161
|
+
for (const el of root.querySelectorAll("[sortable] *")) {
|
|
162
|
+
const values = before.get(original(el));
|
|
163
|
+
if (!values) continue;
|
|
164
|
+
RUNTIME_ATTRS.forEach((name, i) => {
|
|
165
|
+
if (values[i] === null) el.removeAttribute(name); else el.setAttribute(name, values[i]);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
102
170
|
// Set up sortable on page load
|
|
103
171
|
document.querySelectorAll('[sortable]').forEach(el => makeSortable(el, Sortable));
|
|
104
172
|
|
package/src/plugins/upload.js
CHANGED
|
@@ -58,6 +58,12 @@ export async function upload(file, { onProgress, signal } = {}) {
|
|
|
58
58
|
// core host, and embedding is what it should do.
|
|
59
59
|
return result(false, "This host does not store uploaded files", "skipped", "unsupported");
|
|
60
60
|
}
|
|
61
|
+
// The host stores files, but not for this caller on this document: the spec withholds the
|
|
62
|
+
// document's upload block by omission, and a client that finds none does not upload. Not
|
|
63
|
+
// "unsupported", because embedding instead would route around the host's own decision.
|
|
64
|
+
if (!meta.document?.upload) {
|
|
65
|
+
return result(false, "You can't add files to this document", "error", "forbidden");
|
|
66
|
+
}
|
|
61
67
|
|
|
62
68
|
// The one local pre-check the spec asks for, and the only reason `maxBytes` is
|
|
63
69
|
// published: refusing a 40 MB photo here costs nothing, and sending it to be
|
package/src/ui/bevel-controls.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// flip the text direction.
|
|
11
11
|
import { style, make, set } from '../lib/hostile-css.js';
|
|
12
12
|
import { RULES, MEDIA, FONT_SANS, TOKENS } from './bevel.js';
|
|
13
|
+
import { themed, tokenRulesFor, partRules, markParts, floorRules } from './theme-parts.js';
|
|
13
14
|
|
|
14
15
|
export const RUNTIME_ONLY = 'no-save no-watch no-snapshot';
|
|
15
16
|
|
|
@@ -81,12 +82,13 @@ function protectIcon(svg, size) {
|
|
|
81
82
|
}
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
export function bevelButton(label, { variant = 'default', small = false, onClick = null, extra = [], labelExtra = [], onState = null } = {}) {
|
|
85
|
+
export function bevelButton(label, { variant = 'default', small = false, onClick = null, extra = [], labelExtra = [], onState = null, theme = null, parts = [], labelParts = [] } = {}) {
|
|
85
86
|
const v = VARIANT[variant] || VARIANT.default;
|
|
86
87
|
const size = small ? SIZE.small : SIZE.normal;
|
|
87
88
|
const reducedMotion = media('(prefers-reduced-motion: reduce)');
|
|
88
89
|
const forced = media('(forced-colors: active)');
|
|
89
90
|
const coarse = media('(pointer: coarse)');
|
|
91
|
+
const narrow = media('(max-width: 760px)');
|
|
90
92
|
const flags = { hovered: false, pressed: false, focusVisible: false, disabled: false };
|
|
91
93
|
const pinned = new Map();
|
|
92
94
|
|
|
@@ -96,6 +98,12 @@ export function bevelButton(label, { variant = 'default', small = false, onClick
|
|
|
96
98
|
span.textContent = label;
|
|
97
99
|
b.append(span);
|
|
98
100
|
|
|
101
|
+
const variantName = VARIANT[variant] ? variant : 'default';
|
|
102
|
+
const names = ['button', `button.${variantName}`, ...parts];
|
|
103
|
+
const labelNames = ['buttonLabel', ...labelParts];
|
|
104
|
+
markParts(b, theme, names);
|
|
105
|
+
markParts(span, theme, labelNames);
|
|
106
|
+
|
|
99
107
|
function applyState() {
|
|
100
108
|
const rules = [...RESET, 'box-sizing:border-box', ...v.rest, ...size];
|
|
101
109
|
if (flags.disabled) {
|
|
@@ -107,13 +115,25 @@ export function bevelButton(label, { variant = 'default', small = false, onClick
|
|
|
107
115
|
if (flags.focusVisible) rules.push(...RULES.focus);
|
|
108
116
|
if (forced) rules.push(...MEDIA.forcedColors);
|
|
109
117
|
if (coarse) rules.push(...MEDIA.coarseButton);
|
|
118
|
+
if (narrow) rules.push('min-height:44px');
|
|
110
119
|
rules.push(...extra);
|
|
120
|
+
if (themed(theme)) {
|
|
121
|
+
const act = { hover: flags.hovered, active: flags.pressed, focus: flags.focusVisible, disabled: flags.disabled };
|
|
122
|
+
rules.push(...tokenRulesFor(theme, `button.${variantName}`, act), ...partRules(theme, names, act));
|
|
123
|
+
if (forced) rules.push(...MEDIA.forcedColors);
|
|
124
|
+
rules.push(...floorRules(rules, 'min-height', narrow ? 44 : coarse ? 40 : 0));
|
|
125
|
+
}
|
|
111
126
|
for (const [prop, value] of pinned) rules.push(`${prop}:${value}`);
|
|
112
127
|
restyle(b, rules);
|
|
113
128
|
const labelRules = [...RESET, 'font:inherit', 'color:inherit', ...RULES.buttonLabel];
|
|
114
129
|
if (flags.disabled) labelRules.push(...RULES.buttonDisabledLabel);
|
|
115
130
|
else if (flags.pressed) labelRules.push(...(reducedMotion ? MEDIA.reducedMotion : RULES.buttonActiveLabel));
|
|
116
131
|
labelRules.push(...labelExtra);
|
|
132
|
+
if (themed(theme)) {
|
|
133
|
+
const act = { hover: flags.hovered, active: flags.pressed, focus: flags.focusVisible, disabled: flags.disabled };
|
|
134
|
+
labelRules.push(...partRules(theme, labelNames, act));
|
|
135
|
+
if (flags.pressed && !flags.disabled && reducedMotion) labelRules.push(...MEDIA.reducedMotion);
|
|
136
|
+
}
|
|
117
137
|
restyle(span, labelRules);
|
|
118
138
|
onState?.({ ...flags });
|
|
119
139
|
}
|
|
@@ -213,33 +233,67 @@ export function bevelCornerClose({ label = 'Close', onClick = null } = {}) {
|
|
|
213
233
|
}
|
|
214
234
|
|
|
215
235
|
// A plain runtime-only box: reset, then only the given rules. For structure inside a
|
|
216
|
-
// surface (rows, stacks, backdrops) that carries no material of its own.
|
|
217
|
-
|
|
218
|
-
|
|
236
|
+
// surface (rows, stacks, backdrops) that carries no material of its own. `role` names the
|
|
237
|
+
// token declarations it takes, or null for parts only.
|
|
238
|
+
export function bevelBox(tag, rules = [], { theme = null, parts = [], role = null } = {}) {
|
|
239
|
+
const el = runtime(make(tag, [...RESET, 'box-sizing:border-box', ...rules]));
|
|
240
|
+
if (themed(theme)) {
|
|
241
|
+
if (role) style(el, tokenRulesFor(theme, role, {}));
|
|
242
|
+
const names = [...(['text', 'mutedText', 'well'].includes(role) ? [role] : []), ...parts];
|
|
243
|
+
style(el, partRules(theme, names, {}));
|
|
244
|
+
markParts(el, theme, names);
|
|
245
|
+
}
|
|
246
|
+
return el;
|
|
219
247
|
}
|
|
220
248
|
|
|
221
|
-
|
|
222
|
-
|
|
249
|
+
// Rebuild a box's whole inline style: what a stateful control does on every change.
|
|
250
|
+
export function restyleBox(el, rules) {
|
|
251
|
+
restyle(el, [...RESET, 'box-sizing:border-box', ...rules]);
|
|
223
252
|
}
|
|
224
253
|
|
|
225
|
-
export function
|
|
226
|
-
|
|
254
|
+
export function bevelSurface(tag, rules = [], { theme = null, parts = [], role = 'surface' } = {}) {
|
|
255
|
+
const el = runtime(make(tag, [...RESET, 'box-sizing:border-box', ...RULES.surface, `font:14px/1.5 ${FONT_SANS}`, ...rules]));
|
|
256
|
+
if (themed(theme)) {
|
|
257
|
+
if (role) style(el, tokenRulesFor(theme, role, {}));
|
|
258
|
+
style(el, partRules(theme, parts, {}));
|
|
259
|
+
markParts(el, theme, parts);
|
|
260
|
+
}
|
|
261
|
+
return el;
|
|
227
262
|
}
|
|
228
263
|
|
|
229
|
-
export function
|
|
264
|
+
export function bevelWell(rules = [], { theme = null, parts = [], role = 'well' } = {}) {
|
|
265
|
+
const el = runtime(make('div', [...RESET, 'display:block', 'box-sizing:border-box', `font:14px/1.5 ${FONT_SANS}`, `color:${TOKENS.ink}`, ...RULES.recess, ...rules]));
|
|
266
|
+
if (themed(theme)) {
|
|
267
|
+
if (role) style(el, tokenRulesFor(theme, role, {}));
|
|
268
|
+
const names = [...(['text', 'mutedText', 'well'].includes(role) ? [role] : []), ...parts];
|
|
269
|
+
style(el, partRules(theme, names, {}));
|
|
270
|
+
markParts(el, theme, names);
|
|
271
|
+
}
|
|
272
|
+
return el;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function bevelText(tag, rules = [], text, { theme = null, parts = [], role = 'text' } = {}) {
|
|
230
276
|
const el = runtime(make(tag, [...RESET, 'font:inherit', `color:${TOKENS.ink}`, ...rules]));
|
|
231
277
|
if (text != null) el.textContent = text;
|
|
278
|
+
if (themed(theme)) {
|
|
279
|
+
if (role) style(el, tokenRulesFor(theme, role, {}));
|
|
280
|
+
const names = [...(['text', 'mutedText', 'well'].includes(role) ? [role] : []), ...parts];
|
|
281
|
+
style(el, partRules(theme, names, {}));
|
|
282
|
+
markParts(el, theme, names);
|
|
283
|
+
}
|
|
232
284
|
return el;
|
|
233
285
|
}
|
|
234
286
|
|
|
235
287
|
// A text field in Bevel's input material, on an element that already exists (markup a
|
|
236
288
|
// dialog wrote) or a new one. Focus and hover live in flags, as on a button. A
|
|
237
289
|
// textarea keeps the height a person dragged it to across every repaint.
|
|
238
|
-
export function paintInput(el, { rules = [] } = {}) {
|
|
290
|
+
export function paintInput(el, { rules = [], theme = null, parts = [] } = {}) {
|
|
239
291
|
const flags = { hovered: false, focused: false };
|
|
240
292
|
const forced = media('(forced-colors: active)');
|
|
241
293
|
const coarse = media('(pointer: coarse)');
|
|
242
294
|
runtime(el);
|
|
295
|
+
const names = ['input', ...parts];
|
|
296
|
+
markParts(el, theme, names);
|
|
243
297
|
function applyState() {
|
|
244
298
|
const height = el.localName === 'textarea' ? el.style.getPropertyValue('height') : '';
|
|
245
299
|
const list = [...RESET, 'box-sizing:border-box', ...RULES.input];
|
|
@@ -248,6 +302,12 @@ export function paintInput(el, { rules = [] } = {}) {
|
|
|
248
302
|
if (forced) list.push(...MEDIA.forcedColors);
|
|
249
303
|
list.push(...rules);
|
|
250
304
|
if (coarse) list.push(...MEDIA.coarseInput);
|
|
305
|
+
if (themed(theme)) {
|
|
306
|
+
const act = { hover: flags.hovered && !el.disabled, focus: flags.focused, disabled: !!el.disabled };
|
|
307
|
+
list.push(...tokenRulesFor(theme, 'input', act), ...partRules(theme, names, act));
|
|
308
|
+
if (forced) list.push(...MEDIA.forcedColors);
|
|
309
|
+
if (coarse) list.push(...floorRules(list, 'font-size', 16));
|
|
310
|
+
}
|
|
251
311
|
if (height) list.push(`height:${height}`);
|
|
252
312
|
restyle(el, list);
|
|
253
313
|
}
|