@webship.co/workspace 0.0.0-stage → 1.0.0-beta1
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/PLUGINS.md +249 -0
- package/README.md +379 -2
- package/WORKSPACES.md +69 -0
- package/actions/ai-items.js +293 -0
- package/actions/audio.js +48 -0
- package/actions/build-order.js +79 -0
- package/actions/checks.js +95 -0
- package/actions/claude-session.js +104 -0
- package/actions/commands-center.js +258 -0
- package/actions/composer.js +267 -0
- package/actions/ddev-addons.js +90 -0
- package/actions/ddev-mail.js +80 -0
- package/actions/ddev.js +237 -0
- package/actions/folders.js +165 -0
- package/actions/images-svg.js +33 -0
- package/actions/images.js +145 -0
- package/actions/navigation.js +190 -0
- package/actions/node.js +222 -0
- package/actions/notes.js +55 -0
- package/actions/plugins.js +36 -0
- package/actions/project-make.js +28 -0
- package/actions/rag.js +202 -0
- package/actions/row-actions.js +262 -0
- package/actions/screens.js +270 -0
- package/actions/testing.js +121 -0
- package/actions/video-edit.js +54 -0
- package/ai-inventory.js +182 -0
- package/ai-items.js +202 -0
- package/assistant-cast.js +176 -0
- package/assistant-suggestions.js +91 -0
- package/assistant.js +484 -0
- package/audio-voice.js +528 -0
- package/auth-google.js +260 -0
- package/build-ai.js +52 -0
- package/build-order-page.js +191 -0
- package/build-order.js +313 -0
- package/builder-args.js +117 -0
- package/changelog.js +302 -0
- package/chat-threads.js +134 -0
- package/checks.js +105 -0
- package/claude-args.js +282 -0
- package/claude-sessions.js +129 -0
- package/command-author.js +117 -0
- package/command-block.js +20 -0
- package/commands/defaults.yml +159 -0
- package/commands-page.js +460 -0
- package/commands.js +568 -0
- package/cucumber.js +43 -0
- package/ddev-addons-page.js +124 -0
- package/ddev-addons.js +154 -0
- package/ddev-custom-commands.js +96 -0
- package/ddev-domains.js +84 -0
- package/ddev-import.js +93 -0
- package/ddev-mail-page.js +167 -0
- package/ddev-mail.js +110 -0
- package/ddev-options.js +145 -0
- package/ddev-snapshots.js +126 -0
- package/ddev.js +145 -0
- package/docs/DEVELOPER-GUIDE.md +224 -0
- package/docs/README.md +252 -0
- package/docs/REFERENCE.md +684 -0
- package/docs/USER-GUIDE.md +697 -0
- package/docs/screenshots/assistant/avatar-board.png +0 -0
- package/docs/screenshots/assistant/avatar-coffee.png +0 -0
- package/docs/screenshots/assistant/avatar-colours.png +0 -0
- package/docs/screenshots/assistant/avatar-icons.png +0 -0
- package/docs/screenshots/assistant/avatar-moves.png +0 -0
- package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
- package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
- package/docs/screenshots/assistant/avatar-status.png +0 -0
- package/docs/screenshots/create-card/create-card.png +0 -0
- package/docs/screenshots/create-card/start-ai.png +0 -0
- package/docs/screenshots/guide-clone-address.png +0 -0
- package/docs/screenshots/guide-clone-worktree-off.png +0 -0
- package/docs/screenshots/guide-clone-worktree-on.png +0 -0
- package/docs/screenshots/guide-ddev-menu.png +0 -0
- package/docs/screenshots/guide-domains.png +0 -0
- package/docs/screenshots/guide-home.png +0 -0
- package/docs/screenshots/guide-ide-menu.png +0 -0
- package/docs/screenshots/guide-remove-refusal.png +0 -0
- package/docs/screenshots/guide-rows-worktree.png +0 -0
- package/docs/screenshots/guide-screens.png +0 -0
- package/docs/screenshots/guide-settings.png +0 -0
- package/docs/screenshots/guide-workspace-page.png +0 -0
- package/docs/screenshots/home/card-settings.png +0 -0
- package/docs/screenshots/home/drag-reorder.png +0 -0
- package/docs/screenshots/home/edit-card.png +0 -0
- package/docs/screenshots/images/claude-code-image.png +0 -0
- package/docs/screenshots/images/edit-with-ai.png +0 -0
- package/docs/screenshots/images/gallery.png +0 -0
- package/docs/screenshots/images/generate-description.png +0 -0
- package/docs/screenshots/images/generate-plan.png +0 -0
- package/docs/screenshots/images/providers.png +0 -0
- package/docs/screenshots/images/svg-form.png +0 -0
- package/docs/screenshots/images/svg-result.png +0 -0
- package/docs/screenshots/images/upload.png +0 -0
- package/docs/screenshots/new-folder/2-form.png +0 -0
- package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
- package/docs/screenshots/new-folder/files-folder.png +0 -0
- package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
- package/docs/screenshots/projects/state-chip-stop.png +0 -0
- package/docs/screenshots/readme/dashboard-dark.png +0 -0
- package/docs/screenshots/readme/dashboard-light.png +0 -0
- package/docs/screenshots/readme/dev-dark.png +0 -0
- package/docs/screenshots/readme/dev-light.png +0 -0
- package/docs/screenshots/readme/docs-dark.png +0 -0
- package/docs/screenshots/readme/docs-light.png +0 -0
- package/docs/screenshots/readme/icons/ai.svg +4 -0
- package/docs/screenshots/readme/icons/code.svg +4 -0
- package/docs/screenshots/readme/icons/docs.svg +4 -0
- package/docs/screenshots/readme/icons/services.svg +4 -0
- package/docs/screenshots/readme/icons/sites.svg +4 -0
- package/docs/screenshots/readme/test-report-dark.png +0 -0
- package/docs/screenshots/readme/test-report-light.png +0 -0
- package/docs/screenshots/readme/test-scenarios.png +0 -0
- package/docs/screenshots/readme/test-steps.png +0 -0
- package/docs/screenshots/readme/videos-dark.png +0 -0
- package/docs/screenshots/readme/videos-light.png +0 -0
- package/docs/screenshots/screens-menu.png +0 -0
- package/docs/screenshots/screens-new.png +0 -0
- package/docs/screenshots/settings/auth-google-lists.png +0 -0
- package/docs/screenshots/settings/delete-1-row.png +0 -0
- package/docs/screenshots/settings/delete-2-confirm.png +0 -0
- package/docs/screenshots/settings/delete-3-type-name.png +0 -0
- package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
- package/event-log.js +59 -0
- package/files-browser.js +247 -0
- package/folders.js +121 -0
- package/format.js +73 -0
- package/git-info.js +81 -0
- package/github-auth.js +62 -0
- package/html.js +10 -0
- package/icons.js +83 -0
- package/images-ai.js +447 -0
- package/images-create.js +226 -0
- package/images-page.js +417 -0
- package/images-records.js +39 -0
- package/images-render.js +36 -0
- package/images-svg-core.js +214 -0
- package/images-svg-sanitize.js +188 -0
- package/images-svg.js +241 -0
- package/images.js +270 -0
- package/jobs.js +430 -0
- package/linger.js +21 -0
- package/lists.js +292 -0
- package/media-info.js +136 -0
- package/navigation-page.js +264 -0
- package/navigation.js +394 -0
- package/notes.js +0 -0
- package/package.json +47 -4
- package/palette-index.js +46 -0
- package/playwright.config.js +23 -0
- package/plugin-install.js +239 -0
- package/plugin-loader.js +466 -0
- package/plugin-manifest.js +83 -0
- package/plugin-settings.js +44 -0
- package/plugins/workspace-notes/index.js +59 -0
- package/plugins-page.js +103 -0
- package/project-make.js +241 -0
- package/project-name.js +210 -0
- package/public/assistant-avatar-moves.js +646 -0
- package/public/assistant-avatar-ui.js +1223 -0
- package/public/assistant-avatar.js +530 -0
- package/public/avatar/druplicon.vrm +0 -0
- package/public/css/base.css +260 -0
- package/public/css/components/ai-assistant.css +263 -0
- package/public/css/components/assistant-avatar.css +279 -0
- package/public/css/components/assistant-views.css +198 -0
- package/public/css/components/build-order.css +74 -0
- package/public/css/components/clone-as-a-git-worktree.css +30 -0
- package/public/css/components/command-palette.css +61 -0
- package/public/css/components/commands-center.css +139 -0
- package/public/css/components/confirm-dialog.css +40 -0
- package/public/css/components/create-card.css +8 -0
- package/public/css/components/ddev-menu.css +23 -0
- package/public/css/components/deep-chat.css +17 -0
- package/public/css/components/density.css +11 -0
- package/public/css/components/floating-assistant-workspace-pages.css +55 -0
- package/public/css/components/folders.css +5 -0
- package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
- package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
- package/public/css/components/grid-and-list.css +203 -0
- package/public/css/components/help-page.css +36 -0
- package/public/css/components/hero.css +16 -0
- package/public/css/components/images.css +70 -0
- package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
- package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
- package/public/css/components/modal-fit.css +113 -0
- package/public/css/components/page-width.css +23 -0
- package/public/css/components/plugins-page.css +9 -0
- package/public/css/components/project-cards.css +256 -0
- package/public/css/components/project-rows.css +390 -0
- package/public/css/components/reordering-the-workspace-cards.css +85 -0
- package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
- package/public/css/components/running-commands-as-toasts.css +327 -0
- package/public/css/components/settings-editor-core-config-yml.css +828 -0
- package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
- package/public/css/components/sound-player.css +84 -0
- package/public/css/components/suggest-agents-skills-commands.css +112 -0
- package/public/css/components/tab-modal.css +142 -0
- package/public/css/components/terminals.css +244 -0
- package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
- package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
- package/public/css/components/video-edit.css +6 -0
- package/public/css/components/web-ide-open-with.css +23 -0
- package/public/css/components/working-indicators.css +158 -0
- package/public/css/components/workspace-cards.css +79 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/public/htmx.min.js +1 -0
- package/public/logo.png +0 -0
- package/public/media-player.js +215 -0
- package/public/mic.js +255 -0
- package/public/narration.js +110 -0
- package/public/palette.js +278 -0
- package/public/sortable.js +251 -0
- package/public/suggest.js +337 -0
- package/public/terminal.js +903 -0
- package/public/themes/default/theme.css +267 -0
- package/public/themes/ember/theme.css +180 -0
- package/public/themes/press/theme.css +290 -0
- package/public/themes/slate/theme.css +182 -0
- package/public/touch-icon.png +0 -0
- package/public/ui.js +2721 -0
- package/public/vendor/ace/LICENSE.txt +24 -0
- package/public/vendor/ace/ace.js +23 -0
- package/public/vendor/ace/ext-language_tools.js +8 -0
- package/public/vendor/ace/ext-searchbox.js +8 -0
- package/public/vendor/ace/mode-css.js +8 -0
- package/public/vendor/ace/mode-html.js +8 -0
- package/public/vendor/ace/mode-javascript.js +8 -0
- package/public/vendor/ace/mode-markdown.js +8 -0
- package/public/vendor/ace/mode-sh.js +8 -0
- package/public/vendor/ace/mode-yaml.js +8 -0
- package/public/vendor/ace/theme-textmate.js +8 -0
- package/public/vendor/ace/theme-tomorrow_night.js +8 -0
- package/public/vendor/ace/worker-yaml.js +1 -0
- package/public/vendor/deep-chat.LICENSE +21 -0
- package/public/vendor/deep-chat.bundle.js +1 -0
- package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
- package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
- package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
- package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
- package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
- package/public/vendor/tabler-LICENSE.txt +21 -0
- package/public/vendor/tabler-sprite.svg +1 -0
- package/public/vendor/three/LICENSE +21 -0
- package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
- package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
- package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
- package/public/vendor/three/three.core.min.js +6 -0
- package/public/vendor/three/three.module.min.js +6 -0
- package/public/vendor/three-vrm/LICENSE +22 -0
- package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
- package/public/vendor/uikit-icons.min.js +1 -0
- package/public/vendor/uikit.min.css +1 -0
- package/public/vendor/uikit.min.js +1 -0
- package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
- package/public/vendor/webship-icons/ui/headset.svg +1 -0
- package/public/vendor/webship-icons/ui/menu.svg +1 -0
- package/public/vendor/webship-icons/ui/pause.svg +1 -0
- package/public/vendor/webship-icons/ui/play.svg +1 -0
- package/public/vendor/webship-icons/ui/share.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
- package/public/vendor/webship-icons/ui/stop.svg +1 -0
- package/public/vendor/xterm/addon-fit.js +2 -0
- package/public/vendor/xterm/addon-unicode11.js +2 -0
- package/public/vendor/xterm/addon-web-links.js +2 -0
- package/public/vendor/xterm/xterm.css +218 -0
- package/public/vendor/xterm/xterm.js +2 -0
- package/rag.js +169 -0
- package/reader.js +132 -0
- package/repo-blobs.js +66 -0
- package/report-pdf.js +337 -0
- package/row-layout.js +1439 -0
- package/screenrc +37 -0
- package/screens.js +1063 -0
- package/scripts/code-server-start.sh +190 -0
- package/scripts/images-claude-finish.js +107 -0
- package/scripts/screen-host.sh +114 -0
- package/scripts/screen-runner.js +362 -0
- package/scripts/screen-then.js +47 -0
- package/scripts/update.sh +350 -0
- package/server.js +7923 -0
- package/settings-file.js +423 -0
- package/speech.js +297 -0
- package/tab-modal.js +75 -0
- package/test-reports.js +260 -0
- package/themes.js +381 -0
- package/transcripts.js +168 -0
- package/urls.js +13 -0
- package/validate.js +18 -0
- package/version-check.js +94 -0
- package/video-edit.js +588 -0
- package/waiting.js +85 -0
- package/workspace-card-tools.js +41 -0
- package/workspace-delete.js +139 -0
- package/workspace-types.js +85 -0
- package/workspaces.js +553 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Spoken summary of the assistant's answers. Loaded by the page and required by the unit tests.
|
|
2
|
+
(function (root, factory) {
|
|
3
|
+
if (typeof module === 'object' && module.exports) module.exports = factory();
|
|
4
|
+
else root.wsNarration = factory();
|
|
5
|
+
}(typeof self !== 'undefined' ? self : this, () => {
|
|
6
|
+
const PREF = 'ws-voice';
|
|
7
|
+
const ENTITIES = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ''': "'", ' ': ' ' };
|
|
8
|
+
|
|
9
|
+
const decode = (s) => s.replace(/&(?:amp|lt|gt|quot|apos|nbsp|#39);/g, (m) => ENTITIES[m]);
|
|
10
|
+
|
|
11
|
+
// Speakable text: no markdown marks, and a URL becomes "a link".
|
|
12
|
+
function cleanSpoken(text, max = 300) {
|
|
13
|
+
let t = String(text || '')
|
|
14
|
+
.replace(/https?:\/\/\S+/g, 'a link')
|
|
15
|
+
.replace(/[`*_#>|~]/g, ' ')
|
|
16
|
+
.replace(/\s+/g, ' ')
|
|
17
|
+
.trim();
|
|
18
|
+
if (t.length > max) t = t.slice(0, max).replace(/\s+\S*$/, '');
|
|
19
|
+
return t;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The first sentences of a rendered reply, leaving out code blocks, tables, buttons and the plan card.
|
|
23
|
+
function summaryFromHtml(html, { maxChars = 240, maxSentences = 2 } = {}) {
|
|
24
|
+
const text = String(html || '')
|
|
25
|
+
.replace(/<div class="plan-card"[\s\S]*$/, ' ')
|
|
26
|
+
.replace(/<(pre|table|button|script|style)\b[\s\S]*?<\/\1>/gi, ' ')
|
|
27
|
+
.replace(/<\/(p|li|h[1-6]|div|tr)>|<br\s*\/?>/gi, '\n')
|
|
28
|
+
.replace(/<[^>]*>/g, '');
|
|
29
|
+
const lines = decode(text).split('\n').map((l) => l.replace(/\s+/g, ' ').trim()).filter(Boolean)
|
|
30
|
+
.map((l) => (/[.!?:;]$/.test(l) ? l : `${l}.`));
|
|
31
|
+
const sentences = cleanSpoken(lines.join(' '), 2000).split(/(?<=[.!?])\s+/).filter(Boolean);
|
|
32
|
+
let out = '';
|
|
33
|
+
for (const s of sentences.slice(0, maxSentences)) {
|
|
34
|
+
if (out && out.length + s.length + 1 > maxChars) break;
|
|
35
|
+
out = out ? `${out} ${s}` : s;
|
|
36
|
+
}
|
|
37
|
+
return cleanSpoken(out, maxChars);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The best free voice this browser has, when none is configured: the neural ones first (Edge's
|
|
41
|
+
// "Natural" and "Online" voices, Chrome's "Google" voices, Apple's "Enhanced" and "Premium"), in the
|
|
42
|
+
// language asked for; the browser's default when it has nothing better.
|
|
43
|
+
function bestVoice(voices, lang) {
|
|
44
|
+
const want = String(lang || 'en').toLowerCase().slice(0, 2);
|
|
45
|
+
const quality = (v) => (/natural|neural|online/i.test(v.name) ? 8 : 0) + (/google/i.test(v.name) ? 4 : 0)
|
|
46
|
+
+ (/enhanced|premium/i.test(v.name) ? 2 : 0);
|
|
47
|
+
const score = (v) => quality(v) * 2 + (String(v.lang || '').toLowerCase() === String(lang || '').toLowerCase() ? 1 : 0);
|
|
48
|
+
const fit = (voices || []).filter((v) => String(v.lang || '').toLowerCase().startsWith(want));
|
|
49
|
+
const best = fit.sort((a, b) => score(b) - score(a))[0];
|
|
50
|
+
return best && quality(best) > 0 ? best : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Everything the browser provides is passed in, so this runs under test with fakes.
|
|
54
|
+
// `onSpeech({ type: 'start' | 'word' | 'end', word })`, when given, hears the voice as it goes: the
|
|
55
|
+
// assistant's avatar moves its mouth with it.
|
|
56
|
+
// `remote(text, voiceId, params, onSpeech, fallback)`, when given, speaks in a voice of the server's
|
|
57
|
+
// (`voiceName: 'elevenlabs:<id>'`) and returns a function that stops it; `fallback()` speaks the same
|
|
58
|
+
// words in the browser's voice when the server cannot.
|
|
59
|
+
function createNarrator({ params = {}, defaultOn = true, getPref, setPref, synth, Utterance, onSpeech, remote }) {
|
|
60
|
+
const supported = !!(synth && Utterance) || typeof remote === 'function';
|
|
61
|
+
const isOn = () => {
|
|
62
|
+
if (!supported) return false;
|
|
63
|
+
const v = getPref ? getPref(PREF) : null;
|
|
64
|
+
return v === null || v === undefined ? !!defaultOn : v === '1';
|
|
65
|
+
};
|
|
66
|
+
let stopRemote = null;
|
|
67
|
+
const remoteVoice = () => {
|
|
68
|
+
const m = typeof remote === 'function' && String(params.voiceName || '').match(/^elevenlabs:([A-Za-z0-9]{16,32})$/);
|
|
69
|
+
return m ? m[1] : '';
|
|
70
|
+
};
|
|
71
|
+
const cancel = () => {
|
|
72
|
+
if (stopRemote) { const stop = stopRemote; stopRemote = null; try { stop(); } catch (_) { /* already stopped */ } }
|
|
73
|
+
try { if (synth) synth.cancel(); } catch (_) { /* nothing speaking */ }
|
|
74
|
+
if (onSpeech) onSpeech({ type: 'end' });
|
|
75
|
+
};
|
|
76
|
+
const speak = (text) => {
|
|
77
|
+
const t = cleanSpoken(text);
|
|
78
|
+
if (!supported || !t) return false;
|
|
79
|
+
cancel();
|
|
80
|
+
const id = remoteVoice();
|
|
81
|
+
if (id) { stopRemote = remote(t, id, params, onSpeech, () => browserSpeak(t)); return true; }
|
|
82
|
+
return browserSpeak(t);
|
|
83
|
+
};
|
|
84
|
+
const browserSpeak = (t) => {
|
|
85
|
+
if (!synth || !Utterance) return false;
|
|
86
|
+
const u = new Utterance(t);
|
|
87
|
+
for (const k of ['rate', 'pitch', 'volume', 'lang']) if (params[k] !== undefined) u[k] = params[k];
|
|
88
|
+
if (synth.getVoices) {
|
|
89
|
+
const all = synth.getVoices() || [];
|
|
90
|
+
const named = params.voiceName && all.find((x) => x.name.toLowerCase() === String(params.voiceName).toLowerCase());
|
|
91
|
+
const v = named || bestVoice(all, params.lang);
|
|
92
|
+
if (v) u.voice = v;
|
|
93
|
+
}
|
|
94
|
+
if (onSpeech) {
|
|
95
|
+
u.onstart = () => onSpeech({ type: 'start' });
|
|
96
|
+
u.onboundary = (e) => { if (!e || e.name === undefined || e.name === 'word') onSpeech({ type: 'word', word: t.slice(e.charIndex || 0).split(/\s/)[0] }); };
|
|
97
|
+
u.onend = () => onSpeech({ type: 'end' });
|
|
98
|
+
u.onerror = () => onSpeech({ type: 'end' });
|
|
99
|
+
}
|
|
100
|
+
synth.speak(u);
|
|
101
|
+
return true;
|
|
102
|
+
};
|
|
103
|
+
const setOn = (on) => { if (setPref) setPref(PREF, on ? '1' : '0'); if (!on) cancel(); };
|
|
104
|
+
// `reply.spoken` is the assistant's own summary; the first sentences are the fallback.
|
|
105
|
+
const narrate = (reply) => (isOn() ? speak((reply && reply.spoken) || summaryFromHtml(reply && reply.html)) : false);
|
|
106
|
+
return { supported, isOn, setOn, speak, cancel, narrate };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { PREF, cleanSpoken, summaryFromHtml, createNarrator, bestVoice };
|
|
110
|
+
}));
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/* The command palette, the shortcut sheet and the go-to chords.
|
|
2
|
+
*
|
|
3
|
+
* Ctrl or Cmd + K, or / open the palette: workspaces, pages, projects and a few actions
|
|
4
|
+
* ? the shortcut sheet
|
|
5
|
+
* g then a letter go to a workspace or page (g d is Dev, g s is Screens)
|
|
6
|
+
* Esc close
|
|
7
|
+
*
|
|
8
|
+
* Never fires while somebody is typing in a field. The data is one small JSON document from
|
|
9
|
+
* /fragments/palette.json, fetched the first time the palette opens. */
|
|
10
|
+
(function () {
|
|
11
|
+
'use strict';
|
|
12
|
+
var data = null;
|
|
13
|
+
var overlay = null;
|
|
14
|
+
var input = null;
|
|
15
|
+
var list = null;
|
|
16
|
+
var live = null;
|
|
17
|
+
var items = [];
|
|
18
|
+
var active = 0;
|
|
19
|
+
var returnTo = null;
|
|
20
|
+
var chord = 0;
|
|
21
|
+
|
|
22
|
+
var CHORDS = { h: ['home', 'Home'], d: ['dev', 'Dev'], t: ['test', 'Test'], p: ['products', 'Products'], x: ['sandboxes', 'Sandboxes'],
|
|
23
|
+
e: ['demos', 'Demos'], r: ['projects', 'Projects'], v: ['videos', 'Videos'], a: ['audio', 'Audio'], o: ['docs', 'Docs'],
|
|
24
|
+
m: ['modules', 'Modules'], w: ['worklogs', 'Worklogs'], s: ['/screens', 'Screens'], c: ['/commands', 'Commands'],
|
|
25
|
+
',': ['/settings', 'Settings'], '?': ['/help', 'Help'] };
|
|
26
|
+
|
|
27
|
+
function typing(t) {
|
|
28
|
+
if (!t || !t.tagName) return false;
|
|
29
|
+
var n = t.tagName.toLowerCase();
|
|
30
|
+
return n === 'input' || n === 'textarea' || n === 'select' || t.isContentEditable || !!(t.closest && t.closest('.ace_editor, .xterm'));
|
|
31
|
+
}
|
|
32
|
+
function modalOpen() { return !!document.querySelector('#editor-modal.uk-open, #confirm-modal.uk-open'); }
|
|
33
|
+
|
|
34
|
+
function score(text, q) {
|
|
35
|
+
text = text.toLowerCase();
|
|
36
|
+
if (!q) return 1;
|
|
37
|
+
if (text === q) return 100;
|
|
38
|
+
if (text.indexOf(q) === 0) return 80;
|
|
39
|
+
if (text.indexOf(q) > 0) return 60 - Math.min(40, text.indexOf(q));
|
|
40
|
+
var i = 0;
|
|
41
|
+
for (var k = 0; k < text.length && i < q.length; k += 1) if (text[k] === q[i]) i += 1;
|
|
42
|
+
return i === q.length ? 20 : 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function hubUrl(key) {
|
|
46
|
+
if (key === 'home') {
|
|
47
|
+
var parts = location.hostname.split('.');
|
|
48
|
+
var k = parts.findIndex(function (_, i) { return data && data.workspaces.some(function (w) { return w.key === parts[i]; }); });
|
|
49
|
+
return location.protocol + '//' + (k >= 0 ? parts.slice(k + 1).join('.') : location.hostname) + (location.port ? ':' + location.port : '') + '/';
|
|
50
|
+
}
|
|
51
|
+
var w = data && data.workspaces.find(function (x) { return x.key === key; });
|
|
52
|
+
return w ? w.url : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The same preference the switch beside the page width writes (a cookie on the hub domain, then localStorage).
|
|
56
|
+
function density(value) {
|
|
57
|
+
var btn = document.querySelector('.density-pick[data-density="' + value + '"]');
|
|
58
|
+
if (btn) { btn.click(); return; }
|
|
59
|
+
document.documentElement.dataset.density = value;
|
|
60
|
+
if (window.wsPrefSet) window.wsPrefSet('ws-density', value);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function actions() {
|
|
64
|
+
return [
|
|
65
|
+
{ group: 'Do', label: 'New screen', hint: 'A shell, Claude Code or a command, detached', run: function () {
|
|
66
|
+
close();
|
|
67
|
+
if (window.htmx) window.htmx.ajax('GET', '/fragments/screen-new-form' + (location.hostname.split('.')[0] ? '' : ''), { target: '#editor-modal-body', swap: 'innerHTML' });
|
|
68
|
+
} },
|
|
69
|
+
{ group: 'Do', label: 'Switch light and dark', hint: 'The theme toggle', run: function () {
|
|
70
|
+
close();
|
|
71
|
+
var t = document.querySelector('[data-theme-toggle], #theme-toggle, .theme-toggle');
|
|
72
|
+
if (t) t.click();
|
|
73
|
+
} },
|
|
74
|
+
{ group: 'Do', label: 'Rows: compact', hint: 'Project buttons without the caption under each', run: function () { close(); density('compact'); } },
|
|
75
|
+
{ group: 'Do', label: 'Rows: comfortable', hint: 'Project buttons with their captions', run: function () { close(); density('comfortable'); } },
|
|
76
|
+
{ group: 'Do', label: 'Show the keyboard shortcuts', hint: '?', run: function () { render('?'); } },
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function build(q) {
|
|
81
|
+
var out = [];
|
|
82
|
+
if (!data) return out;
|
|
83
|
+
var query = q.trim().toLowerCase();
|
|
84
|
+
var add = function (group, label, hint, url, extra) {
|
|
85
|
+
var s = Math.max(score(label, query), extra ? score(extra, query) - 10 : 0);
|
|
86
|
+
if (s > 0) out.push({ group: group, label: label, hint: hint, url: url, score: s });
|
|
87
|
+
};
|
|
88
|
+
add('Go to', 'Home', 'All workspaces', hubUrl('home'));
|
|
89
|
+
data.workspaces.forEach(function (w) { add('Go to', w.label, w.key + (w.kind === 'files' ? ' · files' : ''), w.url, w.key); });
|
|
90
|
+
data.pages.forEach(function (p) { add('Go to', p.label, p.hint, p.url); });
|
|
91
|
+
if (query) {
|
|
92
|
+
var shown = 0;
|
|
93
|
+
data.projects.forEach(function (p) {
|
|
94
|
+
if (shown >= 40) return;
|
|
95
|
+
var s = score(p.name, query);
|
|
96
|
+
if (s > 0) { out.push({ group: 'Projects', label: p.name, hint: p.ws + (p.ddev ? ' · open the site' : ' · open the workspace'), url: p.url, score: s }); shown += 1; }
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
actions().forEach(function (a) { var s = score(a.label, query); if (s > 0) out.push({ group: a.group, label: a.label, hint: a.hint, run: a.run, score: s - 5 }); });
|
|
100
|
+
// Letters-in-order matches (score 20) are only a fallback: when something contains the query, they are noise.
|
|
101
|
+
if (query && out.some(function (x) { return x.score >= 40; })) out = out.filter(function (x) { return x.score >= 40; });
|
|
102
|
+
out.sort(function (a, b) { return b.score - a.score; });
|
|
103
|
+
var order = { 'Go to': 0, Projects: 1, Do: 2 };
|
|
104
|
+
var grouped = out.slice(0, 40);
|
|
105
|
+
grouped.sort(function (a, b) { return (order[a.group] - order[b.group]) || (b.score - a.score); });
|
|
106
|
+
return grouped;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function sheet() {
|
|
110
|
+
var rows = [['Ctrl or Cmd + K, or /', 'Open this palette'], ['?', 'This sheet'], ['Esc', 'Close']];
|
|
111
|
+
Object.keys(CHORDS).forEach(function (k) {
|
|
112
|
+
var c = CHORDS[k];
|
|
113
|
+
var exists = c[0].charAt(0) === '/' || c[0] === 'home' || (data && data.workspaces.some(function (w) { return w.key === c[0]; }));
|
|
114
|
+
if (exists) rows.push(['g ' + k, c[1]]);
|
|
115
|
+
});
|
|
116
|
+
return rows;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function render(q) {
|
|
120
|
+
if (!list) return;
|
|
121
|
+
list.textContent = '';
|
|
122
|
+
if (q === '?') {
|
|
123
|
+
var h = document.createElement('li');
|
|
124
|
+
h.className = 'palette-group'; h.setAttribute('role', 'presentation'); h.textContent = 'Keyboard shortcuts';
|
|
125
|
+
list.appendChild(h);
|
|
126
|
+
sheet().forEach(function (r) {
|
|
127
|
+
var li = document.createElement('li');
|
|
128
|
+
li.className = 'palette-row palette-shortcut'; li.setAttribute('role', 'presentation');
|
|
129
|
+
var k = document.createElement('kbd'); k.textContent = r[0];
|
|
130
|
+
var s = document.createElement('span'); s.textContent = r[1];
|
|
131
|
+
li.appendChild(k); li.appendChild(s); list.appendChild(li);
|
|
132
|
+
});
|
|
133
|
+
items = []; active = -1;
|
|
134
|
+
input.removeAttribute('aria-activedescendant');
|
|
135
|
+
announce('Keyboard shortcuts');
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
items = build(q);
|
|
139
|
+
active = items.length ? 0 : -1;
|
|
140
|
+
var last = '';
|
|
141
|
+
items.forEach(function (it, i) {
|
|
142
|
+
if (it.group !== last) {
|
|
143
|
+
var g = document.createElement('li');
|
|
144
|
+
g.className = 'palette-group'; g.setAttribute('role', 'presentation'); g.textContent = it.group;
|
|
145
|
+
list.appendChild(g); last = it.group;
|
|
146
|
+
}
|
|
147
|
+
var li = document.createElement('li');
|
|
148
|
+
li.id = 'palette-opt-' + i; li.className = 'palette-row'; li.setAttribute('role', 'option');
|
|
149
|
+
li.setAttribute('aria-selected', i === active ? 'true' : 'false');
|
|
150
|
+
var l = document.createElement('span'); l.className = 'palette-label'; l.textContent = it.label;
|
|
151
|
+
var m = document.createElement('span'); m.className = 'palette-hint'; m.textContent = it.hint || '';
|
|
152
|
+
li.appendChild(l); li.appendChild(m);
|
|
153
|
+
li.addEventListener('mousemove', function () { move(i, true); });
|
|
154
|
+
li.addEventListener('click', function () { choose(i); });
|
|
155
|
+
list.appendChild(li);
|
|
156
|
+
});
|
|
157
|
+
if (!items.length) {
|
|
158
|
+
var e = document.createElement('li');
|
|
159
|
+
e.className = 'palette-empty'; e.setAttribute('role', 'presentation'); e.textContent = 'Nothing matches “' + q + '”.';
|
|
160
|
+
list.appendChild(e);
|
|
161
|
+
}
|
|
162
|
+
if (active >= 0) input.setAttribute('aria-activedescendant', 'palette-opt-' + active); else input.removeAttribute('aria-activedescendant');
|
|
163
|
+
announce(items.length ? items.length + (items.length === 1 ? ' result' : ' results') : 'No results');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function announce(t) { if (live) live.textContent = t; }
|
|
167
|
+
|
|
168
|
+
function move(i, noScroll) {
|
|
169
|
+
if (!items.length) return;
|
|
170
|
+
active = (i + items.length) % items.length;
|
|
171
|
+
Array.prototype.forEach.call(list.querySelectorAll('[role=option]'), function (el, k) { el.setAttribute('aria-selected', k === active ? 'true' : 'false'); });
|
|
172
|
+
input.setAttribute('aria-activedescendant', 'palette-opt-' + active);
|
|
173
|
+
var el = document.getElementById('palette-opt-' + active);
|
|
174
|
+
if (el && !noScroll) el.scrollIntoView({ block: 'nearest' });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function choose(i) {
|
|
178
|
+
var it = items[i];
|
|
179
|
+
if (!it) return;
|
|
180
|
+
if (it.run) { it.run(); return; }
|
|
181
|
+
close();
|
|
182
|
+
location.href = it.url;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function inertOthers(on) {
|
|
186
|
+
Array.prototype.forEach.call(document.body.children, function (el) {
|
|
187
|
+
if (el === overlay || el.tagName === 'SCRIPT' || el.contains(overlay)) return;
|
|
188
|
+
if (on) { if (!('palWasInert' in el.dataset)) el.dataset.palWasInert = el.inert ? '1' : ''; el.inert = true; }
|
|
189
|
+
else if ('palWasInert' in el.dataset) { el.inert = el.dataset.palWasInert === '1'; delete el.dataset.palWasInert; }
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function ensure() {
|
|
194
|
+
if (overlay) return;
|
|
195
|
+
overlay = document.createElement('div');
|
|
196
|
+
overlay.id = 'palette'; overlay.hidden = true;
|
|
197
|
+
overlay.innerHTML = '<div class="palette-dialog" role="dialog" aria-modal="true" aria-label="Command palette">' +
|
|
198
|
+
'<input class="palette-input" type="text" role="combobox" aria-expanded="true" aria-controls="palette-list" aria-autocomplete="list" ' +
|
|
199
|
+
'autocomplete="off" spellcheck="false" placeholder="Go to a workspace, a project or a page…" aria-label="Go to">' +
|
|
200
|
+
'<ul id="palette-list" class="palette-list" role="listbox" aria-label="Results"></ul>' +
|
|
201
|
+
'<div class="palette-foot"><span><kbd>↑</kbd><kbd>↓</kbd> move</span><span><kbd>Enter</kbd> open</span><span><kbd>Esc</kbd> close</span><span><kbd>?</kbd> shortcuts</span></div>' +
|
|
202
|
+
'<div class="palette-live uk-hidden-visually" aria-live="polite"></div></div>';
|
|
203
|
+
document.body.appendChild(overlay);
|
|
204
|
+
input = overlay.querySelector('.palette-input');
|
|
205
|
+
list = overlay.querySelector('.palette-list');
|
|
206
|
+
live = overlay.querySelector('.palette-live');
|
|
207
|
+
overlay.addEventListener('mousedown', function (e) { if (e.target === overlay) close(); });
|
|
208
|
+
input.addEventListener('input', function () { render(input.value); });
|
|
209
|
+
input.addEventListener('keydown', function (e) {
|
|
210
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); move(active + 1); }
|
|
211
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); move(active - 1); }
|
|
212
|
+
else if (e.key === 'Enter') { e.preventDefault(); choose(active); }
|
|
213
|
+
else if (e.key === 'Escape') { e.preventDefault(); close(); }
|
|
214
|
+
else if (e.key === 'Tab') { e.preventDefault(); }
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function open(initial) {
|
|
219
|
+
ensure();
|
|
220
|
+
returnTo = document.activeElement;
|
|
221
|
+
var go = function () {
|
|
222
|
+
overlay.hidden = false;
|
|
223
|
+
inertOthers(true);
|
|
224
|
+
input.value = initial === '?' ? '' : (initial || '');
|
|
225
|
+
render(initial === '?' ? '?' : input.value);
|
|
226
|
+
input.focus();
|
|
227
|
+
};
|
|
228
|
+
if (data) { go(); return; }
|
|
229
|
+
fetch('/fragments/palette.json', { credentials: 'same-origin' }).then(function (r) { return r.json(); })
|
|
230
|
+
.then(function (d) { data = d; go(); })
|
|
231
|
+
.catch(function () { data = { workspaces: [], pages: [], projects: [] }; go(); });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function close() {
|
|
235
|
+
if (!overlay || overlay.hidden) return;
|
|
236
|
+
overlay.hidden = true;
|
|
237
|
+
inertOthers(false);
|
|
238
|
+
if (returnTo && returnTo.focus) { try { returnTo.focus(); } catch (_) { /* gone */ } }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
document.addEventListener('click', function (e) {
|
|
242
|
+
var t = e.target.closest && e.target.closest('.palette-trigger');
|
|
243
|
+
if (t) { e.preventDefault(); open(''); }
|
|
244
|
+
});
|
|
245
|
+
// The key hint says what this machine uses.
|
|
246
|
+
if (/Mac|iPhone|iPad/.test(navigator.platform || '')) {
|
|
247
|
+
Array.prototype.forEach.call(document.querySelectorAll('.palette-trigger-key'), function (k) { k.textContent = '⌘K'; });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
document.addEventListener('keydown', function (e) {
|
|
251
|
+
if (e.defaultPrevented) return;
|
|
252
|
+
var k = e.key;
|
|
253
|
+
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && k.toLowerCase() === 'k') {
|
|
254
|
+
e.preventDefault();
|
|
255
|
+
if (overlay && !overlay.hidden) close(); else open('');
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
|
259
|
+
if (overlay && !overlay.hidden) return;
|
|
260
|
+
if (typing(e.target) || modalOpen()) return;
|
|
261
|
+
if (chord && Date.now() - chord < 1200) {
|
|
262
|
+
chord = 0;
|
|
263
|
+
var c = CHORDS[k.toLowerCase()];
|
|
264
|
+
if (c) {
|
|
265
|
+
e.preventDefault();
|
|
266
|
+
var url = c[0].charAt(0) === '/' ? c[0] : hubUrl(c[0]);
|
|
267
|
+
if (url) { location.href = url; return; }
|
|
268
|
+
// The data is not loaded yet: load it, then go.
|
|
269
|
+
fetch('/fragments/palette.json', { credentials: 'same-origin' }).then(function (r) { return r.json(); })
|
|
270
|
+
.then(function (d) { data = d; var u = hubUrl(c[0]); if (u) location.href = u; });
|
|
271
|
+
}
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (k === '/') { e.preventDefault(); open(''); return; }
|
|
275
|
+
if (k === '?') { e.preventDefault(); open('?'); return; }
|
|
276
|
+
if (k === 'g') { chord = Date.now(); }
|
|
277
|
+
});
|
|
278
|
+
})();
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/* Drag to reorder the workspaces: the cards on the home page and the list in Settings.
|
|
2
|
+
*
|
|
3
|
+
* A list says it is sortable with data-sortable="<item selector>" and carries data-order, data-file and data-key
|
|
4
|
+
* for /actions/list-order. The dragged item follows the pointer, the others slide into place, the page scrolls
|
|
5
|
+
* at its edges, and the order is saved without redrawing the list. Alt+arrow keys move the focused item. */
|
|
6
|
+
(() => {
|
|
7
|
+
const SKIP = 'button, input, select, textarea, .ws-card-tool, .ws-backups, .ws-move, .ws-delete-step, [data-no-drag]';
|
|
8
|
+
const THRESHOLD = 6;
|
|
9
|
+
const EDGE = 64;
|
|
10
|
+
const EASE = 'cubic-bezier(.2,.8,.2,1)';
|
|
11
|
+
let drag = null;
|
|
12
|
+
let suppressClick = false;
|
|
13
|
+
|
|
14
|
+
const itemsOf = (list) => [...list.children].filter((el) => el.matches(list.dataset.sortable));
|
|
15
|
+
const orderOf = (list) => itemsOf(list).map((el) => el.dataset.ws).filter(Boolean);
|
|
16
|
+
// The visible box of an item: a grid card sits inside its column's gutter padding.
|
|
17
|
+
const visualOf = (item) => item.querySelector(':scope > .workspace-card') || item;
|
|
18
|
+
|
|
19
|
+
// How far an item is still sliding.
|
|
20
|
+
function offsetOf(el) {
|
|
21
|
+
const t = getComputedStyle(el).transform;
|
|
22
|
+
if (!t || t === 'none') return { x: 0, y: 0 };
|
|
23
|
+
const m = new DOMMatrixReadOnly(t);
|
|
24
|
+
return { x: m.m41, y: m.m42 };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Where an element sits in the layout, without the slide its item may still be making.
|
|
28
|
+
function slotRect(item, el = item) {
|
|
29
|
+
const r = el.getBoundingClientRect();
|
|
30
|
+
const o = offsetOf(item);
|
|
31
|
+
return new DOMRect(r.left - o.x, r.top - o.y, r.width, r.height);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A row's number and its first/last arrows follow its new place, since the list is not redrawn.
|
|
35
|
+
function renumber(list) {
|
|
36
|
+
const els = itemsOf(list);
|
|
37
|
+
els.forEach((el, i) => {
|
|
38
|
+
const n = el.querySelector(':scope > .ws-order');
|
|
39
|
+
if (n) n.textContent = String(i + 1);
|
|
40
|
+
const [up, down] = el.querySelectorAll(':scope > .ws-move > button');
|
|
41
|
+
if (up) up.disabled = i === 0;
|
|
42
|
+
if (down) down.disabled = i === els.length - 1;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Move items, then slide each from where it was to where it now is.
|
|
47
|
+
function flip(list, mutate) {
|
|
48
|
+
const els = itemsOf(list);
|
|
49
|
+
const before = new Map(els.map((el) => [el, el.getBoundingClientRect()]));
|
|
50
|
+
mutate();
|
|
51
|
+
renumber(list);
|
|
52
|
+
els.forEach((el) => {
|
|
53
|
+
const a = before.get(el);
|
|
54
|
+
const b = slotRect(el);
|
|
55
|
+
const dx = a.left - b.left;
|
|
56
|
+
const dy = a.top - b.top;
|
|
57
|
+
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return;
|
|
58
|
+
el.style.transition = 'none';
|
|
59
|
+
el.style.transform = `translate(${dx}px, ${dy}px)`;
|
|
60
|
+
requestAnimationFrame(() => {
|
|
61
|
+
el.style.transition = `transform 200ms ${EASE}`;
|
|
62
|
+
el.style.transform = '';
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function scrollerOf(el) {
|
|
68
|
+
for (let n = el.parentElement; n && n !== document.body; n = n.parentElement) {
|
|
69
|
+
const oy = getComputedStyle(n).overflowY;
|
|
70
|
+
if ((oy === 'auto' || oy === 'scroll') && n.scrollHeight > n.clientHeight) return n;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function start(e) {
|
|
76
|
+
const { list, item } = drag;
|
|
77
|
+
const vis = visualOf(item);
|
|
78
|
+
const rect = vis.getBoundingClientRect();
|
|
79
|
+
const ghost = item.cloneNode(true);
|
|
80
|
+
ghost.removeAttribute('id');
|
|
81
|
+
ghost.querySelectorAll('[id]').forEach((n) => n.removeAttribute('id'));
|
|
82
|
+
ghost.classList.add('sort-ghost');
|
|
83
|
+
ghost.setAttribute('aria-hidden', 'true');
|
|
84
|
+
Object.assign(ghost.style, { left: `${rect.left}px`, top: `${rect.top}px`, width: `${rect.width}px`, height: `${rect.height}px` });
|
|
85
|
+
// A card carries uk-position-relative, whose !important would put the copy back in the page flow.
|
|
86
|
+
ghost.style.setProperty('position', 'fixed', 'important');
|
|
87
|
+
document.body.appendChild(ghost);
|
|
88
|
+
requestAnimationFrame(() => ghost.classList.add('is-lifted'));
|
|
89
|
+
item.style.setProperty('--sort-inset-left', `${rect.left - item.getBoundingClientRect().left}px`);
|
|
90
|
+
item.classList.add('sort-placeholder');
|
|
91
|
+
document.body.classList.add('is-sorting');
|
|
92
|
+
window.getSelection()?.removeAllRanges();
|
|
93
|
+
Object.assign(drag, { ghost, rect, started: true, scroller: scrollerOf(list), next: item.nextSibling });
|
|
94
|
+
move(e);
|
|
95
|
+
drag.raf = requestAnimationFrame(autoScroll);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function move(e) {
|
|
99
|
+
drag.x = e.clientX;
|
|
100
|
+
drag.y = e.clientY;
|
|
101
|
+
drag.ghost.style.translate = `${e.clientX - drag.startX}px ${e.clientY - drag.startY}px`;
|
|
102
|
+
place();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Put the placeholder where the pointer is. An item under the pointer takes the placeholder's place; between
|
|
106
|
+
// items nothing moves, so a slide in progress never flips the order back.
|
|
107
|
+
function place() {
|
|
108
|
+
const { list, item, x, y } = drag;
|
|
109
|
+
const els = itemsOf(list);
|
|
110
|
+
const hit = els.find((el) => {
|
|
111
|
+
const r = el === item ? slotRect(el) : slotRect(el, visualOf(el));
|
|
112
|
+
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
|
|
113
|
+
});
|
|
114
|
+
let target = null;
|
|
115
|
+
let after = false;
|
|
116
|
+
if (hit && hit !== item) {
|
|
117
|
+
target = hit;
|
|
118
|
+
after = els.indexOf(hit) > els.indexOf(item);
|
|
119
|
+
} else if (!hit) {
|
|
120
|
+
const last = els[els.length - 1];
|
|
121
|
+
const first = els[0];
|
|
122
|
+
const lr = slotRect(last, visualOf(last));
|
|
123
|
+
const fr = slotRect(first, visualOf(first));
|
|
124
|
+
if (last !== item && (y > lr.bottom || (y >= lr.top && x > lr.right))) { target = last; after = true; }
|
|
125
|
+
else if (first !== item && (y < fr.top || (y <= fr.bottom && x < fr.left))) { target = first; after = false; }
|
|
126
|
+
}
|
|
127
|
+
if (!target) return;
|
|
128
|
+
flip(list, () => list.insertBefore(item, after ? target.nextSibling : target));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function autoScroll() {
|
|
132
|
+
if (!drag || !drag.started) return;
|
|
133
|
+
const s = drag.scroller;
|
|
134
|
+
const top = s ? s.getBoundingClientRect().top : 0;
|
|
135
|
+
const bottom = s ? s.getBoundingClientRect().bottom : window.innerHeight;
|
|
136
|
+
let dy = 0;
|
|
137
|
+
if (drag.y < top + EDGE) dy = -Math.ceil((top + EDGE - drag.y) / 4);
|
|
138
|
+
else if (drag.y > bottom - EDGE) dy = Math.ceil((drag.y - (bottom - EDGE)) / 4);
|
|
139
|
+
if (dy) {
|
|
140
|
+
if (s) s.scrollTop += dy; else window.scrollBy(0, dy);
|
|
141
|
+
place();
|
|
142
|
+
}
|
|
143
|
+
drag.raf = requestAnimationFrame(autoScroll);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Land the ghost on the placeholder, then let the item show again.
|
|
147
|
+
function finish(cancelled) {
|
|
148
|
+
const d = drag;
|
|
149
|
+
drag = null;
|
|
150
|
+
cancelAnimationFrame(d.raf);
|
|
151
|
+
document.body.classList.remove('is-sorting');
|
|
152
|
+
if (cancelled) flip(d.list, () => d.list.insertBefore(d.item, d.next));
|
|
153
|
+
const to = slotRect(d.item, visualOf(d.item));
|
|
154
|
+
d.ghost.classList.remove('is-lifted');
|
|
155
|
+
d.ghost.classList.add('is-landing');
|
|
156
|
+
d.ghost.style.translate = `${to.left - d.rect.left}px ${to.top - d.rect.top}px`;
|
|
157
|
+
const done = () => { d.ghost.remove(); d.item.classList.remove('sort-placeholder'); d.item.style.removeProperty('--sort-inset-left'); };
|
|
158
|
+
d.ghost.addEventListener('transitionend', done, { once: true });
|
|
159
|
+
setTimeout(done, 260);
|
|
160
|
+
if (!cancelled && orderOf(d.list).join(',') !== d.was) save(d.list, d.was);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Save without redrawing: the list already shows the new order. A refusal brings back the server's list.
|
|
164
|
+
async function save(list, was) {
|
|
165
|
+
const order = orderOf(list).join(',');
|
|
166
|
+
const body = new URLSearchParams({ order, was, file: list.dataset.file || 'settings.yml', key: list.dataset.key || 'workspaces' });
|
|
167
|
+
if (list.dataset.sortableView) body.set('view', list.dataset.sortableView);
|
|
168
|
+
const wrap = list.closest('#workspace-cards-wrap, [id^="list-editor"]');
|
|
169
|
+
let res;
|
|
170
|
+
let html = '';
|
|
171
|
+
try {
|
|
172
|
+
res = await fetch('/actions/list-order', { method: 'POST', body, headers: { 'HX-Request': 'true' }, credentials: 'same-origin' });
|
|
173
|
+
html = await res.text();
|
|
174
|
+
} catch (_) {
|
|
175
|
+
res = null;
|
|
176
|
+
}
|
|
177
|
+
if (res && res.ok) {
|
|
178
|
+
list.dataset.order = order;
|
|
179
|
+
const msg = new DOMParser().parseFromString(html, 'text/html').querySelector('.msg');
|
|
180
|
+
if (msg && wrap) { wrap.appendChild(msg); if (typeof window.flashResults === 'function') window.flashResults(wrap); }
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (wrap && html) {
|
|
184
|
+
const id = wrap.id;
|
|
185
|
+
wrap.outerHTML = html;
|
|
186
|
+
const fresh = document.getElementById(id);
|
|
187
|
+
if (fresh && window.htmx) window.htmx.process(fresh);
|
|
188
|
+
if (fresh && typeof window.flashResults === 'function') window.flashResults(fresh);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
document.addEventListener('pointerdown', (e) => {
|
|
193
|
+
if (drag || e.button !== 0 || !e.isPrimary) return;
|
|
194
|
+
const list = e.target.closest('[data-sortable]');
|
|
195
|
+
if (!list) return;
|
|
196
|
+
const item = e.target.closest(list.dataset.sortable);
|
|
197
|
+
if (!item || item.parentElement !== list || e.target.closest(SKIP)) return;
|
|
198
|
+
// On a touch screen the page must still scroll, so only the handle drags there.
|
|
199
|
+
if (e.pointerType === 'touch' && !e.target.closest('.ws-card-handle, .ws-handle')) return;
|
|
200
|
+
drag = { list, item, startX: e.clientX, startY: e.clientY, x: e.clientX, y: e.clientY, started: false, was: orderOf(list).join(',') };
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
document.addEventListener('pointermove', (e) => {
|
|
204
|
+
if (!drag) return;
|
|
205
|
+
if (!drag.started) {
|
|
206
|
+
if (Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < THRESHOLD) return;
|
|
207
|
+
start(e);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
e.preventDefault();
|
|
211
|
+
move(e);
|
|
212
|
+
}, { passive: false });
|
|
213
|
+
|
|
214
|
+
document.addEventListener('pointerup', () => {
|
|
215
|
+
if (!drag) return;
|
|
216
|
+
if (!drag.started) { drag = null; return; }
|
|
217
|
+
suppressClick = true;
|
|
218
|
+
setTimeout(() => { suppressClick = false; }, 0);
|
|
219
|
+
finish(false);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
document.addEventListener('pointercancel', () => { if (drag && drag.started) finish(true); else drag = null; });
|
|
223
|
+
|
|
224
|
+
// A drag that ends on a card is not a click on its link.
|
|
225
|
+
document.addEventListener('click', (e) => {
|
|
226
|
+
if (!suppressClick) return;
|
|
227
|
+
suppressClick = false;
|
|
228
|
+
e.preventDefault();
|
|
229
|
+
e.stopPropagation();
|
|
230
|
+
}, true);
|
|
231
|
+
|
|
232
|
+
// Links and images are natively draggable, which would take the pointer away mid-drag.
|
|
233
|
+
document.addEventListener('dragstart', (e) => { if (e.target.closest && e.target.closest('[data-sortable]')) e.preventDefault(); });
|
|
234
|
+
|
|
235
|
+
document.addEventListener('keydown', (e) => {
|
|
236
|
+
if (e.key === 'Escape' && drag && drag.started) { e.preventDefault(); finish(true); return; }
|
|
237
|
+
if (!e.altKey || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return;
|
|
238
|
+
const list = e.target.closest && e.target.closest('[data-sortable]');
|
|
239
|
+
const item = list && e.target.closest(list.dataset.sortable);
|
|
240
|
+
if (!item || item.parentElement !== list) return;
|
|
241
|
+
const els = itemsOf(list);
|
|
242
|
+
const i = els.indexOf(item);
|
|
243
|
+
const j = e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? i - 1 : i + 1;
|
|
244
|
+
if (j < 0 || j >= els.length) return;
|
|
245
|
+
e.preventDefault();
|
|
246
|
+
const was = orderOf(list).join(',');
|
|
247
|
+
flip(list, () => list.insertBefore(item, j > i ? els[j].nextSibling : els[j]));
|
|
248
|
+
e.target.focus();
|
|
249
|
+
save(list, was);
|
|
250
|
+
});
|
|
251
|
+
})();
|