@panphora/clayjs 1.13.0 → 1.14.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/THIRD-PARTY-NOTICES.md +19 -0
- package/dist/clay.standalone.js +69739 -41784
- package/dist/server/checkpoint.cjs +70690 -0
- package/dist/server/checkpoint.mjs +8 -0
- package/dist/server/rooms.cjs +72039 -0
- package/dist/server/rooms.mjs +66 -0
- package/docs/guide.md +95 -94
- package/entries/clay.js +22 -0
- package/package.json +16 -2
- package/packed-contract.json +17 -3
- package/src/core/conflict-download.js +11 -2
- package/src/core/host-meta.js +75 -0
- package/src/core/persist.js +11 -0
- package/src/core/save-core.js +266 -31
- package/src/core/save.js +62 -32
- package/src/core/snapshot.js +39 -13
- package/src/core/style-comparisons.js +37 -0
- package/src/dom/insert-style-tag.js +11 -12
- package/src/lib/effect.js +130 -0
- package/src/lib/style-adoption.js +9 -0
- package/src/lib/style.js +44 -0
- package/src/live/fast-lane.js +347 -60
- package/src/live/local-lane.js +32 -6
- package/src/live/ops.js +77 -0
- package/src/live/provenance.js +129 -0
- package/src/live/save-history.js +188 -0
- package/src/live/server-lane.js +9 -2
- package/src/live/store.js +194 -16
- package/src/loader-logic.js +7 -3
- package/src/loader.js +6 -0
- package/src/page/address.js +20 -0
- package/src/page/adopt-boot.js +189 -0
- package/src/page/adopt.js +240 -0
- package/src/page/ai-tool.js +188 -0
- package/src/page/announce.js +21 -0
- package/src/page/blocks.js +1121 -0
- package/src/page/blocksel.js +269 -0
- package/src/page/board-drag.js +226 -0
- package/src/page/board-panel.js +353 -0
- package/src/page/board-style.js +146 -0
- package/src/page/board-view.js +447 -0
- package/src/page/board.js +41 -0
- package/src/page/chart.js +220 -0
- package/src/page/checklist.js +143 -0
- package/src/page/chrome.js +587 -0
- package/src/page/csv-import.js +169 -0
- package/src/page/drag.js +202 -0
- package/src/page/drop.js +112 -0
- package/src/page/editor.js +384 -0
- package/src/page/highlight.js +268 -0
- package/src/page/inline.js +60 -1
- package/src/page/items.js +159 -0
- package/src/page/link.js +247 -0
- package/src/page/markdown.js +71 -0
- package/src/page/media.js +570 -0
- package/src/page/merge.js +130 -0
- package/src/page/model.js +71 -9
- package/src/page/placeholder.js +23 -0
- package/src/page/projection.js +647 -0
- package/src/page/recover.js +193 -0
- package/src/page/rich/cell.js +16 -0
- package/src/page/rich/chart.js +104 -0
- package/src/page/rich/csv.js +118 -0
- package/src/page/rich/page-tool.js +86 -0
- package/src/page/rich/tool-runtime.js +195 -0
- package/src/page/save-projection.js +53 -0
- package/src/page/save-states.js +85 -0
- package/src/page/schema.js +12 -5
- package/src/page/table-kinds.js +20 -0
- package/src/page/table-paste.js +57 -0
- package/src/page/table-select.js +244 -0
- package/src/page/table-style.js +25 -0
- package/src/page/table.js +650 -0
- package/src/page/themes/content.css +46 -0
- package/src/page/themes/light-1.css +269 -0
- package/src/page/themes/light-2.css +211 -0
- package/src/page/themes/light-3.css +130 -0
- package/src/page/themes/light-4.css +299 -0
- package/src/page/tool-data.js +25 -0
- package/src/page/tool-view.js +68 -0
- package/src/page/tools.js +133 -0
- package/src/page/view.js +136 -0
- package/src/page/ybind/author.js +25 -0
- package/src/page/ybind/binding.js +92 -0
- package/src/page/ybind/board-commands.js +170 -0
- package/src/page/ybind/board.js +89 -0
- package/src/page/ybind/bridge.js +382 -0
- package/src/page/ybind/conversion.js +26 -0
- package/src/page/ybind/core.js +7 -0
- package/src/page/ybind/decode.js +208 -0
- package/src/page/ybind/doc.js +58 -0
- package/src/page/ybind/external.js +190 -0
- package/src/page/ybind/group.js +124 -0
- package/src/page/ybind/index.js +12 -0
- package/src/page/ybind/layout.js +27 -0
- package/src/page/ybind/marks.js +18 -0
- package/src/page/ybind/migrate.js +152 -0
- package/src/page/ybind/moves.js +288 -0
- package/src/page/ybind/origins.js +257 -0
- package/src/page/ybind/parts.js +44 -0
- package/src/page/ybind/projection-map.js +92 -0
- package/src/page/ybind/provider.js +285 -0
- package/src/page/ybind/recovery.js +411 -0
- package/src/page/ybind/seed.js +27 -0
- package/src/page/ybind/table.js +166 -0
- package/src/page/ybind/tokens.js +93 -0
- package/src/page/ybind/walk.js +333 -0
- package/src/page/ybind/writer.js +307 -0
- package/src/plugins/indicator.js +16 -6
- package/src/plugins/live.js +742 -65
- package/src/plugins/page.js +547 -0
- package/src/plugins/undo.js +60 -3
- package/src/plugins/visitor-drafts/prompt.js +4 -6
- package/src/server/checkpoint/index.js +218 -0
- package/src/server/rooms/index.js +59 -0
- package/src/server/rooms/live-data-ops.js +369 -0
- package/src/server/rooms/live-data-rooms.js +965 -0
- package/src/server/rooms/live-data-tags.js +49 -0
- package/src/server/rooms/live-page-history.js +41 -0
- package/src/server/rooms/live-provenance.js +142 -0
- package/src/server/rooms/page-admission.js +79 -0
- package/src/server/rooms/page-conversion.js +62 -0
- package/src/server/rooms/page-rebase.js +108 -0
- package/src/server/rooms/page-state.js +91 -0
- package/src/server/rooms/page-write-admission.js +63 -0
- package/src/server/rooms/page-write-guard.js +23 -0
- package/src/server/rooms/recent.js +18 -0
- package/src/server/rooms/spec-wire.js +17 -0
- package/src/standalone.js +22 -0
- package/src/sync/live-sync.js +13 -2
- package/src/sync/stream.js +62 -16
- package/src/ui/index.js +4 -0
- package/src/ui/toast.js +21 -5
- package/src/vendor/pierre/pierre.vendor.js +3 -0
- package/src/vendor/yjs.vendor.js +7 -0
- package/src/page/binding.js +0 -647
- package/src/page/replay.js +0 -578
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
import { createPageEditor } from "../page/editor.js";
|
|
2
|
+
import { readFile, installAdoption } from "../page/adopt-boot.js";
|
|
3
|
+
import { classifyFile } from "../page/adopt.js";
|
|
4
|
+
import { renderFromTags } from "../page/save-projection.js";
|
|
5
|
+
import { blocksFromYValue } from "../page/ybind/decode.js";
|
|
6
|
+
import { createYPageBinding } from "../page/ybind/binding.js";
|
|
7
|
+
import { authorIdentity } from "../page/ybind/author.js";
|
|
8
|
+
import { yValueOf } from "../page/ybind/provider.js";
|
|
9
|
+
import { seedPageFromArticle } from "../page/ybind/migrate.js";
|
|
10
|
+
import { serializeLive } from "../live/ops.js";
|
|
11
|
+
import { registerUnsavedState } from "../lib/unsaved-state.js";
|
|
12
|
+
import { hostMeta, hostSupports } from "../core/host-meta.js";
|
|
13
|
+
import { savePageForce, setLastSavedBaselines, holdAllSaves, releaseAllSaves, getLastSavedBytes } from "../core/save.js";
|
|
14
|
+
import { captureForComparisonAndDirty, captureForSaveAndComparison } from "../core/snapshot.js";
|
|
15
|
+
import { storeOf, checkpointWithin, requestCheckpoint, fastLane, pageLaneReady, pageWriterReady } from "./live.js";
|
|
16
|
+
import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
|
|
17
|
+
import { saveStateFor, BLOCKED_TEXT, RETRY_TEXT, downloadPageCopy, downloadSnapshot, copyPageText } from "../page/save-states.js";
|
|
18
|
+
import toast, { toastPersistent, dismissPersistent } from "../ui/toast.js";
|
|
19
|
+
import { createRecoverList, focusBlock } from "../page/recover.js";
|
|
20
|
+
import { createToolHost } from "../page/tools.js";
|
|
21
|
+
import { createAiTool } from "../page/ai-tool.js";
|
|
22
|
+
import { tablesFromDoc } from "../page/tool-data.js";
|
|
23
|
+
import { capabilityAdvertised } from "../page/rich/page-tool.js";
|
|
24
|
+
|
|
25
|
+
const ARTICLE_SEL = "article[data-page]";
|
|
26
|
+
const TAG_SEL = 'script[type="application/json"][merge="page"][clay~="live"]';
|
|
27
|
+
const BLOCKED = { state: "blocked", code: "unsupported-host", message: "This host does not support page editing" };
|
|
28
|
+
const RETRY_BOUND_MS = 45000;
|
|
29
|
+
const GENERIC_ERROR = "Couldn't save";
|
|
30
|
+
const GENERIC_OFFLINE = "Offline, not saved";
|
|
31
|
+
const REPLACED_TEXT = "This page was changed outside the editor. Reload to see it.";
|
|
32
|
+
const TOO_LARGE_TEXT = "That change is too large to share, so it was not saved. Reload the page to keep editing.";
|
|
33
|
+
const OVERWRITTEN_TEXT = "Some changes you made were replaced by a change made outside the editor.";
|
|
34
|
+
const REPLACED_HOLD = "The page was changed outside the editor; reload to see it.";
|
|
35
|
+
|
|
36
|
+
const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
37
|
+
|
|
38
|
+
let generation = 0;
|
|
39
|
+
let active = null;
|
|
40
|
+
|
|
41
|
+
// Shorter than installAdoption's 10 s hold, so an unelected room still adopts.
|
|
42
|
+
export const WRITER_WAIT_MS = 8_000;
|
|
43
|
+
|
|
44
|
+
function scrollToHashBlock(view, loc = globalThis.location) {
|
|
45
|
+
const raw = loc?.hash?.slice(1);
|
|
46
|
+
if (!raw) return;
|
|
47
|
+
let id;
|
|
48
|
+
try { id = decodeURIComponent(raw); } catch { return; }
|
|
49
|
+
let found = -1;
|
|
50
|
+
view.state.doc.forEach((node, pos) => { if (found < 0 && node.attrs.id === id) found = pos; });
|
|
51
|
+
if (found < 0) return;
|
|
52
|
+
globalThis.requestAnimationFrame?.(() => view.nodeDOM(found)?.scrollIntoView?.({ block: "start" }));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function abandon(inst) {
|
|
56
|
+
if (active === inst) active = null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function dispatchState(state, code, message) {
|
|
60
|
+
document.dispatchEvent(new CustomEvent("clay:page-save-state", { detail: { state, code, message } }));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function dismissGeneric() {
|
|
64
|
+
dismissPersistent(GENERIC_ERROR);
|
|
65
|
+
dismissPersistent(GENERIC_OFFLINE);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function clearRetry(inst) {
|
|
69
|
+
if (inst.retryTimer) {
|
|
70
|
+
clearTimeout(inst.retryTimer);
|
|
71
|
+
inst.retryTimer = null;
|
|
72
|
+
}
|
|
73
|
+
inst.retryShown = false;
|
|
74
|
+
dismissPersistent(RETRY_TEXT);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function armRetry(inst, code, message) {
|
|
78
|
+
dismissGeneric();
|
|
79
|
+
dispatchState("retrying", code, message);
|
|
80
|
+
// One clock for a run of temporary failures: a later retry of the same save
|
|
81
|
+
// does not push the bound out again, and once it has passed the toast stands.
|
|
82
|
+
if (inst.retryTimer || inst.retryShown) return;
|
|
83
|
+
inst.retryTimer = setTimeout(() => {
|
|
84
|
+
inst.retryTimer = null;
|
|
85
|
+
inst.retryShown = true;
|
|
86
|
+
toastPersistent(RETRY_TEXT, "warning");
|
|
87
|
+
}, RETRY_BOUND_MS);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function showBlocked(inst, code, message) {
|
|
91
|
+
clearRetry(inst);
|
|
92
|
+
dismissGeneric();
|
|
93
|
+
dispatchState("blocked", code, message);
|
|
94
|
+
toastPersistent(BLOCKED_TEXT, "error", {
|
|
95
|
+
detail: message,
|
|
96
|
+
actions: [
|
|
97
|
+
{ label: "Download a copy", onClick: downloadPageCopy },
|
|
98
|
+
{ label: "Copy text", onClick: copyPageText },
|
|
99
|
+
],
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function showSaved(inst) {
|
|
104
|
+
clearRetry(inst);
|
|
105
|
+
dismissPersistent(BLOCKED_TEXT);
|
|
106
|
+
dispatchState("saved", null, "");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// An incoming document with no page tag whose article is not the one this tab
|
|
110
|
+
// last saved: something outside the editor replaced the page, and this tab's
|
|
111
|
+
// state no longer belongs to the file.
|
|
112
|
+
// On a host that keeps no page data every file comes untagged and is merged
|
|
113
|
+
// instead (adopt-boot.js); only a file with no lone article replaced the page.
|
|
114
|
+
function pageReplaced(html, savedRev, mergeTagless = false) {
|
|
115
|
+
if (typeof html !== "string" || !html) return false;
|
|
116
|
+
let doc;
|
|
117
|
+
try { doc = new DOMParser().parseFromString(html, "text/html"); } catch { return false; }
|
|
118
|
+
const articles = doc.querySelectorAll(ARTICLE_SEL);
|
|
119
|
+
if (mergeTagless) return articles.length !== 1 && !doc.querySelector(TAG_SEL);
|
|
120
|
+
if (articles.length !== 1 || doc.querySelector(TAG_SEL)) return false;
|
|
121
|
+
return articles[0].getAttribute("data-rev") !== savedRev;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function savedRevOf(inst) {
|
|
125
|
+
if (!inst.store) return inst.savedRev;
|
|
126
|
+
try { return renderFromTags([JSON.stringify(inst.store.value)]).rev; } catch { return inst.savedRev; }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Nothing of this tab's may reach the file again: the editor is read-only, the
|
|
130
|
+
// binding is detached from the store (no frame, no flush), and every save is
|
|
131
|
+
// held, so the old article cannot be written back over the replacement.
|
|
132
|
+
function stopEditing(inst) {
|
|
133
|
+
const view = inst.view;
|
|
134
|
+
if (view) {
|
|
135
|
+
view.setProps({ editable: () => false });
|
|
136
|
+
view.setProps({ dispatchTransaction: (tr) => view.updateState(view.state.apply(tr)) });
|
|
137
|
+
}
|
|
138
|
+
try { inst.binding?.dispose(); } catch (error) { console.error("clayjs: page plugin binding failed to dispose", error); }
|
|
139
|
+
if (inst.savesHeld) return;
|
|
140
|
+
inst.savesHeld = true;
|
|
141
|
+
holdAllSaves(REPLACED_HOLD);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The tab's own page, when the store no longer holds it (another page replaced it, or the
|
|
145
|
+
// room refused it): the saved document with the article rendered from the tab's document.
|
|
146
|
+
function downloadTabCopy(inst) {
|
|
147
|
+
if (!inst.tabValue) return downloadPageCopy();
|
|
148
|
+
const { forSave } = captureForSaveAndComparison();
|
|
149
|
+
const doc = new DOMParser().parseFromString(forSave, "text/html");
|
|
150
|
+
const articles = doc.querySelectorAll(ARTICLE_SEL);
|
|
151
|
+
if (articles.length !== 1) return downloadPageCopy();
|
|
152
|
+
const rendered = renderFromTags([JSON.stringify(inst.tabValue)]);
|
|
153
|
+
articles[0].innerHTML = rendered.html;
|
|
154
|
+
if (rendered.rev == null) articles[0].removeAttribute("data-rev");
|
|
155
|
+
else articles[0].setAttribute("data-rev", rendered.rev);
|
|
156
|
+
return downloadSnapshot(`<!DOCTYPE html>\n${doc.documentElement.outerHTML}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function stopWithNotice(inst, text) {
|
|
160
|
+
if (inst.replaced) return;
|
|
161
|
+
inst.replaced = true;
|
|
162
|
+
stopEditing(inst);
|
|
163
|
+
toastPersistent(text, "warning", {
|
|
164
|
+
actions: [
|
|
165
|
+
{ label: "Reload", onClick: () => window.location.reload() },
|
|
166
|
+
{ label: "Download your copy", onClick: () => downloadTabCopy(inst) },
|
|
167
|
+
],
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const showReplaced = (inst) => stopWithNotice(inst, REPLACED_TEXT);
|
|
172
|
+
const showTooLarge = (inst) => stopWithNotice(inst, TOO_LARGE_TEXT);
|
|
173
|
+
|
|
174
|
+
function applySaveState(inst, state, code, message) {
|
|
175
|
+
if (state === "saved") showSaved(inst);
|
|
176
|
+
else if (state === "retrying") armRetry(inst, code, message);
|
|
177
|
+
else if (state === "blocked") showBlocked(inst, code, message);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function listen(inst, type, handler) {
|
|
181
|
+
document.addEventListener(type, handler);
|
|
182
|
+
inst.listeners.push([type, handler]);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function listenForSaves(inst) {
|
|
186
|
+
listen(inst, "clay:save-error", (event) => {
|
|
187
|
+
const code = event.detail?.code ?? null;
|
|
188
|
+
applySaveState(inst, saveStateFor({ code }), code, event.detail?.msg ?? "");
|
|
189
|
+
});
|
|
190
|
+
listen(inst, "clay:save-offline", (event) => {
|
|
191
|
+
const code = event.detail?.code ?? null;
|
|
192
|
+
applySaveState(inst, saveStateFor({ code, offline: true }), code, event.detail?.msg ?? "");
|
|
193
|
+
});
|
|
194
|
+
listen(inst, "clay:save-conflict", (event) => {
|
|
195
|
+
const code = event.detail?.code ?? null;
|
|
196
|
+
applySaveState(inst, saveStateFor({ code }), code, event.detail?.msg ?? "");
|
|
197
|
+
});
|
|
198
|
+
listen(inst, "clay:save-saved", (event) => {
|
|
199
|
+
applySaveState(inst, saveStateFor({ ok: true }), null, event.detail?.msg ?? "");
|
|
200
|
+
inst.savedRev = savedRevOf(inst);
|
|
201
|
+
inst.adoption?.saved(getLastSavedBytes());
|
|
202
|
+
});
|
|
203
|
+
listen(inst, "clay:live-notice", (event) => {
|
|
204
|
+
if (event.detail?.name !== "page") return;
|
|
205
|
+
if (event.detail?.code === "op-too-large") return;
|
|
206
|
+
if (event.detail?.code === "unknown-store") {
|
|
207
|
+
showReplaced(inst);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const code = event.detail?.code ?? null;
|
|
211
|
+
applySaveState(inst, saveStateFor({ code }), code, event.detail?.message ?? "");
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function restoreArticle(inst) {
|
|
216
|
+
const article = inst.article;
|
|
217
|
+
if (!article) return;
|
|
218
|
+
let html = inst.bootHtml;
|
|
219
|
+
let rev = inst.bootRev;
|
|
220
|
+
if (inst.store) {
|
|
221
|
+
try {
|
|
222
|
+
const rendered = renderFromTags([JSON.stringify(inst.store.value)]);
|
|
223
|
+
html = rendered.html;
|
|
224
|
+
rev = rendered.rev;
|
|
225
|
+
} catch (error) {
|
|
226
|
+
console.error("clayjs: page plugin could not render the saved article", error);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
article.innerHTML = html;
|
|
230
|
+
if (rev == null) article.removeAttribute("data-rev");
|
|
231
|
+
else article.setAttribute("data-rev", rev);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function dispose() {
|
|
235
|
+
generation += 1;
|
|
236
|
+
const inst = active;
|
|
237
|
+
active = null;
|
|
238
|
+
if (!inst) return;
|
|
239
|
+
if (inst.retryTimer) {
|
|
240
|
+
clearTimeout(inst.retryTimer);
|
|
241
|
+
inst.retryTimer = null;
|
|
242
|
+
}
|
|
243
|
+
for (const [type, handler] of inst.listeners) document.removeEventListener(type, handler);
|
|
244
|
+
inst.listeners = [];
|
|
245
|
+
dismissPersistent(RETRY_TEXT);
|
|
246
|
+
dismissPersistent(BLOCKED_TEXT);
|
|
247
|
+
dismissPersistent(REPLACED_TEXT);
|
|
248
|
+
if (inst.savesHeld) {
|
|
249
|
+
inst.savesHeld = false;
|
|
250
|
+
releaseAllSaves({ replay: false, reason: REPLACED_HOLD });
|
|
251
|
+
}
|
|
252
|
+
try { inst.adoption?.dispose(); } catch (error) { console.error("clayjs: page plugin adoption failed to dispose", error); }
|
|
253
|
+
try { inst.recover?.destroy(); } catch (error) { console.error("clayjs: page plugin recover list failed to dispose", error); }
|
|
254
|
+
try { inst.view?.destroy(); } catch (error) { console.error("clayjs: page plugin view failed to destroy", error); }
|
|
255
|
+
inst.aiTool?.dispose();
|
|
256
|
+
inst.toolHost?.dispose();
|
|
257
|
+
if (inst.article) {
|
|
258
|
+
const shell = inst.article.querySelector(".page-shell");
|
|
259
|
+
if (shell) shell.remove();
|
|
260
|
+
}
|
|
261
|
+
try { inst.binding?.dispose(); } catch (error) { console.error("clayjs: page plugin binding failed to dispose", error); }
|
|
262
|
+
try { inst.binding?.ydoc?.destroy(); } catch (error) { console.error("clayjs: page plugin document failed to destroy", error); }
|
|
263
|
+
restoreArticle(inst);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function run(inst, gen, options = {}) {
|
|
267
|
+
const articles = document.querySelectorAll(ARTICLE_SEL);
|
|
268
|
+
let tags = document.querySelectorAll(TAG_SEL);
|
|
269
|
+
if (!articles.length && !tags.length) return abandon(inst);
|
|
270
|
+
// A page saved without its merge state (HTML Clay, an outside edit, a download)
|
|
271
|
+
// is booted from its article alone: the seed is deterministic, so every tab
|
|
272
|
+
// rebuilding the same article holds the same state and their edits merge.
|
|
273
|
+
const seeded = articles.length === 1 && tags.length === 0;
|
|
274
|
+
if (seeded) {
|
|
275
|
+
const saved = readFile(document);
|
|
276
|
+
if (!saved) return abandon(inst);
|
|
277
|
+
const tag = document.createElement("script");
|
|
278
|
+
tag.setAttribute("type", "application/json");
|
|
279
|
+
tag.setAttribute("merge", "page");
|
|
280
|
+
tag.setAttribute("clay", "live");
|
|
281
|
+
tag.textContent = serializeLive(seedPageFromArticle(saved.article));
|
|
282
|
+
articles[0].before(tag);
|
|
283
|
+
tags = document.querySelectorAll(TAG_SEL);
|
|
284
|
+
}
|
|
285
|
+
if (articles.length !== 1 || tags.length !== 1) {
|
|
286
|
+
console.warn(`clayjs: page plugin needs exactly one article[data-page] and one merge="page" live tag; found ${articles.length} article(s) and ${tags.length} tag(s), leaving the page as saved HTML`);
|
|
287
|
+
return abandon(inst);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const article = articles[0];
|
|
291
|
+
const file = readFile(document);
|
|
292
|
+
if (!file) return abandon(inst);
|
|
293
|
+
let value = null;
|
|
294
|
+
try { value = HyperMorph.parseJsonRelaxed(tags[0].textContent); } catch (error) { value = null; }
|
|
295
|
+
if (!isPlainObject(value) || value.v !== 1 || !isPlainObject(value.y) || Object.hasOwn(value, "blocks") || Object.hasOwn(value, "deleted")) {
|
|
296
|
+
console.warn("clayjs: page plugin: the page tag is not a strict Yjs page, leaving the page as saved HTML");
|
|
297
|
+
return abandon(inst);
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
blocksFromYValue(value);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
console.warn("clayjs: page plugin: the page tag is not a valid Yjs page, leaving the page as saved HTML", error);
|
|
303
|
+
return abandon(inst);
|
|
304
|
+
}
|
|
305
|
+
const source = { article: file.article, tags: [value] };
|
|
306
|
+
|
|
307
|
+
let meta = null;
|
|
308
|
+
try { meta = await hostMeta(); } catch (error) { meta = null; }
|
|
309
|
+
if (gen !== generation) return;
|
|
310
|
+
if (!meta || !Array.isArray(meta.extensions) || !meta.extensions.includes("page")) {
|
|
311
|
+
showBlocked(inst, BLOCKED.code, BLOCKED.message);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// The room was opened from a document that had no page tag, so it holds no page
|
|
316
|
+
// store and would answer this tab's frames with unknown-store. One conditional
|
|
317
|
+
// save of the document with the tag is the page's initial write; a tab that
|
|
318
|
+
// loses that race reloads, which composes the state the winner initialized.
|
|
319
|
+
inst.mergeTagless = !meta.extensions.includes("live-data");
|
|
320
|
+
let initialSave = false;
|
|
321
|
+
if (seeded && (await hostSupports("live-data"))) {
|
|
322
|
+
if (gen !== generation) return;
|
|
323
|
+
const result = await savePageForce();
|
|
324
|
+
if (gen !== generation) return;
|
|
325
|
+
if (!result?.ok) {
|
|
326
|
+
if (result?.status === 412) {
|
|
327
|
+
window.location.reload();
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
showBlocked(inst, result?.code ?? null, result?.msg ?? "");
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
initialSave = true;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
let handle;
|
|
337
|
+
try {
|
|
338
|
+
handle = window.clay.live("page");
|
|
339
|
+
} catch (error) {
|
|
340
|
+
console.warn("clayjs: page plugin could not open the page store", error);
|
|
341
|
+
return abandon(inst);
|
|
342
|
+
}
|
|
343
|
+
await handle.ready;
|
|
344
|
+
if (gen !== generation) return;
|
|
345
|
+
const store = storeOf("page");
|
|
346
|
+
if (!store) return abandon(inst);
|
|
347
|
+
inst.store = store;
|
|
348
|
+
|
|
349
|
+
let flushFailed = false;
|
|
350
|
+
let registered = null;
|
|
351
|
+
const trackFlush = async (call) => {
|
|
352
|
+
const result = await call();
|
|
353
|
+
flushFailed = result?.ok === false;
|
|
354
|
+
registered?.changed();
|
|
355
|
+
return result;
|
|
356
|
+
};
|
|
357
|
+
const identity = authorIdentity();
|
|
358
|
+
const binding = createYPageBinding({
|
|
359
|
+
store,
|
|
360
|
+
onStopped: (reason) => {
|
|
361
|
+
inst.tabValue = yValueOf(binding.ydoc);
|
|
362
|
+
if (reason === "lineage") {
|
|
363
|
+
fastLane()?.abandon?.("page");
|
|
364
|
+
showReplaced(inst);
|
|
365
|
+
} else {
|
|
366
|
+
showTooLarge(inst);
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
authorToken: identity.token,
|
|
370
|
+
ownedTokens: identity.owned,
|
|
371
|
+
checkpointWithin,
|
|
372
|
+
requestCheckpoint,
|
|
373
|
+
registerUnsavedState: (def) => {
|
|
374
|
+
registered = registerUnsavedState({
|
|
375
|
+
id: def.id,
|
|
376
|
+
isPending: () => flushFailed || def.isPending(),
|
|
377
|
+
flush: () => trackFlush(() => def.flush()),
|
|
378
|
+
});
|
|
379
|
+
return registered;
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
inst.binding = binding;
|
|
383
|
+
inst.flush = (options) => trackFlush(() => binding.flush(options));
|
|
384
|
+
listenForSaves(inst);
|
|
385
|
+
|
|
386
|
+
inst.source = source;
|
|
387
|
+
inst.article = article;
|
|
388
|
+
inst.bootHtml = source.article.innerHTML;
|
|
389
|
+
inst.bootRev = source.article.getAttribute("data-rev");
|
|
390
|
+
inst.savedRev = inst.bootRev;
|
|
391
|
+
listen(inst, "clay:sync-applied", (event) => {
|
|
392
|
+
if (inst.replaced) return;
|
|
393
|
+
if (!pageReplaced(event.detail?.html, inst.savedRev, inst.mergeTagless)) return;
|
|
394
|
+
showReplaced(inst);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// A tool written into the article by hand or by an agent arrives without the
|
|
398
|
+
// store its state lives in, and without one the tool could not keep its state:
|
|
399
|
+
// the page gets the empty store an editor-made page starts with.
|
|
400
|
+
const TOOLS_SEL = 'script[type="application/json"][merge="page.tools"][clay~="live"]';
|
|
401
|
+
if (!document.querySelector(TOOLS_SEL) && article.querySelector('section[data-type="tool"]')) {
|
|
402
|
+
const tools = document.createElement("script");
|
|
403
|
+
tools.setAttribute("type", "application/json");
|
|
404
|
+
tools.setAttribute("merge", "page.tools");
|
|
405
|
+
tools.setAttribute("clay", "live");
|
|
406
|
+
tools.textContent = "{}";
|
|
407
|
+
document.head.appendChild(tools);
|
|
408
|
+
}
|
|
409
|
+
let toolsStore = null;
|
|
410
|
+
if (document.querySelector(TOOLS_SEL)) {
|
|
411
|
+
try {
|
|
412
|
+
toolsStore = window.clay.live("page.tools");
|
|
413
|
+
await toolsStore.ready;
|
|
414
|
+
} catch (error) {
|
|
415
|
+
console.warn("clayjs: page plugin could not open the tool store", error);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (gen !== generation) return;
|
|
419
|
+
let editorView = null;
|
|
420
|
+
const toolHost = createToolHost({
|
|
421
|
+
store: toolsStore, mode: "edit",
|
|
422
|
+
tables: () => tablesFromDoc(editorView?.state.doc ?? binding.doc()).map(({ id, cols, rows }) => ({ id, cols, rows })),
|
|
423
|
+
});
|
|
424
|
+
inst.toolHost = toolHost;
|
|
425
|
+
const transport = window.clay?.wire?.pageTool ?? options.transport ?? null;
|
|
426
|
+
if (capabilityAdvertised(meta)) {
|
|
427
|
+
inst.aiTool = createAiTool({ getView: () => inst.view, store: toolsStore, transport, acceptState: options.acceptState });
|
|
428
|
+
}
|
|
429
|
+
article.innerHTML = "";
|
|
430
|
+
const view = createPageEditor(article, {
|
|
431
|
+
doc: binding.doc(),
|
|
432
|
+
dispatch: binding.dispatch,
|
|
433
|
+
history: false,
|
|
434
|
+
undo: binding.undo,
|
|
435
|
+
redo: binding.redo,
|
|
436
|
+
board: binding.board,
|
|
437
|
+
editable: false,
|
|
438
|
+
toolHost,
|
|
439
|
+
onAiTool: inst.aiTool ? (_view, remove) => inst.aiTool.open({ remove }) : null,
|
|
440
|
+
reviseTool: inst.aiTool ? (toolId, rev) => inst.aiTool.open({ target: { toolId, rev } }) : null,
|
|
441
|
+
});
|
|
442
|
+
editorView = view;
|
|
443
|
+
inst.view = view;
|
|
444
|
+
binding.attach(view);
|
|
445
|
+
scrollToHashBlock(view);
|
|
446
|
+
inst.recover = createRecoverList({
|
|
447
|
+
binding,
|
|
448
|
+
mount: view.dom.closest(".page-shell") ?? article,
|
|
449
|
+
ownTokens: [identity.token, ...identity.owned],
|
|
450
|
+
onRestored: (id) => focusBlock(view, id),
|
|
451
|
+
onStale: () => toast("That change was updated. The list now shows the latest version.", "warning"),
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
// A hand-edited article is adopted by the room's elected writer only. With a
|
|
455
|
+
// lane but no writer yet, this tab claims the room (a redundant set of an
|
|
456
|
+
// existing Yjs update: a valid nonempty frame that changes nothing) and then
|
|
457
|
+
// waits for the election, so exactly one tab adopts.
|
|
458
|
+
const needsAdoption = classifyFile(source.article, source.tags).kind === "adopt";
|
|
459
|
+
const awaitWriter = async () => {
|
|
460
|
+
const lane = await pageLaneReady();
|
|
461
|
+
if (!lane) return;
|
|
462
|
+
if (gen !== generation) return;
|
|
463
|
+
if (lane.status.writer === null) {
|
|
464
|
+
const y = store.value.y;
|
|
465
|
+
const keys = Object.keys(y);
|
|
466
|
+
const key = keys.length ? keys[0] : `claim-${binding.ydoc.clientID.toString(36)}`;
|
|
467
|
+
const value = keys.length ? y[keys[0]] : yValueOf(binding.ydoc).y.seed;
|
|
468
|
+
store.handle.set(["y", key], value);
|
|
469
|
+
}
|
|
470
|
+
// A lane that never reports a writer must not cost the hand edit: give up
|
|
471
|
+
// before adoption's own bound, and adopt as the file-mode tab would.
|
|
472
|
+
let timer;
|
|
473
|
+
await Promise.race([pageWriterReady(), new Promise((resolve) => { timer = setTimeout(resolve, WRITER_WAIT_MS); })]);
|
|
474
|
+
clearTimeout(timer);
|
|
475
|
+
};
|
|
476
|
+
const ready = needsAdoption ? awaitWriter() : Promise.resolve();
|
|
477
|
+
|
|
478
|
+
const adoption = installAdoption({
|
|
479
|
+
view,
|
|
480
|
+
source,
|
|
481
|
+
ready,
|
|
482
|
+
isAdopter: () => {
|
|
483
|
+
const lane = fastLane();
|
|
484
|
+
return !lane || lane.status.writer === null ? true : lane.isWriter();
|
|
485
|
+
},
|
|
486
|
+
requestCheckpoint: () => checkpointWithin(0),
|
|
487
|
+
snapshot: () => captureForSaveAndComparison().forSave,
|
|
488
|
+
mergeTagless: inst.mergeTagless,
|
|
489
|
+
writerReady: awaitWriter,
|
|
490
|
+
adopted: (rev) => fastLane()?.adopted?.("page", rev, store.mark),
|
|
491
|
+
notice: (counts) => {
|
|
492
|
+
if (!counts.copy) return;
|
|
493
|
+
toastPersistent(OVERWRITTEN_TEXT, "warning", {
|
|
494
|
+
actions: [{ label: "Download your copy", onClick: () => downloadSnapshot(counts.copy) }],
|
|
495
|
+
});
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
inst.adoption = adoption;
|
|
499
|
+
|
|
500
|
+
await adoption.idle();
|
|
501
|
+
if (gen !== generation) return;
|
|
502
|
+
// A replacement that landed while the page was still booting keeps the editor
|
|
503
|
+
// read-only: the notice is already up, and this tab must not edit the old article.
|
|
504
|
+
if (inst.replaced) {
|
|
505
|
+
stopEditing(inst);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
view.setProps({ editable: () => true });
|
|
509
|
+
|
|
510
|
+
// That initial write advanced the saved baselines from a capture taken before
|
|
511
|
+
// the editor mounted, which is what the load-time settle would otherwise absorb
|
|
512
|
+
// boot churn into. Install them once more from the booted page, so a tab that
|
|
513
|
+
// was opened and left alone does not read as unsaved.
|
|
514
|
+
if (initialSave) {
|
|
515
|
+
const { forComparison, forDirty } = captureForComparisonAndDirty();
|
|
516
|
+
setLastSavedBaselines(forComparison, forDirty);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function install(options = {}) {
|
|
521
|
+
dispose();
|
|
522
|
+
const gen = ++generation;
|
|
523
|
+
const inst = { gen, article: null, view: null, binding: null, adoption: null, source: null, store: null, toolHost: null, aiTool: null, bootHtml: "", bootRev: null, savedRev: null, replaced: false, savesHeld: false, flush: null, listeners: [], retryTimer: null, retryShown: false };
|
|
524
|
+
active = inst;
|
|
525
|
+
return run(inst, gen, options).catch((error) => {
|
|
526
|
+
console.error("clayjs: page plugin failed to install", error);
|
|
527
|
+
if (active === inst) dispose();
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export const page = {
|
|
532
|
+
ready: null,
|
|
533
|
+
install,
|
|
534
|
+
flush(options = {}) {
|
|
535
|
+
const inst = active;
|
|
536
|
+
if (!inst || !inst.flush) return Promise.resolve({ ok: false, code: "disposed" });
|
|
537
|
+
return inst.flush(options);
|
|
538
|
+
},
|
|
539
|
+
dispose,
|
|
540
|
+
get view() {
|
|
541
|
+
return active?.view ?? null;
|
|
542
|
+
},
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
page.ready = install();
|
|
546
|
+
|
|
547
|
+
export default page;
|
package/src/plugins/undo.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { undo } from '../vendor/hyper-undo.vendor.js'
|
|
7
7
|
import { isEditMode } from '../core/is-edit-mode.js'
|
|
8
8
|
import { isTabLocalAttr } from '../lib/root-attrs.js'
|
|
9
|
+
import { PERSIST } from '../lib/attr-aliases.js'
|
|
9
10
|
|
|
10
11
|
// Tab-local attributes (ephemeral state, route-active, root library state) are
|
|
11
12
|
// never undone. The shared mutation hub already drops them for the main
|
|
@@ -21,18 +22,74 @@ const withTabLocal = (opts = {}) => {
|
|
|
21
22
|
const createScope = undo.create.bind(undo)
|
|
22
23
|
undo.create = (opts) => createScope(withTabLocal(opts))
|
|
23
24
|
|
|
25
|
+
// A commit inside clay.effect drains the paused hub's records, so the effect's
|
|
26
|
+
// own writes reach autosave and re-trigger it. Warn once; the commit still runs.
|
|
27
|
+
const commit = undo.commit.bind(undo)
|
|
28
|
+
let warnedEffectCommit = false
|
|
29
|
+
undo.commit = (label, fn) => {
|
|
30
|
+
if (!warnedEffectCommit && window.clay?.effect?.running) {
|
|
31
|
+
warnedEffectCommit = true
|
|
32
|
+
console.warn(`clay.effect: undo.commit("${label}") was called inside an effect. Run commands from event handlers, not from an effect.`)
|
|
33
|
+
}
|
|
34
|
+
return commit(label, fn)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Undo and redo restore attributes. A control the user has touched shows its
|
|
38
|
+
// live property instead (the WHATWG dirty flag), so copy the attributes back, as
|
|
39
|
+
// a sync merge does. Undo is the user's own request, so the focused field is
|
|
40
|
+
// restored too, with its selection clamped to the new length. Controls persist
|
|
41
|
+
// never writes (password, hidden, file) and regions undo does not record are
|
|
42
|
+
// left alone.
|
|
43
|
+
const CONTROLS = `input${PERSIST}:not([type="password"]):not([type="hidden"]):not([type="file"]), textarea${PERSIST}, select${PERSIST}`
|
|
44
|
+
|
|
45
|
+
function restoreControls() {
|
|
46
|
+
const undoable = (el) => window.clay?.region?.resolveRegionPolicy?.(el)?.undoable !== false
|
|
47
|
+
document.querySelectorAll(CONTROLS).forEach((el) => {
|
|
48
|
+
if (!undoable(el)) return
|
|
49
|
+
if (el.tagName === 'SELECT') {
|
|
50
|
+
for (const opt of el.options) {
|
|
51
|
+
const want = opt.hasAttribute('selected')
|
|
52
|
+
if (opt.selected !== want) opt.selected = want
|
|
53
|
+
}
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
const type = (el.getAttribute('type') || 'text').toLowerCase()
|
|
57
|
+
if (el.tagName === 'INPUT' && (type === 'checkbox' || type === 'radio')) {
|
|
58
|
+
const want = el.hasAttribute('checked')
|
|
59
|
+
if (el.checked !== want) el.checked = want
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
const want = el.tagName === 'TEXTAREA'
|
|
63
|
+
? (el.getAttribute('data-value') ?? el.textContent)
|
|
64
|
+
: (el.getAttribute('value') ?? '')
|
|
65
|
+
if (el.value === want) return
|
|
66
|
+
const focused = el === document.activeElement
|
|
67
|
+
let start, end
|
|
68
|
+
try { start = el.selectionStart; end = el.selectionEnd } catch {}
|
|
69
|
+
el.value = want
|
|
70
|
+
if (focused && start != null) {
|
|
71
|
+
try { el.setSelectionRange(Math.min(start, want.length), Math.min(end, want.length)) } catch {}
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
24
76
|
function init() {
|
|
25
77
|
if (!isEditMode) return
|
|
26
78
|
// Defer until the body exists so the default scope (document.body) is valid.
|
|
27
79
|
if (typeof document === 'undefined') return
|
|
80
|
+
const start = () => {
|
|
81
|
+
undo.start(withTabLocal({ bindKeys: true }))
|
|
82
|
+
undo.on('undo', restoreControls)
|
|
83
|
+
undo.on('redo', restoreControls)
|
|
84
|
+
}
|
|
28
85
|
if (document.readyState === 'loading') {
|
|
29
|
-
document.addEventListener('DOMContentLoaded',
|
|
86
|
+
document.addEventListener('DOMContentLoaded', start)
|
|
30
87
|
} else {
|
|
31
|
-
|
|
88
|
+
start()
|
|
32
89
|
}
|
|
33
90
|
}
|
|
34
91
|
|
|
35
92
|
init()
|
|
36
93
|
|
|
37
|
-
export { undo }
|
|
94
|
+
export { undo, restoreControls }
|
|
38
95
|
export default undo
|
|
@@ -20,7 +20,7 @@ const ASIDE = `<aside class="hcp" id="hcp" hidden aria-label="Download this app"
|
|
|
20
20
|
</section>
|
|
21
21
|
<div class="hcp-dock">
|
|
22
22
|
<button type="button" class="hcp-btn hcp-tab" data-hcp="open" aria-controls="hcp-card" aria-expanded="false" aria-label="This app is free to keep"><span><svg width="16" height="16" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M224,144v64a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V144a8,8,0,0,1,16,0v56H208V144a8,8,0,0,1,16,0Zm-101.66,5.66a8,8,0,0,0,11.32,0l40-40a8,8,0,0,0-11.32-11.32L136,124.69V32a8,8,0,0,0-16,0v92.69L93.66,98.34a8,8,0,0,0-11.32,11.32Z"/></svg><span class="hcp-tab__label">This app is free to keep</span></span></button>
|
|
23
|
-
<button type="button" class="hcp-x" data-hcp="
|
|
23
|
+
<button type="button" class="hcp-x" data-hcp="minimize" aria-label="Minimize download notice"><svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true"><path d="M1.4 0 6 4.6 10.6 0 12 1.4 7.4 6 12 10.6 10.6 12 6 7.4 1.4 12 0 10.6 4.6 6 0 1.4z"/></svg></button>
|
|
24
24
|
</div>
|
|
25
25
|
</aside>`;
|
|
26
26
|
|
|
@@ -50,7 +50,7 @@ export function mountPrompt({ memory, appName, link }) {
|
|
|
50
50
|
root.setAttribute("data-hcp-state", state);
|
|
51
51
|
if (reason) root.setAttribute("data-hcp-reason", reason);
|
|
52
52
|
tab.setAttribute("aria-expanded", state === "card" ? "true" : "false");
|
|
53
|
-
if (state === "card") { mem.shown = true; memory.save(); root.setAttribute("data-hcp-arrived", ""); }
|
|
53
|
+
if (state === "card" || state === "mini") { mem.shown = true; memory.save(); root.setAttribute("data-hcp-arrived", ""); }
|
|
54
54
|
lift();
|
|
55
55
|
}
|
|
56
56
|
|
|
@@ -126,7 +126,7 @@ export function mountPrompt({ memory, appName, link }) {
|
|
|
126
126
|
else if (act === "minimize") { set("mini"); tab.focus(); }
|
|
127
127
|
else if (act === "dismiss") {
|
|
128
128
|
if (root.getAttribute("data-hcp-reason") === "error") { set("mini"); tab.focus(); }
|
|
129
|
-
else { mem.dismissed = true;
|
|
129
|
+
else { mem.dismissed = true; set("mini"); tab.focus(); }
|
|
130
130
|
}
|
|
131
131
|
return;
|
|
132
132
|
}
|
|
@@ -151,8 +151,6 @@ export function mountPrompt({ memory, appName, link }) {
|
|
|
151
151
|
}
|
|
152
152
|
const api = { set, root, showError, clearError };
|
|
153
153
|
|
|
154
|
-
if (mem.dismissed) { root.setAttribute("data-hcp-state", "dismissed"); return api; }
|
|
155
|
-
|
|
156
154
|
document.addEventListener("input", onUse, true);
|
|
157
155
|
document.addEventListener("change", onUse, true);
|
|
158
156
|
document.addEventListener("focusin", onFocusIn);
|
|
@@ -161,7 +159,7 @@ export function mountPrompt({ memory, appName, link }) {
|
|
|
161
159
|
addEventListener("resize", lift);
|
|
162
160
|
document.addEventListener("transitionend", lift, true);
|
|
163
161
|
|
|
164
|
-
set(mem.shown ? "mini" : "tab");
|
|
162
|
+
set(mem.shown || mem.dismissed ? "mini" : "tab");
|
|
165
163
|
if (!mem.shown && (mem.uses || 0) >= USES_TO_SHOW) maybeShow("return");
|
|
166
164
|
|
|
167
165
|
return api;
|