@hypit/hypit 0.1.5 → 0.1.7
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 +1 -1
- package/examples/semantic-composition/README.md +2 -1
- package/examples/semantic-composition/chat.svml +1 -1
- package/examples/semantic-composition/hypit.runtime.json +1 -1
- package/package.json +4 -1
- package/packages/caption-fine/README.md +7 -0
- package/packages/cli/src/view.ts +4 -3
- package/packages/film/src/surface.ts +4 -1
- package/packages/provider-hypihub/README.md +17 -3
- package/packages/provider-hypihub/src/oauth.ts +1 -1
- package/packages/provider-hypihub/src/provider.ts +35 -9
- package/packages/provider-hypihub/src/upload.ts +37 -4
- package/packages/runtime-local/README.md +17 -0
- package/packages/script/README.md +15 -3
- package/packages/script/src/manifest.ts +2 -1
- package/packages/script/src/parser.ts +45 -6
- package/packages/studio/LOCALIZATION.md +98 -0
- package/packages/studio/README.md +13 -0
- package/packages/studio/locales/en.json +197 -0
- package/packages/studio/locales/zh-CN.json +191 -0
- package/packages/studio/src/localization-node.ts +46 -0
- package/packages/studio/src/localization.ts +85 -0
- package/packages/studio/src/style.css +15 -3
- package/packages/studio/src/ui/artifact-name.ts +8 -6
- package/packages/studio/src/ui/artifact-preview.ts +5 -4
- package/packages/studio/src/ui/code.ts +17 -16
- package/packages/studio/src/ui/comments.ts +26 -25
- package/packages/studio/src/ui/dropdown.ts +68 -0
- package/packages/studio/src/ui/i18n.ts +109 -0
- package/packages/studio/src/ui/icons.ts +1 -0
- package/packages/studio/src/ui/library.ts +76 -65
- package/packages/studio/src/ui/main.ts +84 -131
- package/packages/studio/src/ui/scrub-preview.ts +8 -6
- package/packages/studio/src/ui/stage.ts +28 -27
- package/packages/studio/src/ui/timeline.ts +17 -14
- package/packages/studio/start.ts +39 -22
|
@@ -142,7 +142,7 @@ button:focus-visible, textarea:focus-visible {
|
|
|
142
142
|
white-space: nowrap;
|
|
143
143
|
}
|
|
144
144
|
.topbar-right { justify-self: end; display: flex; align-items: center; min-width: 0; }
|
|
145
|
-
.view-tabs { display: flex; align-items: center; gap: 2px; margin-left: 16px; padding: 2px; border-radius: 5px; background: var(--subtab-surface); }
|
|
145
|
+
.view-tabs { display: flex; flex: 0 0 auto; white-space: nowrap; align-items: center; gap: 2px; margin-left: 16px; padding: 2px; border-radius: 5px; background: var(--subtab-surface); }
|
|
146
146
|
.view-tabs button {
|
|
147
147
|
display: flex; align-items: center; gap: 7px; height: 24px; padding: 0 10px;
|
|
148
148
|
border: 0; border-radius: 4px; color: var(--text-3); background: transparent;
|
|
@@ -150,6 +150,18 @@ button:focus-visible, textarea:focus-visible {
|
|
|
150
150
|
.view-tabs button[aria-selected="true"] { color: var(--subtab-active-text); background: var(--subtab-active-surface); }
|
|
151
151
|
.view-tabs button:focus-visible { outline: 1px solid #8a8f99; outline-offset: -1px; }
|
|
152
152
|
.view-tabs .icon { width: 14px; height: 14px; }
|
|
153
|
+
.parameter-select.language-select { width: auto; flex: 0 0 auto; margin-left: 10px; }
|
|
154
|
+
.language-select .language-toggle {
|
|
155
|
+
display: grid; place-items: center; grid-template-columns: 1fr;
|
|
156
|
+
width: 28px; height: 28px; padding: 0;
|
|
157
|
+
color: var(--subtab-active-text); background: var(--subtab-surface);
|
|
158
|
+
border-color: var(--line);
|
|
159
|
+
}
|
|
160
|
+
.language-toggle .icon { width: 16px; height: 16px; }
|
|
161
|
+
.language-select .parameter-select-menu { width: max-content; min-width: 150px; max-width: 240px; }
|
|
162
|
+
.language-select .parameter-select-option { font-family: var(--font-ui); text-align: start; }
|
|
163
|
+
.topbar { position: relative; z-index: 40; }
|
|
164
|
+
|
|
153
165
|
.meta {
|
|
154
166
|
color: var(--text-3);
|
|
155
167
|
font: 500 11px/1 var(--font-ui);
|
|
@@ -1560,8 +1572,8 @@ button:focus-visible, textarea:focus-visible {
|
|
|
1560
1572
|
.failure:empty { display: none; }
|
|
1561
1573
|
|
|
1562
1574
|
@media (max-width: 920px) {
|
|
1563
|
-
.meta, .topbar-right { display: none; }
|
|
1564
|
-
.topbar { grid-template-columns:
|
|
1575
|
+
.meta, .topbar-right .status { display: none; }
|
|
1576
|
+
.topbar { grid-template-columns: auto minmax(0, 1fr) auto; }
|
|
1565
1577
|
.upper-shell { grid-template-columns: minmax(320px, var(--source-width)) 8px minmax(300px, 1fr); }
|
|
1566
1578
|
.workspace-handle, .workspace-panel { display: none; }
|
|
1567
1579
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { uiAttr, uiText, userText } from "./i18n.js";
|
|
2
|
+
|
|
1
3
|
/** Two-line label with an expanding editor; the caller owns persistence. */
|
|
2
4
|
export function createArtifactName(options: {
|
|
3
5
|
readonly name: string;
|
|
@@ -15,7 +17,7 @@ export function createArtifactName(options: {
|
|
|
15
17
|
text.className = "artifact-name-text";
|
|
16
18
|
text.textContent = name;
|
|
17
19
|
label.append(text);
|
|
18
|
-
label
|
|
20
|
+
uiAttr(label, "title", options.editable ? "library.rename-hint" : "library.name-pending", { name });
|
|
19
21
|
element.append(label);
|
|
20
22
|
label.addEventListener("click", (event) => { event.stopPropagation(); options.select(); });
|
|
21
23
|
let editing = false;
|
|
@@ -30,7 +32,7 @@ export function createArtifactName(options: {
|
|
|
30
32
|
field.className = "artifact-name-editor";
|
|
31
33
|
field.value = name;
|
|
32
34
|
field.rows = 1;
|
|
33
|
-
field
|
|
35
|
+
uiAttr(field, "aria-label", "library.display-name");
|
|
34
36
|
const error = document.createElement("span");
|
|
35
37
|
error.className = "artifact-name-error";
|
|
36
38
|
error.setAttribute("role", "status");
|
|
@@ -53,17 +55,17 @@ export function createArtifactName(options: {
|
|
|
53
55
|
if (!editing || saving) return;
|
|
54
56
|
const value = field.value.trim();
|
|
55
57
|
if (value === name) { close(); return; }
|
|
56
|
-
if (value.length === 0) { error
|
|
58
|
+
if (value.length === 0) { uiText(error, "library.enter-name"); return; }
|
|
57
59
|
saving = true;
|
|
58
60
|
field.readOnly = true;
|
|
59
|
-
error
|
|
61
|
+
uiText(error, "common.saving");
|
|
60
62
|
try {
|
|
61
63
|
name = await options.save(value);
|
|
62
64
|
text.textContent = name;
|
|
63
|
-
label
|
|
65
|
+
uiAttr(label, "title", "library.rename-hint", { name });
|
|
64
66
|
close();
|
|
65
67
|
} catch (cause) {
|
|
66
|
-
error
|
|
68
|
+
userText(error, cause instanceof Error ? cause.message : String(cause));
|
|
67
69
|
} finally {
|
|
68
70
|
saving = false;
|
|
69
71
|
field.readOnly = false;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { uiAttr, uiText, userText } from "./i18n.js";
|
|
1
2
|
import type { StudioArtifactView } from "../shared.js";
|
|
2
3
|
import { audioPreview } from "./material-preview.js";
|
|
3
4
|
|
|
@@ -35,7 +36,7 @@ export function createArtifactPreview(changed: () => void) {
|
|
|
35
36
|
error.setAttribute("role", "status");
|
|
36
37
|
const failed = () => {
|
|
37
38
|
if (generation !== current) return;
|
|
38
|
-
error
|
|
39
|
+
uiText(error, "player.media-failed");
|
|
39
40
|
error.hidden = false;
|
|
40
41
|
changed();
|
|
41
42
|
};
|
|
@@ -61,14 +62,14 @@ export function createArtifactPreview(changed: () => void) {
|
|
|
61
62
|
if (audio) {
|
|
62
63
|
const wave = document.createElement("div");
|
|
63
64
|
wave.className = "stage-audio-wave";
|
|
64
|
-
wave
|
|
65
|
+
uiText(wave, "library.audio");
|
|
65
66
|
element.append(wave);
|
|
66
67
|
void audioPreview(url).then((waveform) => {
|
|
67
68
|
if (generation !== current || waveform === undefined) return;
|
|
68
69
|
const image = document.createElement("img");
|
|
69
70
|
image.src = waveform;
|
|
70
|
-
image
|
|
71
|
-
wave.replaceChildren(image);
|
|
71
|
+
uiAttr(image, "alt", "player.audio-waveform");
|
|
72
|
+
userText(wave, ""); wave.replaceChildren(image);
|
|
72
73
|
});
|
|
73
74
|
}
|
|
74
75
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { t, userText, uiAttribute, uiText, uiAttr } from "./i18n.js";
|
|
1
2
|
import type { Range, StudioSnapshot, StudioSourceView } from "../shared.js";
|
|
2
3
|
import { icon, setIcon } from "./icons.js";
|
|
3
4
|
import { intentTones } from "./markers.js";
|
|
@@ -77,10 +78,10 @@ export function createCodePane(): CodePane {
|
|
|
77
78
|
<strong class="code-location" data-path></strong>
|
|
78
79
|
<div class="code-actions">
|
|
79
80
|
<span class="code-save-state" data-save-state></span>
|
|
80
|
-
<button type="button" class="icon-button code-wrap" data-wrap aria-label
|
|
81
|
+
<button type="button" class="icon-button code-wrap" data-wrap ${uiAttribute("aria-label", "source.wrap-lines")} aria-pressed="false" ${uiAttribute("title", "source.wrap-lines")}>
|
|
81
82
|
${icon("wrap")}
|
|
82
83
|
</button>
|
|
83
|
-
<button type="button" class="icon-button code-mode" data-mode aria-label
|
|
84
|
+
<button type="button" class="icon-button code-mode" data-mode ${uiAttribute("aria-label", "source.edit-source")} ${uiAttribute("title", "source.edit-source")}>
|
|
84
85
|
<span data-mode-icon>${icon("edit")}</span>
|
|
85
86
|
</button>
|
|
86
87
|
</div>`;
|
|
@@ -88,7 +89,7 @@ export function createCodePane(): CodePane {
|
|
|
88
89
|
element.className = "code";
|
|
89
90
|
element.innerHTML = `
|
|
90
91
|
<div class="code-scroll"><svg class="range-canvas" aria-hidden="true"></svg></div>
|
|
91
|
-
<textarea class="code-editor" data-editor spellcheck="false" aria-label
|
|
92
|
+
<textarea class="code-editor" data-editor spellcheck="false" ${uiAttribute("aria-label", "source.svml-source")}></textarea>`;
|
|
92
93
|
const path = toolbar.querySelector<HTMLElement>("[data-path]")!;
|
|
93
94
|
const scroll = element.querySelector<HTMLElement>(".code-scroll")!;
|
|
94
95
|
const canvas = element.querySelector<SVGSVGElement>(".range-canvas")!;
|
|
@@ -116,8 +117,8 @@ export function createCodePane(): CodePane {
|
|
|
116
117
|
wrapped = value;
|
|
117
118
|
element.classList.toggle("is-wrapped", wrapped);
|
|
118
119
|
wrap.setAttribute("aria-pressed", String(wrapped));
|
|
119
|
-
wrap
|
|
120
|
-
wrap
|
|
120
|
+
uiAttr(wrap, "aria-label", wrapped ? "source.keep-lines-unwrapped" : "source.wrap-lines");
|
|
121
|
+
uiAttr(wrap, "title", wrapped ? "source.keep-lines-unwrapped" : "source.wrap-lines");
|
|
121
122
|
window.requestAnimationFrame(draw);
|
|
122
123
|
};
|
|
123
124
|
|
|
@@ -125,8 +126,8 @@ export function createCodePane(): CodePane {
|
|
|
125
126
|
editing = value;
|
|
126
127
|
element.classList.toggle("is-editing", editing);
|
|
127
128
|
setIcon(modeIcon, editing ? "eye" : "edit");
|
|
128
|
-
mode
|
|
129
|
-
mode
|
|
129
|
+
uiAttr(mode, "aria-label", editing ? "source.read-source" : "source.edit-source");
|
|
130
|
+
uiAttr(mode, "title", editing ? "source.read-source" : "source.edit-source");
|
|
130
131
|
if (editing) {
|
|
131
132
|
editor.value = sourceText;
|
|
132
133
|
editingRevision = snapshotRevision;
|
|
@@ -143,7 +144,7 @@ export function createCodePane(): CodePane {
|
|
|
143
144
|
saveInFlight = true;
|
|
144
145
|
let accepted = false;
|
|
145
146
|
let acceptedRevision = savingRevision;
|
|
146
|
-
saveState
|
|
147
|
+
uiText(saveState, "common.saving");
|
|
147
148
|
saveState.className = "code-save-state saving";
|
|
148
149
|
try {
|
|
149
150
|
const response = await fetch("/__studio/source", {
|
|
@@ -153,7 +154,7 @@ export function createCodePane(): CodePane {
|
|
|
153
154
|
});
|
|
154
155
|
if (!response.ok) {
|
|
155
156
|
const reason = await response.text();
|
|
156
|
-
throw new Error(reason ||
|
|
157
|
+
throw new Error(reason || t("source.save-failed-status", { status: response.status }));
|
|
157
158
|
}
|
|
158
159
|
const result = await response.json() as { readonly revision?: unknown };
|
|
159
160
|
if (typeof result.revision === "number") acceptedRevision = result.revision;
|
|
@@ -163,15 +164,15 @@ export function createCodePane(): CodePane {
|
|
|
163
164
|
sourceText = savingText;
|
|
164
165
|
dirty = false;
|
|
165
166
|
}
|
|
166
|
-
saveState
|
|
167
|
+
uiText(saveState, "common.saved");
|
|
167
168
|
saveState.className = "code-save-state saved";
|
|
168
169
|
window.setTimeout(() => {
|
|
169
|
-
if (!dirty) saveState
|
|
170
|
+
if (!dirty) userText(saveState, "");
|
|
170
171
|
}, 1200);
|
|
171
172
|
} catch (error) {
|
|
172
|
-
saveState
|
|
173
|
-
? "
|
|
174
|
-
: "
|
|
173
|
+
uiText(saveState, error instanceof Error && error.message.includes("changed")
|
|
174
|
+
? "source.changed-outside-studio"
|
|
175
|
+
: "common.save-failed");
|
|
175
176
|
saveState.className = "code-save-state error";
|
|
176
177
|
} finally {
|
|
177
178
|
saveInFlight = false;
|
|
@@ -189,7 +190,7 @@ export function createCodePane(): CodePane {
|
|
|
189
190
|
wrap.addEventListener("click", () => setWrapped(!wrapped));
|
|
190
191
|
editor.addEventListener("input", () => {
|
|
191
192
|
dirty = true;
|
|
192
|
-
saveState
|
|
193
|
+
uiText(saveState, "source.unsaved");
|
|
193
194
|
saveState.className = "code-save-state dirty";
|
|
194
195
|
if (saveTimer !== undefined) window.clearTimeout(saveTimer);
|
|
195
196
|
saveTimer = window.setTimeout(() => void save(), 480);
|
|
@@ -308,7 +309,7 @@ export function createCodePane(): CodePane {
|
|
|
308
309
|
imports: [],
|
|
309
310
|
};
|
|
310
311
|
if (activeSource !== undefined && activeSource.path !== sourceFile.path && dirty) {
|
|
311
|
-
saveState
|
|
312
|
+
uiText(saveState, "source.saving-before-switch");
|
|
312
313
|
saveState.className = "code-save-state saving";
|
|
313
314
|
void save();
|
|
314
315
|
return false;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { t, uiLabel, uiAttribute, uiText, uiAttr, type Message } from "./i18n.js";
|
|
1
2
|
import { feedbackClock } from "../feedback.js";
|
|
2
3
|
import type { FeedbackComment, FeedbackMutation, FeedbackView } from "../feedback.js";
|
|
3
4
|
import { icon } from "./icons.js";
|
|
@@ -11,28 +12,28 @@ export function createComments(store: Store, stage: Stage) {
|
|
|
11
12
|
element.className = "comments-panel";
|
|
12
13
|
element.innerHTML = `
|
|
13
14
|
<div class="pane-heading comments-heading">
|
|
14
|
-
<div class="pane-title">${icon("comments")}<h2
|
|
15
|
-
<button type="button" class="comments-icon" data-refresh aria-label
|
|
15
|
+
<div class="pane-title">${icon("comments")}<h2>${uiLabel("app.comments")}</h2><span data-count></span></div>
|
|
16
|
+
<button type="button" class="comments-icon" data-refresh ${uiAttribute("aria-label", "comments.refresh-comments")} ${uiAttribute("title", "comments.refresh-comments")}>${icon("refresh")}</button>
|
|
16
17
|
</div>
|
|
17
|
-
<div class="comments-filters" role="group" aria-label
|
|
18
|
-
<button type="button" data-filter="all" aria-pressed="true"
|
|
19
|
-
<button type="button" data-filter="open" aria-pressed="false"
|
|
20
|
-
<button type="button" data-filter="done" aria-pressed="false"
|
|
18
|
+
<div class="comments-filters" role="group" ${uiAttribute("aria-label", "comments.filter-comments")}>
|
|
19
|
+
<button type="button" data-filter="all" aria-pressed="true">${uiLabel("comments.all")}</button>
|
|
20
|
+
<button type="button" data-filter="open" aria-pressed="false">${uiLabel("comments.open")}</button>
|
|
21
|
+
<button type="button" data-filter="done" aria-pressed="false">${uiLabel("comments.done")}</button>
|
|
21
22
|
</div>
|
|
22
23
|
<div class="comments-error" role="status" aria-live="polite" hidden></div>
|
|
23
|
-
<div class="comments-list" aria-label
|
|
24
|
+
<div class="comments-list" ${uiAttribute("aria-label", "app.comments")}></div>
|
|
24
25
|
<form class="comment-composer">
|
|
25
26
|
<div class="comment-anchor">
|
|
26
|
-
<button type="button" class="comment-time" data-current-time title
|
|
27
|
+
<button type="button" class="comment-time" data-current-time ${uiAttribute("title", "comments.use-current-frame")} ${uiAttribute("aria-label", "comments.use-current-frame")}>${icon("when")}<span>00:00.00</span></button>
|
|
27
28
|
</div>
|
|
28
29
|
<div class="comment-input">
|
|
29
|
-
<textarea rows="3" aria-label
|
|
30
|
+
<textarea rows="3" ${uiAttribute("aria-label", "comments.write-a-comment")} ${uiAttribute("placeholder", "comments.placeholder")} spellcheck="true" enterkeyhint="send"></textarea>
|
|
30
31
|
<div class="comment-input-actions">
|
|
31
32
|
<div class="comment-tools">
|
|
32
|
-
<button type="button" class="comments-icon" aria-label
|
|
33
|
-
<button type="button" class="comments-icon" aria-label
|
|
33
|
+
<button type="button" class="comments-icon" ${uiAttribute("aria-label", "comments.emoji-soon")} ${uiAttribute("title", "comments.emoji-soon")} disabled>${icon("smile")}</button>
|
|
34
|
+
<button type="button" class="comments-icon" ${uiAttribute("aria-label", "comments.draw-soon")} ${uiAttribute("title", "comments.draw-soon")} disabled>${icon("edit")}</button>
|
|
34
35
|
</div>
|
|
35
|
-
<button class="comment-submit" type="submit" aria-label
|
|
36
|
+
<button class="comment-submit" type="submit" ${uiAttribute("aria-label", "comments.send-comment")} ${uiAttribute("title", "comments.send-shortcut")} disabled>${icon("send")}</button>
|
|
36
37
|
</div>
|
|
37
38
|
</div>
|
|
38
39
|
</form>`;
|
|
@@ -68,10 +69,10 @@ export function createComments(store: Store, stage: Stage) {
|
|
|
68
69
|
submit.disabled = busy || view === undefined || anchor === undefined || input.value.trim().length === 0;
|
|
69
70
|
};
|
|
70
71
|
const capture = (): void => { stage.pause(); anchor = currentAnchor(); renderAnchor(); };
|
|
71
|
-
const button = (name: string, label:
|
|
72
|
+
const button = (name: string, label: Message, action: () => void): HTMLButtonElement => {
|
|
72
73
|
const node = document.createElement("button");
|
|
73
74
|
node.type = "button"; node.className = "comments-icon";
|
|
74
|
-
node.innerHTML = icon(name); node
|
|
75
|
+
node.innerHTML = icon(name); uiAttr(node, "title", label); uiAttr(node, "aria-label", label);
|
|
75
76
|
node.disabled = busy;
|
|
76
77
|
node.addEventListener("click", (event) => { event.stopPropagation(); action(); });
|
|
77
78
|
return node;
|
|
@@ -101,8 +102,8 @@ export function createComments(store: Store, stage: Stage) {
|
|
|
101
102
|
const empty = document.createElement("div");
|
|
102
103
|
empty.className = "comments-empty";
|
|
103
104
|
empty.innerHTML = `${icon(filter === "done" ? "check" : "comments")}<strong></strong><p></p>`;
|
|
104
|
-
empty.querySelector("strong")
|
|
105
|
-
empty.querySelector("p")
|
|
105
|
+
uiText(empty.querySelector("strong")!, view === undefined ? "comments.loading-comments" : filter === "done" ? "comments.no-completed-comments" : filter === "open" ? "comments.caught-up" : "comments.empty");
|
|
106
|
+
uiText(empty.querySelector("p")!, filter === "all" ? "comments.empty-hint" : "comments.filter-hint");
|
|
106
107
|
list.append(empty);
|
|
107
108
|
}
|
|
108
109
|
for (const { comment, number } of comments) {
|
|
@@ -114,31 +115,31 @@ export function createComments(store: Store, stage: Stage) {
|
|
|
114
115
|
const label = document.createElement("span"); label.className = "comment-number"; label.textContent = `#${number}`;
|
|
115
116
|
const time = document.createElement("span"); time.className = "comment-time"; time.textContent = feedbackClock(comment.at);
|
|
116
117
|
location.append(time);
|
|
117
|
-
location
|
|
118
|
+
uiAttr(location, "title", "comments.go-to-this-frame"); location.addEventListener("click", () => locate(comment));
|
|
118
119
|
row.addEventListener("click", (event) => {
|
|
119
120
|
if ((event.target as Element).closest("button, form") !== null || window.getSelection()?.isCollapsed === false) return;
|
|
120
121
|
locate(comment);
|
|
121
122
|
});
|
|
122
123
|
const actions = document.createElement("div"); actions.className = "comment-actions";
|
|
123
|
-
const resolve = button("check", comment.resolved ? "
|
|
124
|
+
const resolve = button("check", comment.resolved ? "comments.reopen-comment" : "comments.mark-complete", () => {
|
|
124
125
|
void mutate({ type: "replace", before: comment, comment: { ...comment, resolved: !comment.resolved } });
|
|
125
126
|
});
|
|
126
127
|
resolve.classList.toggle("is-done", comment.resolved === true);
|
|
127
128
|
resolve.setAttribute("aria-pressed", String(comment.resolved === true));
|
|
128
|
-
actions.append(label, resolve, button("edit", "
|
|
129
|
+
actions.append(label, resolve, button("edit", "comments.edit-comment", () => {
|
|
129
130
|
editing = { id: comment.id, before: comment, text: comment.text }; selected = comment.id; render();
|
|
130
131
|
list.querySelector<HTMLTextAreaElement>(".comment-edit textarea")?.focus();
|
|
131
|
-
}), button("trash", "
|
|
132
|
+
}), button("trash", "comments.delete-comment", () => { void mutate({ type: "delete", before: comment }); }));
|
|
132
133
|
head.append(location, actions); row.append(head);
|
|
133
134
|
if (editing?.id === comment.id) {
|
|
134
135
|
const edit = document.createElement("form"); edit.className = "comment-edit";
|
|
135
136
|
const text = document.createElement("textarea"); text.rows = 3; text.value = editing.text;
|
|
136
|
-
text
|
|
137
|
+
uiAttr(text, "aria-label", "comments.edit-comment-text");
|
|
137
138
|
text.addEventListener("input", () => { if (editing !== undefined) editing.text = text.value; });
|
|
138
139
|
const controls = document.createElement("div"); controls.className = "comment-edit-actions";
|
|
139
|
-
const cancel = document.createElement("button"); cancel.type = "button"; cancel
|
|
140
|
+
const cancel = document.createElement("button"); cancel.type = "button"; uiText(cancel, "common.cancel");
|
|
140
141
|
cancel.addEventListener("click", () => { editing = undefined; render(); });
|
|
141
|
-
const save = document.createElement("button"); save.type = "submit"; save
|
|
142
|
+
const save = document.createElement("button"); save.type = "submit"; uiText(save, "common.save"); save.disabled = busy;
|
|
142
143
|
controls.append(cancel, save); edit.append(text, controls);
|
|
143
144
|
edit.addEventListener("submit", (event) => {
|
|
144
145
|
event.preventDefault();
|
|
@@ -161,7 +162,7 @@ export function createComments(store: Store, stage: Stage) {
|
|
|
161
162
|
try {
|
|
162
163
|
const response = await fetch("/__studio/feedback");
|
|
163
164
|
const data = await response.json() as FeedbackView & { error?: string };
|
|
164
|
-
if (!response.ok) throw new Error(data.error ?? "
|
|
165
|
+
if (!response.ok) throw new Error(data.error ?? t("comments.read-failed"));
|
|
165
166
|
if (request !== fetchId) return;
|
|
166
167
|
if (JSON.stringify(view) !== JSON.stringify(data)) { view = data; render(); }
|
|
167
168
|
showError(); renderAnchor();
|
|
@@ -173,7 +174,7 @@ export function createComments(store: Store, stage: Stage) {
|
|
|
173
174
|
try {
|
|
174
175
|
const response = await fetch("/__studio/feedback", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(mutation) });
|
|
175
176
|
const data = await response.json() as FeedbackView & { error?: string };
|
|
176
|
-
if (!response.ok) throw new Error(data.error ?? "
|
|
177
|
+
if (!response.ok) throw new Error(data.error ?? t("comments.save-failed"));
|
|
177
178
|
++fetchId; view = data; busy = false; saved?.();
|
|
178
179
|
if (mutation.type === "delete" && selected === mutation.before.id) selected = undefined;
|
|
179
180
|
if (mutation.type === "delete" && editing?.id === mutation.before.id) editing = undefined;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Shared listbox interaction for Studio controls. Callers own the options and values. */
|
|
2
|
+
export function bindDropdown(control: HTMLElement, trigger: HTMLButtonElement, menu: HTMLElement, options: readonly HTMLButtonElement[]) {
|
|
3
|
+
const close = (restoreFocus = false): void => {
|
|
4
|
+
control.classList.remove("open", "open-up");
|
|
5
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
6
|
+
menu.hidden = true;
|
|
7
|
+
if (restoreFocus) trigger.focus();
|
|
8
|
+
};
|
|
9
|
+
const open = (focus: "selected" | "first" | "last" = "selected"): void => {
|
|
10
|
+
control.classList.add("open");
|
|
11
|
+
trigger.setAttribute("aria-expanded", "true");
|
|
12
|
+
menu.hidden = false;
|
|
13
|
+
control.classList.remove("open-up");
|
|
14
|
+
const scroll = control.closest<HTMLElement>(".workspace-scroll");
|
|
15
|
+
if (scroll !== null) {
|
|
16
|
+
const menuBox = menu.getBoundingClientRect();
|
|
17
|
+
const scrollBox = scroll.getBoundingClientRect();
|
|
18
|
+
if (menuBox.bottom > scrollBox.bottom && trigger.getBoundingClientRect().top - menuBox.height >= scrollBox.top) {
|
|
19
|
+
control.classList.add("open-up");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const target = focus === "first" ? options[0]
|
|
23
|
+
: focus === "last" ? options.at(-1)
|
|
24
|
+
: options.find((option) => option.classList.contains("active")) ?? options[0];
|
|
25
|
+
target?.focus();
|
|
26
|
+
};
|
|
27
|
+
const moveOptionFocus = (offset: number): void => {
|
|
28
|
+
const current = options.indexOf(document.activeElement as HTMLButtonElement);
|
|
29
|
+
const next = current < 0 ? 0 : (current + offset + options.length) % options.length;
|
|
30
|
+
options[next]?.focus();
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
trigger.addEventListener("click", () => {
|
|
34
|
+
if (control.classList.contains("open")) close(false);
|
|
35
|
+
else open();
|
|
36
|
+
});
|
|
37
|
+
trigger.addEventListener("keydown", (event) => {
|
|
38
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
event.stopPropagation();
|
|
41
|
+
open(event.key === "ArrowDown" ? "first" : "last");
|
|
42
|
+
} else if (event.key === "Escape" && control.classList.contains("open")) {
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
event.stopPropagation();
|
|
45
|
+
close(false);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
menu.addEventListener("keydown", (event) => {
|
|
49
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
50
|
+
event.preventDefault();
|
|
51
|
+
event.stopPropagation();
|
|
52
|
+
moveOptionFocus(event.key === "ArrowDown" ? 1 : -1);
|
|
53
|
+
} else if (event.key === "Home" || event.key === "End") {
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
event.stopPropagation();
|
|
56
|
+
options[event.key === "Home" ? 0 : options.length - 1]?.focus();
|
|
57
|
+
} else if (event.key === "Escape") {
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
event.stopPropagation();
|
|
60
|
+
close(true);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
control.addEventListener("focusout", (event) => {
|
|
64
|
+
if (event.relatedTarget instanceof Node && control.contains(event.relatedTarget)) return;
|
|
65
|
+
close(false);
|
|
66
|
+
});
|
|
67
|
+
return { close };
|
|
68
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { builtinLanguages, chooseLanguage, formatMessage, readLanguagePack } from "../localization.js";
|
|
2
|
+
import type { LanguagePack, Message, MessageValues } from "../localization.js";
|
|
3
|
+
import { bindDropdown } from "./dropdown.js";
|
|
4
|
+
import { icon } from "./icons.js";
|
|
5
|
+
export type { Message } from "../localization.js";
|
|
6
|
+
|
|
7
|
+
type Attribute = "title" | "aria-label" | "placeholder" | "alt";
|
|
8
|
+
const attributes: readonly Attribute[] = ["title", "aria-label", "placeholder", "alt"];
|
|
9
|
+
const storageKey = "hypit-studio.language";
|
|
10
|
+
let languages = builtinLanguages;
|
|
11
|
+
let language = chooseLanguage(languages, navigator.languages);
|
|
12
|
+
|
|
13
|
+
export async function initializeI18n(): Promise<void> {
|
|
14
|
+
const response = await fetch("/__studio/locales");
|
|
15
|
+
if (!response.ok) throw new Error(`Could not load Studio languages (${response.status}).`);
|
|
16
|
+
languages = (await response.json() as unknown[]).map(readLanguagePack);
|
|
17
|
+
let saved: string | null = null;
|
|
18
|
+
try { saved = localStorage.getItem(storageKey); } catch { /* Browser storage may be disabled. */ }
|
|
19
|
+
language = chooseLanguage(languages, [...(saved ? [saved] : []), ...navigator.languages]);
|
|
20
|
+
document.documentElement.lang = language.locale;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function t(message: Message, values: MessageValues = {}): string {
|
|
24
|
+
return formatMessage(language, message, values);
|
|
25
|
+
}
|
|
26
|
+
function escape(value: string): string {
|
|
27
|
+
return value.replace(/[&<>"']/gu, char => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]!);
|
|
28
|
+
}
|
|
29
|
+
export function uiLabel(message: Message): string {
|
|
30
|
+
return `<span data-ui-text="${message}" dir="${language.direction}">${escape(t(message))}</span>`;
|
|
31
|
+
}
|
|
32
|
+
export function uiAttribute(attribute: Attribute, message: Message): string {
|
|
33
|
+
return `${attribute}="${escape(t(message))}" data-ui-${attribute}="${message}"`;
|
|
34
|
+
}
|
|
35
|
+
export function uiText(node: Element, message: Message, values: MessageValues = {}): void {
|
|
36
|
+
node.setAttribute("data-ui-text", message);
|
|
37
|
+
node.setAttribute("data-ui-values", JSON.stringify(values));
|
|
38
|
+
node.setAttribute("dir", language.direction);
|
|
39
|
+
node.textContent = t(message, values);
|
|
40
|
+
}
|
|
41
|
+
/** Use when a node changes from UI text into a user-provided name or an empty value. */
|
|
42
|
+
export function userText(node: Element, value: string): void {
|
|
43
|
+
node.removeAttribute("data-ui-text");
|
|
44
|
+
node.removeAttribute("data-ui-values");
|
|
45
|
+
node.setAttribute("dir", "auto");
|
|
46
|
+
node.textContent = value;
|
|
47
|
+
}
|
|
48
|
+
export function uiAttr(node: Element, attribute: Attribute, message: Message, values: MessageValues = {}): void {
|
|
49
|
+
node.setAttribute(`data-ui-${attribute}`, message);
|
|
50
|
+
node.setAttribute(`data-ui-${attribute}-values`, JSON.stringify(values));
|
|
51
|
+
node.setAttribute(attribute, t(message, values));
|
|
52
|
+
}
|
|
53
|
+
export function uiDate(node: Element, timestamp: number): void {
|
|
54
|
+
node.setAttribute("data-ui-date", String(timestamp));
|
|
55
|
+
node.textContent = new Intl.DateTimeFormat(language.locale, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(timestamp);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function selectLanguage(pack: LanguagePack): void {
|
|
59
|
+
language = pack;
|
|
60
|
+
try { localStorage.setItem(storageKey, pack.locale); } catch { /* Tab-local choice still applies. */ }
|
|
61
|
+
document.documentElement.lang = pack.locale;
|
|
62
|
+
// Update explicitly marked chrome in place, without rebuilding editors or the player.
|
|
63
|
+
for (const node of Array.from(document.querySelectorAll("[data-ui-text]"))) {
|
|
64
|
+
uiText(node, node.getAttribute("data-ui-text") as Message, JSON.parse(node.getAttribute("data-ui-values") ?? "{}") as MessageValues);
|
|
65
|
+
}
|
|
66
|
+
for (const attribute of attributes) {
|
|
67
|
+
for (const node of Array.from(document.querySelectorAll(`[data-ui-${attribute}]`))) {
|
|
68
|
+
uiAttr(node, attribute, node.getAttribute(`data-ui-${attribute}`) as Message, JSON.parse(node.getAttribute(`data-ui-${attribute}-values`) ?? "{}") as MessageValues);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for (const node of Array.from(document.querySelectorAll("[data-ui-date]"))) uiDate(node, Number(node.getAttribute("data-ui-date")));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function languageMenu(): HTMLElement {
|
|
75
|
+
const control = document.createElement("div");
|
|
76
|
+
control.className = "parameter-select language-select";
|
|
77
|
+
const trigger = document.createElement("button");
|
|
78
|
+
trigger.type = "button"; trigger.className = "parameter-select-trigger language-toggle";
|
|
79
|
+
trigger.dataset.language = "";
|
|
80
|
+
trigger.innerHTML = icon("globe");
|
|
81
|
+
uiAttr(trigger, "title", "app.language"); uiAttr(trigger, "aria-label", "app.language");
|
|
82
|
+
trigger.setAttribute("aria-haspopup", "listbox"); trigger.setAttribute("aria-expanded", "false");
|
|
83
|
+
const menu = document.createElement("div");
|
|
84
|
+
menu.className = "parameter-select-menu"; menu.id = "studio-languages"; menu.hidden = true;
|
|
85
|
+
menu.setAttribute("role", "listbox"); uiAttr(menu, "aria-label", "app.language");
|
|
86
|
+
trigger.setAttribute("aria-controls", menu.id);
|
|
87
|
+
const options = languages.map(pack => {
|
|
88
|
+
const item = document.createElement("button");
|
|
89
|
+
item.type = "button"; item.className = "parameter-select-option";
|
|
90
|
+
item.lang = pack.locale; item.dir = pack.direction;
|
|
91
|
+
item.textContent = pack.name;
|
|
92
|
+
item.dataset.locale = pack.locale;
|
|
93
|
+
item.setAttribute("role", "option");
|
|
94
|
+
item.addEventListener("click", () => {
|
|
95
|
+
selectLanguage(pack); renderSelection(); close(true);
|
|
96
|
+
});
|
|
97
|
+
return item;
|
|
98
|
+
});
|
|
99
|
+
const renderSelection = (): void => {
|
|
100
|
+
for (const item of options) {
|
|
101
|
+
const selected = item.dataset.locale === language.locale;
|
|
102
|
+
item.classList.toggle("active", selected); item.setAttribute("aria-selected", String(selected));
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
menu.append(...options); control.append(trigger, menu);
|
|
106
|
+
const { close } = bindDropdown(control, trigger, menu, options);
|
|
107
|
+
renderSelection();
|
|
108
|
+
return control;
|
|
109
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const paths: Readonly<Record<string, string>> = {
|
|
2
2
|
brand: '<g transform="translate(0 1.282) scale(.091603) translate(-50 -180)"><path fill="currentColor" stroke="none" d="M303.78,277.82c-5.98-8.63-15.83-13.77-26.32-13.77h-93.41c-12.28,0-24.92,7.44-29.6,18.8l-28.26,75.82c-4.6,11.25-4.54,24.05,2.22,34.16,6.79,10.15,19.36,16.15,31.57,16.15h85.98c14.12,0,26.9-8.88,31.84-22.1l29.69-79.57c3.67-9.84,2.29-20.86-3.69-29.48v-.02h-.02ZM252.02,377.25c-.94,2.52-3.39,4.22-6.08,4.22h-83.45c-4.34,0-6.68-2.8-7.49-3.99s-2.5-4.43-.84-8.44l23.85-66.24c2.29-6.35,8.08-10.87,14.82-11.22.33-.02.67-.02,1.02-.02h75.41s12.51,1.42,9.29,14.61l-26.53,71.09h0ZM106.85,361.73s-22.36-11.59-17.66-32.66l29.67-82.23c4.78-13.25,17.36-22.09,31.45-22.09h95.02c6.25,0,12.1,3.03,15.71,8.14l13.89,19.68h-112.97c-8.7,0-16.49,5.41-19.52,13.57l-35.59,95.6h0ZM73.3,323.27s-22.37-11.59-17.66-32.66l29.67-82.23c4.79-13.25,17.37-22.09,31.45-22.09h89.91c6.25,0,12.1,3.03,15.71,8.14l13.89,19.68h-107.84c-8.7,0-16.49,5.41-19.52,13.57l-35.59,95.6h-.02Z"/></g>',
|
|
3
|
+
globe: '<circle cx="12" cy="12" r="9"/><ellipse cx="12" cy="12" rx="4" ry="9"/><path d="M3 12h18"/>',
|
|
3
4
|
code: '<path d="M9 7 4 12l5 5M15 7l5 5-5 5M13 4l-2 16"/>',
|
|
4
5
|
edit: '<path d="m4 16-.8 4 4-.8L18.5 8.9 15.1 5.5 4 16Z"/><path d="m13.8 6.8 3.4 3.4"/>',
|
|
5
6
|
studio: '<rect x="3" y="4" width="18" height="16" rx="1.5"/><path d="M3 14h18M8 4v10M15 14v6"/>',
|