@3sln/trove 0.0.2
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 +1227 -0
- package/package.json +75 -0
- package/packages/core/src/collections/index.js +249 -0
- package/packages/core/src/errors.js +186 -0
- package/packages/core/src/identity/discovery.js +210 -0
- package/packages/core/src/identity/index.js +188 -0
- package/packages/core/src/identity/jwt.js +199 -0
- package/packages/core/src/index.js +104 -0
- package/packages/core/src/indexers/contribution.js +115 -0
- package/packages/core/src/indexers/registry.js +162 -0
- package/packages/core/src/indexing.js +340 -0
- package/packages/core/src/issues.js +150 -0
- package/packages/core/src/kv.js +0 -0
- package/packages/core/src/links.js +141 -0
- package/packages/core/src/metadata/cursor.js +73 -0
- package/packages/core/src/metadata/interface.js +244 -0
- package/packages/core/src/metadata/memory.js +270 -0
- package/packages/core/src/metadata/sqlite.js +412 -0
- package/packages/core/src/notifications/index.js +139 -0
- package/packages/core/src/notifications/webpush.js +217 -0
- package/packages/core/src/plugins/contributions.js +177 -0
- package/packages/core/src/plugins/identity.js +98 -0
- package/packages/core/src/plugins/index.js +225 -0
- package/packages/core/src/plugins/indexers.js +142 -0
- package/packages/core/src/plugins/installStore.js +134 -0
- package/packages/core/src/plugins/package.js +102 -0
- package/packages/core/src/plugins/packageStore.js +61 -0
- package/packages/core/src/plugins/runtime.js +101 -0
- package/packages/core/src/plugins/sql.js +52 -0
- package/packages/core/src/retry.js +74 -0
- package/packages/core/src/scan.js +302 -0
- package/packages/core/src/search/embeddings.js +128 -0
- package/packages/core/src/search/index.js +200 -0
- package/packages/core/src/search/keywordStore.js +107 -0
- package/packages/core/src/search/sqliteStores.js +455 -0
- package/packages/core/src/search/tagMatch.js +59 -0
- package/packages/core/src/search/transformer.js +195 -0
- package/packages/core/src/search/vectorStore.js +274 -0
- package/packages/core/src/search/vectorize.js +249 -0
- package/packages/core/src/sidecar/document.js +213 -0
- package/packages/core/src/sidecar/index.js +174 -0
- package/packages/core/src/sidecar/manager.js +239 -0
- package/packages/core/src/sidecar/store.js +46 -0
- package/packages/core/src/signedUrls.js +170 -0
- package/packages/core/src/sqlite-d1.js +162 -0
- package/packages/core/src/sqlite-driver.js +42 -0
- package/packages/core/src/sqlite.js +162 -0
- package/packages/core/src/storage/filesystem.js +283 -0
- package/packages/core/src/storage/interface.js +222 -0
- package/packages/core/src/storage/memory.js +113 -0
- package/packages/core/src/storage/prefixed.js +75 -0
- package/packages/core/src/storage/s3.js +316 -0
- package/packages/core/src/storage/s3sigv4.js +185 -0
- package/packages/core/src/tasks.js +228 -0
- package/packages/core/src/uploads.js +386 -0
- package/packages/core/src/util.js +125 -0
- package/packages/core/src/vfs.js +666 -0
- package/packages/plugin-sdk/src/browser.js +316 -0
- package/packages/plugin-sdk/src/index.js +32 -0
- package/packages/plugin-sdk/src/protocol.js +59 -0
- package/packages/plugin-sdk/src/rpc.js +95 -0
- package/packages/server/src/adapters/bun.js +78 -0
- package/packages/server/src/adapters/node.js +115 -0
- package/packages/server/src/adapters/staticAssets.js +123 -0
- package/packages/server/src/adapters/webDist.js +70 -0
- package/packages/server/src/adapters/worker-tasks.js +206 -0
- package/packages/server/src/adapters/worker.js +159 -0
- package/packages/server/src/cachePolicy.js +34 -0
- package/packages/server/src/engine/README.md +88 -0
- package/packages/server/src/engine/actions/scanCollection.js +114 -0
- package/packages/server/src/engine/index.js +95 -0
- package/packages/server/src/engine/lazy.js +25 -0
- package/packages/server/src/engine/providers/access.js +363 -0
- package/packages/server/src/engine/providers/core.js +405 -0
- package/packages/server/src/engine/providers/scan.js +67 -0
- package/packages/server/src/index.js +698 -0
- package/packages/server/src/manifest.js +98 -0
- package/packages/server/src/mcp/auth.js +40 -0
- package/packages/server/src/mcp/index.js +213 -0
- package/packages/server/src/mcp/protocol.js +181 -0
- package/packages/server/src/mcp/tools.js +351 -0
- package/packages/server/src/router.js +229 -0
- package/packages/server/src/routes.js +1066 -0
- package/packages/server/src/scope.js +43 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
- package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
- package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
- package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
- package/packages/web/dist/icon.svg +11 -0
- package/packages/web/dist/index.html +16 -0
- package/packages/web/dist/sql-wasm.wasm +0 -0
- package/packages/web/dist/sw.js +186 -0
- package/packages/web/src/bl/actions.js +410 -0
- package/packages/web/src/bl/activity.js +306 -0
- package/packages/web/src/bl/commands.js +274 -0
- package/packages/web/src/bl/fileType.js +49 -0
- package/packages/web/src/bl/index.js +70 -0
- package/packages/web/src/bl/links.js +54 -0
- package/packages/web/src/bl/offline.js +268 -0
- package/packages/web/src/bl/openers.js +71 -0
- package/packages/web/src/bl/pluginInstall.js +59 -0
- package/packages/web/src/bl/services.js +143 -0
- package/packages/web/src/bl/social.js +234 -0
- package/packages/web/src/bl/tagQuery.js +44 -0
- package/packages/web/src/main.js +10 -0
- package/packages/web/src/platform/api.js +529 -0
- package/packages/web/src/platform/commands.js +89 -0
- package/packages/web/src/platform/context.js +77 -0
- package/packages/web/src/platform/contributions.js +156 -0
- package/packages/web/src/platform/index.js +150 -0
- package/packages/web/src/platform/keybindings.js +199 -0
- package/packages/web/src/platform/mediaUrls.js +137 -0
- package/packages/web/src/platform/navigation.js +131 -0
- package/packages/web/src/platform/notifications.js +50 -0
- package/packages/web/src/platform/overlay.js +81 -0
- package/packages/web/src/platform/pluginClientDb.js +132 -0
- package/packages/web/src/platform/pluginDock.js +141 -0
- package/packages/web/src/platform/pluginFrames.js +194 -0
- package/packages/web/src/platform/pluginHost.js +648 -0
- package/packages/web/src/platform/pluginMedia.js +62 -0
- package/packages/web/src/platform/pluginModules.js +90 -0
- package/packages/web/src/platform/pluginNet.js +71 -0
- package/packages/web/src/platform/pluginPackage.js +247 -0
- package/packages/web/src/platform/pluginRpc.js +377 -0
- package/packages/web/src/platform/pluginSigning.js +168 -0
- package/packages/web/src/platform/pluginStore.js +67 -0
- package/packages/web/src/platform/settings.js +101 -0
- package/packages/web/src/platform/spatialNav.js +286 -0
- package/packages/web/src/platform/viewport.js +123 -0
- package/packages/web/src/platform/voice.js +133 -0
- package/packages/web/src/platform/voiceSearch.js +155 -0
- package/packages/web/src/platform/whenclause.js +162 -0
- package/packages/web/src/platform/workbench.js +156 -0
- package/packages/web/src/runtime.js +73 -0
- package/packages/web/src/styles.css +1382 -0
- package/packages/web/src/ui/components/activityBar.js +35 -0
- package/packages/web/src/ui/components/activityPanel.js +132 -0
- package/packages/web/src/ui/components/commandPalette.js +154 -0
- package/packages/web/src/ui/components/editorArea.js +75 -0
- package/packages/web/src/ui/components/launcher.js +392 -0
- package/packages/web/src/ui/components/openers/index.js +212 -0
- package/packages/web/src/ui/components/openers/markdown.js +222 -0
- package/packages/web/src/ui/components/overlays.js +255 -0
- package/packages/web/src/ui/components/phoneChrome.js +188 -0
- package/packages/web/src/ui/components/pluginReview.js +151 -0
- package/packages/web/src/ui/components/pluginsView.js +120 -0
- package/packages/web/src/ui/components/settingsView.js +258 -0
- package/packages/web/src/ui/components/social.js +290 -0
- package/packages/web/src/ui/components/statusBar.js +198 -0
- package/packages/web/src/ui/components/views/grid.js +115 -0
- package/packages/web/src/ui/components/views/index.js +155 -0
- package/packages/web/src/ui/components/views/list.js +50 -0
- package/packages/web/src/ui/components/views/parts.js +58 -0
- package/packages/web/src/ui/compositions/workbench.js +125 -0
- package/packages/web/src/ui/format.js +33 -0
- package/packages/web/src/ui/icon.js +81 -0
- package/packages/web/src/ui/media.js +114 -0
- package/packages/web/src/ui/sanitize.js +86 -0
- package/packages/web/src/workbench.js +205 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// How a list of items is DRAWN.
|
|
2
|
+
//
|
|
3
|
+
// An opener renders one file; a view renders the results. They register through the
|
|
4
|
+
// same contribution system, so "a gallery for a collection full of photographs" is a
|
|
5
|
+
// contribution rather than an edit to the launcher — which is the whole point: the
|
|
6
|
+
// launcher should not have to know how many ways there are to look at a drive.
|
|
7
|
+
//
|
|
8
|
+
// A view is the workbench's own: the built-ins, plus whatever a build passes to
|
|
9
|
+
// `createWorkbench({ views })`. It runs in the host, so the contract is a dodo vnode.
|
|
10
|
+
//
|
|
11
|
+
// The contract is one function:
|
|
12
|
+
//
|
|
13
|
+
// render({ groups, index, handlers, state, ui }) -> vnode
|
|
14
|
+
//
|
|
15
|
+
// `groups` is what the launcher assembled — `[{ title, verbatim?, action?, items,
|
|
16
|
+
// empty? }]`, each item `{ icon, title, detail, badge, node?, run() }`. `index` is the
|
|
17
|
+
// flat position of the highlighted item across every group, because keyboard
|
|
18
|
+
// navigation is the launcher's job and not the view's: up and down must mean the same
|
|
19
|
+
// thing whichever way the results happen to be drawn.
|
|
20
|
+
|
|
21
|
+
import { dd } from '../../../runtime.js';
|
|
22
|
+
import { icon } from '../../icon.js';
|
|
23
|
+
import { listView } from './list.js';
|
|
24
|
+
import { gridView, gridMove } from './grid.js';
|
|
25
|
+
|
|
26
|
+
const { div, button, span } = dd;
|
|
27
|
+
|
|
28
|
+
const BUILT_IN = {
|
|
29
|
+
'core.view.list': {
|
|
30
|
+
title: 'List', icon: 'list', priority: 50,
|
|
31
|
+
render: listView,
|
|
32
|
+
},
|
|
33
|
+
'core.view.grid': {
|
|
34
|
+
// Offered first where the results are pictures, and available everywhere.
|
|
35
|
+
title: 'Grid', icon: 'grid', priority: 40,
|
|
36
|
+
match: { mime: ['image/*', 'video/*'] },
|
|
37
|
+
render: gridView, move: gridMove,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function registerBuiltinViews(platform) {
|
|
42
|
+
for (const [name, spec] of Object.entries(BUILT_IN)) {
|
|
43
|
+
platform.contributions.register(name, { type: 'view', ...spec });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SETTING = 'explorer.view';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every view that could draw these results, best first.
|
|
51
|
+
*
|
|
52
|
+
* No availability check, unlike openers: a view is host code, so there is no "its
|
|
53
|
+
* provider isn't answering" state to be in. A when-clause is the only gate, and it is
|
|
54
|
+
* the same one every other contribution answers to.
|
|
55
|
+
*/
|
|
56
|
+
export function availableViews(platform) {
|
|
57
|
+
return platform.contributions
|
|
58
|
+
.ofType('view')
|
|
59
|
+
.filter((v) => !v.when || platform.context.evaluate(v.when))
|
|
60
|
+
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The view to draw with, in order of how much someone meant it.
|
|
65
|
+
*
|
|
66
|
+
* 1. A saved choice — they pressed a button. Nothing infers its way past that.
|
|
67
|
+
* 2. The search transformer's hint, when there is a search on screen. It read the
|
|
68
|
+
* sentence: "photos from the trip last summer" is a request for a gallery as much as
|
|
69
|
+
* it is a query, and nothing downstream can recover that from a list of content types.
|
|
70
|
+
* 3. A view whose `match` suits what is actually there — a collection of photographs
|
|
71
|
+
* opens as a grid without anyone asking.
|
|
72
|
+
* 4. The highest priority, which is the list.
|
|
73
|
+
*
|
|
74
|
+
* A hint naming a view this build doesn't have is ignored, not an error: the transformer
|
|
75
|
+
* is deployment configuration and may outlive the build it was written against.
|
|
76
|
+
*/
|
|
77
|
+
export function activeView(platform, items = [], hint = null) {
|
|
78
|
+
const views = availableViews(platform);
|
|
79
|
+
if (!views.length) return null;
|
|
80
|
+
const saved = platform.settings.get(SETTING);
|
|
81
|
+
const chosen = saved && views.find((v) => v.id === saved);
|
|
82
|
+
if (chosen) return chosen;
|
|
83
|
+
const suggested = hint && views.find((v) => v.id === hint);
|
|
84
|
+
if (suggested) return suggested;
|
|
85
|
+
const nodes = items.map((i) => i.node).filter(Boolean);
|
|
86
|
+
if (nodes.length >= 3) {
|
|
87
|
+
const suits = (v) => v.match && Object.keys(v.match).length
|
|
88
|
+
&& nodes.filter((n) => matchesView(v, n)).length / nodes.length > 0.6;
|
|
89
|
+
const fitted = views.find(suits);
|
|
90
|
+
if (fitted) return fitted;
|
|
91
|
+
}
|
|
92
|
+
return views[0];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function matchesView(view, node) {
|
|
96
|
+
const ct = node.contentType || '';
|
|
97
|
+
const name = (node.name || '').toLowerCase();
|
|
98
|
+
const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')) : '';
|
|
99
|
+
if ((view.match.ext || []).includes(ext)) return true;
|
|
100
|
+
return (view.match.mime || []).some((m) => (m.endsWith('/*') ? ct.startsWith(m.slice(0, -1)) : ct === m));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function chooseView(platform, viewId) {
|
|
104
|
+
platform.settings.set(SETTING, viewId || undefined);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The switcher, shown only when there is more than one way to look at this. */
|
|
108
|
+
export function viewSwitcher(platform, current) {
|
|
109
|
+
const views = availableViews(platform);
|
|
110
|
+
if (views.length < 2) return null;
|
|
111
|
+
return div({ className: 'view-switch' }, ...views.map((v) =>
|
|
112
|
+
button({
|
|
113
|
+
className: `vs-btn ${v.id === current?.id ? 'on' : ''}`,
|
|
114
|
+
title: `${v.title || v.name} view`,
|
|
115
|
+
'aria-pressed': v.id === current?.id ? 'true' : 'false',
|
|
116
|
+
}, icon(v.icon || 'list', { size: 14 })).on({ click: () => chooseView(platform, v.id) })));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Draw the results with `view`, falling back to the list if it throws.
|
|
121
|
+
*
|
|
122
|
+
* A view is the whole results area. One that fails takes the drive's contents off the
|
|
123
|
+
* screen with it, so a broken third-party view degrades to the built-in list rather
|
|
124
|
+
* than to nothing.
|
|
125
|
+
*/
|
|
126
|
+
/**
|
|
127
|
+
* The delta an arrow key should move the highlight by, or null for the default.
|
|
128
|
+
*
|
|
129
|
+
* Keyboard navigation stays the launcher's: it owns the index, the selection and the
|
|
130
|
+
* wrapping. But "down" means the row below in a list and the tile below in a grid, so
|
|
131
|
+
* the view is asked first and answers in the launcher's own terms.
|
|
132
|
+
*/
|
|
133
|
+
export function viewMove(view, key, ctx) {
|
|
134
|
+
try {
|
|
135
|
+
const d = view?.move?.({ key, ...ctx });
|
|
136
|
+
return Number.isFinite(d) && d !== 0 ? d : null;
|
|
137
|
+
} catch {
|
|
138
|
+
return null; // a view that can't answer doesn't get to break the arrow keys
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function renderView(view, ctx) {
|
|
143
|
+
try {
|
|
144
|
+
if (typeof view?.render === 'function') return view.render(ctx);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
console.error(`view "${view?.id}" failed`, err);
|
|
147
|
+
return div({},
|
|
148
|
+
div({ className: 'view-error' }, icon('warn', { size: 13 }),
|
|
149
|
+
span(`The “${view?.title || view?.id}” view could not be drawn — showing the list instead.`)),
|
|
150
|
+
listView(ctx));
|
|
151
|
+
}
|
|
152
|
+
return listView(ctx);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export { listView, gridView };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The list view: one row per item, which is what a drive looks like unless you ask
|
|
2
|
+
// for something else.
|
|
3
|
+
//
|
|
4
|
+
// It is also the fallback for every other view: one that throws lands here, and views
|
|
5
|
+
// are host code with no sandbox to catch them first. So it must not depend on anything
|
|
6
|
+
// beyond the contract itself — no settings, no capabilities, no network.
|
|
7
|
+
|
|
8
|
+
import { dd } from '../../../runtime.js';
|
|
9
|
+
import { icon } from '../../icon.js';
|
|
10
|
+
import { groupHeader, menuButton, openRowMenu } from './parts.js';
|
|
11
|
+
|
|
12
|
+
const { div, span } = dd;
|
|
13
|
+
|
|
14
|
+
export function listView({ groups, index, handlers, ui }) {
|
|
15
|
+
let gi = -1;
|
|
16
|
+
return div({ className: 'launch-view view-list' },
|
|
17
|
+
...groups.map((group) => div({ className: 'launch-group' },
|
|
18
|
+
groupHeader(group),
|
|
19
|
+
group.items.length
|
|
20
|
+
? div({ className: 'launch-list' }, ...group.items.map((it) => {
|
|
21
|
+
const at = ++gi;
|
|
22
|
+
return itemRow(it, at === index, {
|
|
23
|
+
hover: () => handlers.hover(at),
|
|
24
|
+
select: () => handlers.select(at),
|
|
25
|
+
}, ui);
|
|
26
|
+
}))
|
|
27
|
+
: div({ className: 'launch-empty' }, group.empty || 'Nothing here.'),
|
|
28
|
+
)),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function itemRow(it, active, { hover, select }, ui) {
|
|
33
|
+
return div({ className: `launch-item ${active ? 'active' : ''}` },
|
|
34
|
+
icon(it.icon, { size: 15 }),
|
|
35
|
+
span({ className: 'name' }, it.title),
|
|
36
|
+
it.detail ? span({ className: 'launch-detail' }, it.detail) : null,
|
|
37
|
+
it.badge ? span({ className: 'launch-kind' }, it.badge) : null,
|
|
38
|
+
// Everything you can do to a file, on the file. Rename, download, copy link and
|
|
39
|
+
// delete were all commands with no way to reach them: the palette's versions act on
|
|
40
|
+
// "the selection", and until the highlight became a selection there never was one.
|
|
41
|
+
menuButton(it, ui, select),
|
|
42
|
+
// One `.on()` call: a second replaces the handler map rather than merging into it,
|
|
43
|
+
// which is how adding `contextmenu` silently removed `click` and stopped every file
|
|
44
|
+
// in the drive from opening.
|
|
45
|
+
).on({
|
|
46
|
+
click: it.run,
|
|
47
|
+
mouseenter: hover,
|
|
48
|
+
...(it.menu ? { contextmenu: (e) => { e.preventDefault(); openRowMenu(e.currentTarget, it, ui, e, select); } } : {}),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// The pieces every view draws the same way.
|
|
2
|
+
//
|
|
3
|
+
// A view decides how results are ARRANGED. It does not get to decide what a group
|
|
4
|
+
// header says, or where the row menu opens, because those are the same promises
|
|
5
|
+
// whichever way the drive is drawn: the header carries the group's action (Upload,
|
|
6
|
+
// Empty trash, Retry) and the menu carries everything you can do to a file. A view that
|
|
7
|
+
// dropped either would quietly take working features off the screen.
|
|
8
|
+
|
|
9
|
+
import { dd } from '../../../runtime.js';
|
|
10
|
+
import { icon } from '../../icon.js';
|
|
11
|
+
|
|
12
|
+
const { div, span, button } = dd;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A group's heading.
|
|
16
|
+
*
|
|
17
|
+
* `group.title` is uppercased by CSS, which is right for a label and wrong for anything
|
|
18
|
+
* the user typed — it turns their `#draft` into `#DRAFT`, a tag that isn't what they
|
|
19
|
+
* wrote. So a group can carry a verbatim half.
|
|
20
|
+
*/
|
|
21
|
+
export function groupHeader(group) {
|
|
22
|
+
return div({ className: 'launch-h' },
|
|
23
|
+
// Label and value together on the left; `.launch-h` is space-between, so an
|
|
24
|
+
// ungrouped value gets flung to the far edge away from the label it belongs to.
|
|
25
|
+
div({ className: 'lh-title' },
|
|
26
|
+
span(group.title),
|
|
27
|
+
group.verbatim ? span({ className: 'lh-verbatim' }, group.verbatim) : null),
|
|
28
|
+
group.action || null,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The "…" button, for items that have a menu. */
|
|
33
|
+
export function menuButton(it, ui, select) {
|
|
34
|
+
if (!it.menu) return null;
|
|
35
|
+
return button({
|
|
36
|
+
className: 'launch-more',
|
|
37
|
+
title: `Actions for ${it.title}`,
|
|
38
|
+
$attrs: { 'aria-label': `Actions for ${it.title}` },
|
|
39
|
+
}, icon('dots', { size: 14 }))
|
|
40
|
+
.on({ click: (e) => { e.stopPropagation(); openRowMenu(e.currentTarget, it, ui, null, select); } });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Open an item's menu, anchored to the thing it belongs to.
|
|
45
|
+
*
|
|
46
|
+
* Anchored to the row (or the pointer, when there was one) rather than to a remembered
|
|
47
|
+
* click position, so the keyboard route lands somewhere sensible too.
|
|
48
|
+
*/
|
|
49
|
+
export function openRowMenu(anchor, it, ui, event, select) {
|
|
50
|
+
const r = anchor?.getBoundingClientRect?.();
|
|
51
|
+
const x = event?.clientX ?? (r ? r.right - 8 : 0);
|
|
52
|
+
const y = event?.clientY ?? (r ? r.bottom : 0);
|
|
53
|
+
// Read the coordinates BEFORE selecting: selecting re-renders, and `anchor` is then
|
|
54
|
+
// a detached node whose rect is all zeroes.
|
|
55
|
+
const items = it.menu(ui);
|
|
56
|
+
select?.();
|
|
57
|
+
ui.platform.workbench.showContextMenu(x, y, items);
|
|
58
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Root composition. Assembles the `ui` helper (stable for the app's life),
|
|
2
|
+
// zips every reactive slice the shell needs into one snapshot, and renders the
|
|
3
|
+
// full workbench from it. Components stay pure — they receive (state, ui) and
|
|
4
|
+
// either call ui.exec(commandId) or ui.go(action). This is the only module that
|
|
5
|
+
// knows about every service at once.
|
|
6
|
+
|
|
7
|
+
import { dd, cell, derive, constant } from '../../runtime.js';
|
|
8
|
+
import { NavigateAction } from '../../bl/actions.js';
|
|
9
|
+
import activityBar from '../components/activityBar.js';
|
|
10
|
+
import statusBar from '../components/statusBar.js';
|
|
11
|
+
import launcher from '../components/launcher.js';
|
|
12
|
+
import settingsView from '../components/settingsView.js';
|
|
13
|
+
import pluginsView from '../components/pluginsView.js';
|
|
14
|
+
import editorArea from '../components/editorArea.js';
|
|
15
|
+
import commandPalette from '../components/commandPalette.js';
|
|
16
|
+
import { dialog, contextMenu, toasts, transferTray, pluginPanel } from '../components/overlays.js';
|
|
17
|
+
import activityPanel from '../components/activityPanel.js';
|
|
18
|
+
import { infoPanel } from '../components/social.js';
|
|
19
|
+
import { phoneTopBar, phoneBottomBar, phoneSheet } from '../components/phoneChrome.js';
|
|
20
|
+
|
|
21
|
+
const { alias, div } = dd;
|
|
22
|
+
|
|
23
|
+
export default function workbench({ engine, app, platform, plugins }) {
|
|
24
|
+
// A counter rather than a timestamp: a cell drops a write of the value it already
|
|
25
|
+
// holds, and two `rerender()` calls in the same millisecond would have compared equal.
|
|
26
|
+
const bump = cell(0);
|
|
27
|
+
const ui = {
|
|
28
|
+
engine, app, platform,
|
|
29
|
+
go: (action) => engine.dispatch(action),
|
|
30
|
+
exec: (id, ...args) => platform.commands.execute(id, ...args),
|
|
31
|
+
rerender: () => bump.update((n) => n + 1),
|
|
32
|
+
uninstallPlugin: (id) => plugins?.uninstall(id),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const { watch } = platform.reactive;
|
|
36
|
+
// One derived snapshot of every slice the shell reads. `derive` invalidates when any
|
|
37
|
+
// of them does and recomputes once on the next read, so fifteen changes in a frame
|
|
38
|
+
// still cost one render.
|
|
39
|
+
const combined = derive(
|
|
40
|
+
[
|
|
41
|
+
platform.workbench.observe(),
|
|
42
|
+
platform.workbench.observeOverlay(),
|
|
43
|
+
platform.workbench.observeNav(),
|
|
44
|
+
app.explorer.observe(),
|
|
45
|
+
app.search.observe(),
|
|
46
|
+
app.transfers.observe(),
|
|
47
|
+
platform.notifications.observe(),
|
|
48
|
+
platform.context.observe(),
|
|
49
|
+
platform.settings.observe(),
|
|
50
|
+
platform.plugins.observe() ?? constant([]),
|
|
51
|
+
platform.contributions.observeType('statusItem'),
|
|
52
|
+
app.social.observe(),
|
|
53
|
+
app.offline.observe(),
|
|
54
|
+
app.activity.observe(),
|
|
55
|
+
platform.viewport.observe(),
|
|
56
|
+
platform.voice.observe(),
|
|
57
|
+
bump,
|
|
58
|
+
],
|
|
59
|
+
// `_bump` is IN the snapshot, unlike before. `watch` skips a render whose value is
|
|
60
|
+
// shallow-equal to the last one, so a forced re-render that left no trace in the
|
|
61
|
+
// object would be discarded as "nothing changed" — which is the opposite of what
|
|
62
|
+
// asking for one means.
|
|
63
|
+
(wb, overlay, nav, ex, se, tr, notif, ctx, settings, pluginList, statusItems, so, off, act, vp, voice, _bump) =>
|
|
64
|
+
({ wb, overlay, nav, ex, se, tr, notif, ctx, settings, plugins: pluginList, statusItems, so, off, act, vp, voice, _bump }),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return alias(() => watch(combined, (state) => view(state, ui)));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function view(state, ui) {
|
|
71
|
+
const mode = state.vp?.mode || 'desktop';
|
|
72
|
+
const phone = mode === 'phone';
|
|
73
|
+
return div({ className: `shell ${mode} ${state.settings['workbench.density'] === 'compact' ? 'compact' : ''}` },
|
|
74
|
+
// On a phone the rail becomes a bottom bar and the status bar folds behind one icon
|
|
75
|
+
// in a top bar — see phoneChrome. Everything between them is identical, which is the
|
|
76
|
+
// point: only the chrome changes shape, not the app.
|
|
77
|
+
phone ? phoneTopBar(state, ui) : null,
|
|
78
|
+
div({ className: 'body' },
|
|
79
|
+
phone ? null : activityBar(state, ui),
|
|
80
|
+
mainArea(state, ui),
|
|
81
|
+
),
|
|
82
|
+
phone ? phoneBottomBar(state, ui) : statusBar(state, ui),
|
|
83
|
+
phone ? phoneSheet(state, ui) : null,
|
|
84
|
+
// Overlays.
|
|
85
|
+
searchModal(state, ui),
|
|
86
|
+
commandPalette(state, ui),
|
|
87
|
+
dialog(state, ui),
|
|
88
|
+
contextMenu(state, ui),
|
|
89
|
+
pluginPanel(state, ui),
|
|
90
|
+
toasts(state, ui),
|
|
91
|
+
transferTray(state, ui),
|
|
92
|
+
activityPanel(state, ui),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function mainArea(state, ui) {
|
|
97
|
+
switch (state.wb.activity) {
|
|
98
|
+
case 'settings': return settingsView(state, ui);
|
|
99
|
+
case 'plugins': return pluginsView(state, ui);
|
|
100
|
+
default: {
|
|
101
|
+
// Home: the top of the panel stack — the launcher (base search) or, once a
|
|
102
|
+
// file is open, its opener full-width (optionally split with the info panel).
|
|
103
|
+
if (!state.nav.activeTabId) return launcher(state, ui);
|
|
104
|
+
if (!state.wb.infoPanel) return editorArea(state, ui);
|
|
105
|
+
// A 340px rail beside a 390px screen leaves nothing on either side. On a phone the
|
|
106
|
+
// details panel takes the whole panel instead — it has its own close button, so
|
|
107
|
+
// there is still a way back to the file.
|
|
108
|
+
return state.vp?.mode === 'phone'
|
|
109
|
+
? infoPanel(state, ui)
|
|
110
|
+
: div({ className: 'editor-split' }, editorArea(state, ui), infoPanel(state, ui));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Double-shift search: the launcher as a modal overlay. Picking an item resets the
|
|
116
|
+
// viewer stack (starts over) — the launcher reads state.wb.searchModal to know.
|
|
117
|
+
function searchModal(state, ui) {
|
|
118
|
+
if (!state.wb.searchModal) return div();
|
|
119
|
+
return div({ className: 'search-modal' },
|
|
120
|
+
div({ className: 'scrim' }).on({ click: () => ui.platform.workbench.closeSearchModal() }),
|
|
121
|
+
div({ className: 'search-modal-panel' }, launcher(state, ui, { modal: true })),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export { NavigateAction };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Formatting helpers for the UI.
|
|
2
|
+
|
|
3
|
+
export function bytes(n) {
|
|
4
|
+
if (n == null || Number.isNaN(n)) return '—';
|
|
5
|
+
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
6
|
+
let i = 0;
|
|
7
|
+
let v = n;
|
|
8
|
+
while (v >= 1024 && i < u.length - 1) {
|
|
9
|
+
v /= 1024;
|
|
10
|
+
i++;
|
|
11
|
+
}
|
|
12
|
+
return `${v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)} ${u[i]}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function duration(sec) {
|
|
16
|
+
if (sec == null || Number.isNaN(sec) || !isFinite(sec)) return '0:00';
|
|
17
|
+
sec = Math.max(0, Math.floor(sec));
|
|
18
|
+
const h = Math.floor(sec / 3600);
|
|
19
|
+
const m = Math.floor((sec % 3600) / 60);
|
|
20
|
+
const s = sec % 60;
|
|
21
|
+
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function relativeDate(ms) {
|
|
25
|
+
if (!ms) return '';
|
|
26
|
+
const diff = Date.now() - ms;
|
|
27
|
+
const day = 86400000;
|
|
28
|
+
if (diff < 60000) return 'just now';
|
|
29
|
+
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
|
30
|
+
if (diff < day) return `${Math.floor(diff / 3600000)}h ago`;
|
|
31
|
+
if (diff < 7 * day) return `${Math.floor(diff / day)}d ago`;
|
|
32
|
+
return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: diff > 330 * day ? 'numeric' : undefined });
|
|
33
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Inline SVG icons. One function, a path table, sized via viewBox 24. Using
|
|
2
|
+
// dodo's element helpers so icons are just vnodes like everything else.
|
|
3
|
+
|
|
4
|
+
import { dd } from '../runtime.js';
|
|
5
|
+
|
|
6
|
+
const { svg, h } = dd;
|
|
7
|
+
|
|
8
|
+
// Each entry is an array of <path>/<primitive> specs (attrs use SVG names).
|
|
9
|
+
const PATHS = {
|
|
10
|
+
link: [{ d: 'M10 13a5 5 0 0 0 7.07 0l3-3A5 5 0 0 0 13 3l-1.5 1.5' }, { d: 'M14 11a5 5 0 0 0-7.07 0l-3 3A5 5 0 0 0 11 21l1.5-1.5' }],
|
|
11
|
+
file: [{ d: 'M6 3h8l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z' }, { d: 'M14 3v5h5' }],
|
|
12
|
+
'file-text': [{ d: 'M6 3h8l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z' }, { d: 'M14 3v5h5M9 13h6M9 17h6M9 9h2' }],
|
|
13
|
+
'file-image': [{ d: 'M6 3h8l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z' }, { d: 'M14 3v5h5' }, { d: 'M8.5 15l2-2 3 3 2-1.5' }, { d: 'M10 12a1 1 0 1 0 0-.01' }],
|
|
14
|
+
'file-audio': [{ d: 'M6 3h8l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z' }, { d: 'M14 3v5h5M10 12v5M13 10v9' }],
|
|
15
|
+
'file-video': [{ d: 'M6 3h8l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z' }, { d: 'M14 3v5h5M10 12l4 2.5-4 2.5z' }],
|
|
16
|
+
book: [{ d: 'M4 5a2 2 0 0 1 2-2h13v16H6a2 2 0 0 0-2 2z' }, { d: 'M4 19a2 2 0 0 1 2-2h13' }],
|
|
17
|
+
search: [{ d: 'M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-3.5-3.5' }],
|
|
18
|
+
mic: [{ d: 'M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3z' }, { d: 'M5 11a7 7 0 0 0 14 0M12 18v3M9 21h6' }],
|
|
19
|
+
command: [{ d: 'M8 6a2 2 0 1 0-2 2h12a2 2 0 1 0-2-2v12a2 2 0 1 0 2-2H6a2 2 0 1 0 2 2z' }],
|
|
20
|
+
gear: [{ d: 'M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z' }, { d: 'M19 12a7 7 0 0 0-.1-1.2l2-1.6-2-3.4-2.4 1a7 7 0 0 0-2-1.2L14 2h-4l-.5 2.6a7 7 0 0 0-2 1.2l-2.4-1-2 3.4 2 1.6A7 7 0 0 0 5 12a7 7 0 0 0 .1 1.2l-2 1.6 2 3.4 2.4-1a7 7 0 0 0 2 1.2L10 22h4l.5-2.6a7 7 0 0 0 2-1.2l2.4 1 2-3.4-2-1.6A7 7 0 0 0 19 12z' }],
|
|
21
|
+
upload: [{ d: 'M12 16V4M7 9l5-5 5 5M4 20h16' }],
|
|
22
|
+
download: [{ d: 'M12 4v12M7 11l5 5 5-5M4 20h16' }],
|
|
23
|
+
trash: [{ d: 'M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6' }],
|
|
24
|
+
refresh: [{ d: 'M20 11a8 8 0 1 0-.5 4M20 5v6h-6' }],
|
|
25
|
+
'chevron-right': [{ d: 'M9 6l6 6-6 6' }],
|
|
26
|
+
'chevron-left': [{ d: 'M15 6l-6 6 6 6' }],
|
|
27
|
+
'chevron-down': [{ d: 'M6 9l6 6 6-6' }],
|
|
28
|
+
close: [{ d: 'M6 6l12 12M18 6L6 18' }],
|
|
29
|
+
tag: [{ d: 'M4 4h6.5a1 1 0 0 1 .7.3l8.5 8.5a1 1 0 0 1 0 1.4l-5.5 5.5a1 1 0 0 1-1.4 0L4.3 11.2a1 1 0 0 1-.3-.7V4z' }, { d: 'M8 8a0.6 0.6 0 1 0 0-.01' }],
|
|
30
|
+
play: [{ d: 'M8 5v14l11-7z', fill: 'currentColor', stroke: 'none' }],
|
|
31
|
+
pause: [{ d: 'M8 5h3v14H8zM13 5h3v14h-3z', fill: 'currentColor', stroke: 'none' }],
|
|
32
|
+
'skip-back': [{ d: 'M11 6v12L3 12zM13 6l8 6-8 6zM21 6v12', fill: 'none' }],
|
|
33
|
+
'skip-forward': [{ d: 'M13 6v12l8-6zM3 6l8 6-8 6zM3 6v12' }],
|
|
34
|
+
'back-30': [{ d: 'M11 8a7 7 0 1 0 7 7' }, { d: 'M11 4l-3 4 4 1' }],
|
|
35
|
+
'fwd-30': [{ d: 'M13 8a7 7 0 1 1-7 7' }, { d: 'M13 4l3 4-4 1' }],
|
|
36
|
+
plug: [{ d: 'M9 3v6M15 3v6M6 9h12v3a6 6 0 0 1-12 0zM12 18v3' }],
|
|
37
|
+
bell: [{ d: 'M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9z' }, { d: 'M13.73 21a2 2 0 0 1-3.46 0' }],
|
|
38
|
+
files: [{ d: 'M4 4h9l3 3v3M8 8h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2z' }],
|
|
39
|
+
dots: [{ d: 'M5 12h.01M12 12h.01M19 12h.01' }],
|
|
40
|
+
check: [{ d: 'M5 12l5 5L20 6' }],
|
|
41
|
+
plus: [{ d: 'M12 5v14M5 12h14' }],
|
|
42
|
+
'arrow-up': [{ d: 'M12 19V5M6 11l6-6 6 6' }],
|
|
43
|
+
star: [{ d: 'M12 3l2.7 5.6 6.1.9-4.4 4.3 1 6.1L12 17l-5.4 2.9 1-6.1L3.2 9.5l6.1-.9z' }],
|
|
44
|
+
info: [{ d: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM12 11v5M12 8h.01' }],
|
|
45
|
+
warn: [{ d: 'M12 3l9 16H3zM12 10v4M12 17h.01' }],
|
|
46
|
+
x: [{ d: 'M6 6l12 12M18 6L6 18' }],
|
|
47
|
+
list: [{ d: 'M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01' }],
|
|
48
|
+
grid: [{ d: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z' }],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Map a node to an icon name via the shared file-type classifier.
|
|
52
|
+
export { iconForKind as iconForNode } from '../bl/fileType.js';
|
|
53
|
+
|
|
54
|
+
export function icon(name, { size = 18, strokeWidth = 1.6, className } = {}) {
|
|
55
|
+
// `Object.hasOwn`, not a bare lookup: `PATHS` is a plain object, so a contribution
|
|
56
|
+
// declaring `"icon": "constructor"` (kept verbatim from the manifest) otherwise
|
|
57
|
+
// resolves to a function and takes down the whole render with `specs.map is not a
|
|
58
|
+
// function`.
|
|
59
|
+
const specs = Object.hasOwn(PATHS, name) ? PATHS[name] : PATHS.file;
|
|
60
|
+
// `class` through `$attrs`, not `className`. An SVGElement's `className` is a
|
|
61
|
+
// read-only SVGAnimatedString, so the prop is written out as a literal `className="…"`
|
|
62
|
+
// attribute — which no selector matches. `.sheet-row .sr-go` was styling nothing.
|
|
63
|
+
return svg(
|
|
64
|
+
{
|
|
65
|
+
$attrs: {
|
|
66
|
+
viewBox: '0 0 24 24', width: size, height: size, fill: 'none',
|
|
67
|
+
stroke: 'currentColor', 'stroke-width': strokeWidth,
|
|
68
|
+
'stroke-linecap': 'round', 'stroke-linejoin': 'round',
|
|
69
|
+
...(className ? { class: className } : {}),
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
...specs.map((s) => h('path', { $attrs: filterAttrs(s) })),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function filterAttrs(s) {
|
|
77
|
+
const out = { d: s.d };
|
|
78
|
+
if (s.fill) out.fill = s.fill;
|
|
79
|
+
if (s.stroke) out.stroke = s.stroke;
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Keeping a media element pointed at a URL that still works.
|
|
2
|
+
//
|
|
3
|
+
// A minted URL expires (docs/design/signed-urls.md). Content URLs are long — longer than
|
|
4
|
+
// almost any sitting — but "almost" is not "always": someone pauses a film, closes the
|
|
5
|
+
// laptop, and comes back tomorrow. So the element has to be able to swap its own source,
|
|
6
|
+
// and swapping the source under a playing video is not a matter of assigning `src`.
|
|
7
|
+
//
|
|
8
|
+
// Both refresh paths are needed and neither is sufficient alone:
|
|
9
|
+
//
|
|
10
|
+
// PROACTIVE a timer from `expiresAt`. Handles the common case invisibly.
|
|
11
|
+
// REACTIVE the element's own `error`. A backgrounded tab has its timers throttled,
|
|
12
|
+
// so the proactive refresh may simply never run — which is exactly the
|
|
13
|
+
// paused-overnight case, the one the timer was supposed to cover.
|
|
14
|
+
|
|
15
|
+
const REFRESH_AT = 0.8; // of the URL's life, so the swap lands well before it matters.
|
|
16
|
+
const MAX_TIMER = 2 ** 31 - 1; // setTimeout overflows past this and fires immediately.
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Point `el` at `node`'s bytes and keep it pointed there.
|
|
20
|
+
*
|
|
21
|
+
* @param {HTMLMediaElement|HTMLImageElement} el
|
|
22
|
+
* @param {object} node
|
|
23
|
+
* @param {object} ui
|
|
24
|
+
* @param {{op?: string, onError?: (msg: string) => void}} [opts]
|
|
25
|
+
* @returns {() => void} detach
|
|
26
|
+
*/
|
|
27
|
+
export function attachMedia(el, node, ui, { op = 'media', onError } = {}) {
|
|
28
|
+
const urls = ui.platform.mediaUrls;
|
|
29
|
+
let timer = null;
|
|
30
|
+
let stopped = false;
|
|
31
|
+
// One retry per URL. A file whose bytes are genuinely gone would otherwise re-mint
|
|
32
|
+
// forever, hammering the server over a 404 that is never going to change.
|
|
33
|
+
let retried = false;
|
|
34
|
+
|
|
35
|
+
const clear = () => { clearTimeout(timer); timer = null; };
|
|
36
|
+
|
|
37
|
+
const schedule = (expiresAt) => {
|
|
38
|
+
clear();
|
|
39
|
+
if (!Number.isFinite(expiresAt)) return; // nothing was minted; nothing expires
|
|
40
|
+
const wait = Math.max(1000, (expiresAt - Date.now()) * REFRESH_AT);
|
|
41
|
+
timer = setTimeout(() => apply({ resume: true }), Math.min(wait, MAX_TIMER));
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Fetch a URL and put it on the element, preserving where the user was.
|
|
46
|
+
*
|
|
47
|
+
* The order matters and every step earns its place: without `resume` the film restarts
|
|
48
|
+
* from the beginning, without `load()` an element that has already errored may not pick
|
|
49
|
+
* up the new source at all, and without the final `play()` it sits there paused looking
|
|
50
|
+
* like a crash.
|
|
51
|
+
*/
|
|
52
|
+
async function apply({ resume }) {
|
|
53
|
+
if (stopped) return;
|
|
54
|
+
const media = isMedia(el);
|
|
55
|
+
const at = media && resume ? el.currentTime : 0;
|
|
56
|
+
const wasPlaying = media && resume ? !el.paused && !el.ended : false;
|
|
57
|
+
let got;
|
|
58
|
+
try {
|
|
59
|
+
got = await urls.url(node.id, { op });
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if (!stopped) onError?.(err.message);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (stopped) return;
|
|
65
|
+
el.src = got.url;
|
|
66
|
+
if (media) {
|
|
67
|
+
el.load();
|
|
68
|
+
if (at > 0 || wasPlaying) {
|
|
69
|
+
const restore = () => {
|
|
70
|
+
el.removeEventListener('loadedmetadata', restore);
|
|
71
|
+
// Guard the seek: a live or zero-length source has nowhere to seek to, and
|
|
72
|
+
// assigning currentTime there throws.
|
|
73
|
+
if (at > 0 && Number.isFinite(el.duration) && el.duration > at) el.currentTime = at;
|
|
74
|
+
if (wasPlaying) el.play().catch(() => { /* autoplay policy; the user can press play */ });
|
|
75
|
+
};
|
|
76
|
+
el.addEventListener('loadedmetadata', restore);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
schedule(got.expiresAt);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The retry budget is spent by a failed LOAD and refunded by a successful one — not by
|
|
83
|
+
// a successful mint. Refunding it in `apply` meant a file whose bytes are genuinely
|
|
84
|
+
// gone re-minted forever: every retry got a perfectly good URL, reset the budget, and
|
|
85
|
+
// failed again, so the fallback was never shown and the request never stopped.
|
|
86
|
+
const onLoaded = () => { retried = false; };
|
|
87
|
+
el.addEventListener('load', onLoaded); // <img>
|
|
88
|
+
el.addEventListener('loadeddata', onLoaded); // <audio>/<video>
|
|
89
|
+
|
|
90
|
+
// The reactive half. An expired URL and a missing file look identical from here — both
|
|
91
|
+
// are just a failed load — so it re-mints once and, if that fails too, says so.
|
|
92
|
+
const onElementError = () => {
|
|
93
|
+
if (stopped) return;
|
|
94
|
+
if (retried) { onError?.(null); return; }
|
|
95
|
+
retried = true;
|
|
96
|
+
urls.invalidate(node.id, op);
|
|
97
|
+
apply({ resume: true });
|
|
98
|
+
};
|
|
99
|
+
el.addEventListener('error', onElementError);
|
|
100
|
+
|
|
101
|
+
apply({ resume: false });
|
|
102
|
+
|
|
103
|
+
return () => {
|
|
104
|
+
stopped = true;
|
|
105
|
+
clear();
|
|
106
|
+
el.removeEventListener('error', onElementError);
|
|
107
|
+
el.removeEventListener('load', onLoaded);
|
|
108
|
+
el.removeEventListener('loadeddata', onLoaded);
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isMedia(el) {
|
|
113
|
+
return typeof el?.play === 'function' && typeof el?.load === 'function';
|
|
114
|
+
}
|