@hypit/hypit 0.1.5 → 0.1.6
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/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/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
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
{
|
|
2
|
+
"locale": "zh-CN",
|
|
3
|
+
"name": "简体中文",
|
|
4
|
+
"direction": "ltr",
|
|
5
|
+
"messages": {
|
|
6
|
+
"app.comments": "评论",
|
|
7
|
+
"app.language": "语言",
|
|
8
|
+
"app.studio": "工作台",
|
|
9
|
+
"app.studio-view": "工作区视图",
|
|
10
|
+
"comments.all": "全部",
|
|
11
|
+
"comments.caught-up": "评论都已处理",
|
|
12
|
+
"comments.delete-comment": "删除评论",
|
|
13
|
+
"comments.done": "已完成",
|
|
14
|
+
"comments.draw-soon": "批注 · 即将支持",
|
|
15
|
+
"comments.edit-comment": "编辑评论",
|
|
16
|
+
"comments.edit-comment-text": "编辑评论内容",
|
|
17
|
+
"comments.emoji-soon": "表情 · 即将支持",
|
|
18
|
+
"comments.empty": "留下第一条评论",
|
|
19
|
+
"comments.empty-hint": "暂停在想调整的画面,说说你的想法。",
|
|
20
|
+
"comments.filter-comments": "筛选评论",
|
|
21
|
+
"comments.filter-hint": "切换筛选,查看其他评论。",
|
|
22
|
+
"comments.go-to-this-frame": "跳转到这一帧",
|
|
23
|
+
"comments.loading-comments": "加载评论中…",
|
|
24
|
+
"comments.mark-complete": "标为已完成",
|
|
25
|
+
"comments.no-completed-comments": "暂无已完成评论",
|
|
26
|
+
"comments.open": "待处理",
|
|
27
|
+
"comments.placeholder": "想调整哪里?",
|
|
28
|
+
"comments.read-failed": "无法读取评论。",
|
|
29
|
+
"comments.refresh-comments": "刷新评论",
|
|
30
|
+
"comments.reopen-comment": "标为待处理",
|
|
31
|
+
"comments.save-failed": "无法保存评论。",
|
|
32
|
+
"comments.send-comment": "发送评论",
|
|
33
|
+
"comments.send-shortcut": "发送评论(Enter)",
|
|
34
|
+
"comments.use-current-frame": "使用当前帧",
|
|
35
|
+
"comments.write-a-comment": "写评论",
|
|
36
|
+
"common.cancel": "取消",
|
|
37
|
+
"common.cancelled": "已取消",
|
|
38
|
+
"common.compile-failed": "编译失败",
|
|
39
|
+
"common.completed": "已完成",
|
|
40
|
+
"common.copied": "已复制",
|
|
41
|
+
"common.failed": "失败",
|
|
42
|
+
"common.invalid-value": "无效的值",
|
|
43
|
+
"common.needs-attention": "需要处理",
|
|
44
|
+
"common.queued": "排队中",
|
|
45
|
+
"common.refresh": "刷新",
|
|
46
|
+
"common.running": "运行中",
|
|
47
|
+
"common.save": "保存",
|
|
48
|
+
"common.save-failed": "保存失败",
|
|
49
|
+
"common.saved": "已保存",
|
|
50
|
+
"common.saving": "保存中",
|
|
51
|
+
"common.waiting": "等待中",
|
|
52
|
+
"inspector.add-color": "添加颜色",
|
|
53
|
+
"inspector.add-item": "添加条目",
|
|
54
|
+
"inspector.anchor.program-end": "时间线终点{detail}",
|
|
55
|
+
"inspector.anchor.program-start": "时间线起点{detail}",
|
|
56
|
+
"inspector.anchor.segment-end": "段尾{detail}",
|
|
57
|
+
"inspector.anchor.segment-start": "段头{detail}",
|
|
58
|
+
"inspector.anchor.token-end": "词尾{detail}",
|
|
59
|
+
"inspector.anchor.token-start": "词头{detail}",
|
|
60
|
+
"inspector.aspect-ratio": "画幅比例",
|
|
61
|
+
"inspector.author": "源文件",
|
|
62
|
+
"inspector.build": "构建",
|
|
63
|
+
"inspector.candidates": "候选实现",
|
|
64
|
+
"inspector.canvas": "画布",
|
|
65
|
+
"inspector.duration": "时长",
|
|
66
|
+
"inspector.empty": "当前选中项没有详细信息",
|
|
67
|
+
"inspector.end": "终点",
|
|
68
|
+
"inspector.end-anchor": "终点语义锚点",
|
|
69
|
+
"inspector.frame-rate": "帧率",
|
|
70
|
+
"inspector.frames": "帧数",
|
|
71
|
+
"inspector.how": "样式",
|
|
72
|
+
"inspector.invalid-fields": "请正确填写必填项后保存。",
|
|
73
|
+
"inspector.item-count": "{count} 项",
|
|
74
|
+
"inspector.moment": "时刻",
|
|
75
|
+
"inspector.moment-anchor": "时刻语义锚点",
|
|
76
|
+
"inspector.move-down": "下移",
|
|
77
|
+
"inspector.move-up": "上移",
|
|
78
|
+
"inspector.project": "项目",
|
|
79
|
+
"inspector.properties": "属性",
|
|
80
|
+
"inspector.remove-item": "移除条目",
|
|
81
|
+
"inspector.resolution": "分辨率",
|
|
82
|
+
"inspector.run": "运行方案",
|
|
83
|
+
"inspector.semantic-anchors": "语义锚点",
|
|
84
|
+
"inspector.sources": "引用文件",
|
|
85
|
+
"inspector.start": "起点",
|
|
86
|
+
"inspector.start-anchor": "起点语义锚点",
|
|
87
|
+
"inspector.structured-value": "结构化值",
|
|
88
|
+
"inspector.targets": "目标产物",
|
|
89
|
+
"inspector.timeline": "时间线",
|
|
90
|
+
"inspector.timing": "时间",
|
|
91
|
+
"inspector.tracks": "轨道",
|
|
92
|
+
"inspector.use-color": "使用 {color}",
|
|
93
|
+
"inspector.when": "时间",
|
|
94
|
+
"inspector.where": "位置",
|
|
95
|
+
"library.all-media": "全部素材",
|
|
96
|
+
"library.all-tasks": "全部任务",
|
|
97
|
+
"library.artifacts": "产物",
|
|
98
|
+
"library.audio": "音频",
|
|
99
|
+
"library.clear-task-filter": "清除任务筛选",
|
|
100
|
+
"library.data": "数据",
|
|
101
|
+
"library.display-name": "显示名称",
|
|
102
|
+
"library.enter-name": "请输入名称。",
|
|
103
|
+
"library.finished": "已结束",
|
|
104
|
+
"library.image": "图片",
|
|
105
|
+
"library.images": "图片",
|
|
106
|
+
"library.in-progress": "进行中",
|
|
107
|
+
"library.loading-media": "加载素材中…",
|
|
108
|
+
"library.loading-more": "加载更多…",
|
|
109
|
+
"library.loading-tasks": "加载任务中…",
|
|
110
|
+
"library.media-categories": "素材分类",
|
|
111
|
+
"library.media-category": "素材分类",
|
|
112
|
+
"library.media-hint": "这里展示已产出的图片、视频和音频,也包括正在构建的任务产物。",
|
|
113
|
+
"library.name-failed": "无法保存名称",
|
|
114
|
+
"library.name-pending": "{name}\n构建完成后可以重命名",
|
|
115
|
+
"library.no-files-in-this-category": "此分类暂无素材",
|
|
116
|
+
"library.no-media": "暂无素材",
|
|
117
|
+
"library.no-tasks-here": "暂无任务",
|
|
118
|
+
"library.no-tasks-in-progress": "没有进行中的任务",
|
|
119
|
+
"library.preview-name": "预览 {name}",
|
|
120
|
+
"library.preview-unavailable": "暂时无法预览",
|
|
121
|
+
"library.referenced-source-files": "引用的源文件",
|
|
122
|
+
"library.refresh-failed": "刷新失败:{detail}",
|
|
123
|
+
"library.refresh-failed-retained": "刷新失败:{detail} · 已保留上次视图",
|
|
124
|
+
"library.refresh-interrupted": "刷新已中断",
|
|
125
|
+
"library.refresh-media": "刷新素材",
|
|
126
|
+
"library.refresh-tasks": "刷新任务",
|
|
127
|
+
"library.refreshing": "刷新中…",
|
|
128
|
+
"library.rename-hint": "{name}\n双击或按 F2 重命名",
|
|
129
|
+
"library.return-to-task": "返回任务",
|
|
130
|
+
"library.source": "源文件",
|
|
131
|
+
"library.studio-library": "项目资源",
|
|
132
|
+
"library.task-categories": "任务分类",
|
|
133
|
+
"library.task-return": "{name} · 返回任务",
|
|
134
|
+
"library.tasks": "任务",
|
|
135
|
+
"library.unavailable": "资源暂时不可用",
|
|
136
|
+
"library.updated": "更新于 {time}",
|
|
137
|
+
"library.video": "视频",
|
|
138
|
+
"library.video-loading-preview": "视频 · 加载预览中",
|
|
139
|
+
"library.videos": "视频",
|
|
140
|
+
"library.view-media": "查看产物",
|
|
141
|
+
"library.view-task-media": "查看任务产物",
|
|
142
|
+
"player.audio-waveform": "音频波形",
|
|
143
|
+
"player.back-5-seconds": "后退 5 秒",
|
|
144
|
+
"player.back-to-composition": "返回作品",
|
|
145
|
+
"player.composition-time": "作品播放进度",
|
|
146
|
+
"player.elements-at-this-point": "此处的元素",
|
|
147
|
+
"player.forward-5-seconds": "前进 5 秒",
|
|
148
|
+
"player.frame-loading": "加载画面中…",
|
|
149
|
+
"player.frame-unavailable": "画面暂时不可用",
|
|
150
|
+
"player.hovered-frame": "悬停画面",
|
|
151
|
+
"player.media-failed": "浏览器无法打开此素材。",
|
|
152
|
+
"player.media-time": "素材播放进度",
|
|
153
|
+
"player.mute": "静音",
|
|
154
|
+
"player.next-frame": "下一帧",
|
|
155
|
+
"player.pause": "暂停",
|
|
156
|
+
"player.play": "播放",
|
|
157
|
+
"player.preview": "预览",
|
|
158
|
+
"player.previous-frame": "上一帧",
|
|
159
|
+
"player.unmute": "取消静音",
|
|
160
|
+
"source.changed-outside-studio": "文件已在外部更改",
|
|
161
|
+
"source.edit-source": "编辑源文件",
|
|
162
|
+
"source.keep-lines-unwrapped": "不自动换行",
|
|
163
|
+
"source.read-source": "查看源文件",
|
|
164
|
+
"source.save-failed-status": "保存失败({status})",
|
|
165
|
+
"source.saving-before-switch": "切换前保存中",
|
|
166
|
+
"source.svml-source": "SVML 源文件",
|
|
167
|
+
"source.unsaved": "未保存",
|
|
168
|
+
"source.wrap-lines": "自动换行",
|
|
169
|
+
"tasks.copy-error": "复制错误信息",
|
|
170
|
+
"tasks.copy-manually": "请选择文字复制",
|
|
171
|
+
"tasks.no-runtime": "尚未选择运行环境,可在「已结束」中查看保存的任务。",
|
|
172
|
+
"tasks.progress": "已完成 {completed}/{total} 步",
|
|
173
|
+
"tasks.refresh-hint": "点击刷新,查看最新任务状态。",
|
|
174
|
+
"tasks.saved-only": "项目中保存的结果 · 未选择运行环境",
|
|
175
|
+
"timeline.fit-timeline": "适配时间线",
|
|
176
|
+
"timeline.moment-name": "时刻 {name}",
|
|
177
|
+
"timeline.overlapping-timeline-objects": "重叠的时间线对象",
|
|
178
|
+
"timeline.resize-timeline-track-labels": "调整轨道名称区宽度",
|
|
179
|
+
"timeline.resize-track-labels": "调整轨道名称区宽度",
|
|
180
|
+
"timeline.segment-name": "片段 {name}",
|
|
181
|
+
"timeline.segments": "片段",
|
|
182
|
+
"timeline.select": "选择",
|
|
183
|
+
"timeline.selection-name": "区间 {name}",
|
|
184
|
+
"timeline.selections-and-moments": "区间与时刻",
|
|
185
|
+
"timeline.words": "词语",
|
|
186
|
+
"timeline.zoom-in": "放大",
|
|
187
|
+
"timeline.zoom-out": "缩小",
|
|
188
|
+
"inspector.color-picker": "{label}取色器",
|
|
189
|
+
"timeline.segment-count": "{count} 个片段"
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { isAbsolute, resolve } from "node:path";
|
|
4
|
+
import type { Plugin } from "vite";
|
|
5
|
+
import { builtinLanguages, languageCoverage, readLanguagePack } from "./localization.js";
|
|
6
|
+
import type { LanguagePack } from "./localization.js";
|
|
7
|
+
|
|
8
|
+
export async function loadLanguagePack(specifier: string, cwd: string, packageRoot: string): Promise<LanguagePack> {
|
|
9
|
+
const path = isAbsolute(specifier) || specifier.startsWith(".")
|
|
10
|
+
? resolve(cwd, specifier)
|
|
11
|
+
: createRequire(resolve(packageRoot, "package.json")).resolve(specifier);
|
|
12
|
+
try { return readLanguagePack(JSON.parse(await readFile(path, "utf8"))); }
|
|
13
|
+
catch (error) { throw new Error(`Language pack ${specifier}: ${error instanceof Error ? error.message : String(error)}`); }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function describeLanguagePack(pack: LanguagePack): string {
|
|
17
|
+
const { missing, unknown } = languageCoverage(pack);
|
|
18
|
+
return [`${pack.name} (${pack.locale})`,
|
|
19
|
+
`Missing translations (${missing.length}; English is used): ${missing.join(", ") || "none"}`,
|
|
20
|
+
`Unknown message IDs (${unknown.length}): ${unknown.join(", ") || "none"}`,
|
|
21
|
+
].join("\n");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function studioLanguages(specifiers: readonly string[], cwd: string, packageRoot: string): Promise<readonly LanguagePack[]> {
|
|
25
|
+
const packs = new Map(builtinLanguages.map(pack => [pack.locale, pack]));
|
|
26
|
+
for (const specifier of specifiers) {
|
|
27
|
+
const pack = await loadLanguagePack(specifier, cwd, packageRoot);
|
|
28
|
+
packs.set(pack.locale, pack);
|
|
29
|
+
}
|
|
30
|
+
return [...packs.values()];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function studioLocalizationPlugin(packs: readonly LanguagePack[]): Plugin {
|
|
34
|
+
return {
|
|
35
|
+
name: "hypit-studio-localization",
|
|
36
|
+
configureServer(server) {
|
|
37
|
+
server.middlewares.use((request, response, next) => {
|
|
38
|
+
if (new URL(request.url ?? "/", "http://localhost").pathname !== "/__studio/locales") return next();
|
|
39
|
+
if (request.method !== "GET") { response.statusCode = 405; response.end(); return; }
|
|
40
|
+
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
41
|
+
response.setHeader("cache-control", "no-store");
|
|
42
|
+
response.end(JSON.stringify(packs));
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import english from "../locales/en.json" with { type: "json" };
|
|
2
|
+
import chinese from "../locales/zh-CN.json" with { type: "json" };
|
|
3
|
+
|
|
4
|
+
export type Message = keyof typeof english.messages;
|
|
5
|
+
export type MessageValue = string | ({ readonly other: string } & Partial<Record<Intl.LDMLPluralRule, string>>);
|
|
6
|
+
export type LanguagePack = {
|
|
7
|
+
readonly locale: string;
|
|
8
|
+
/** Native language name, displayed without translation. */
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly direction: "ltr" | "rtl";
|
|
11
|
+
readonly messages: Readonly<Record<string, MessageValue>>;
|
|
12
|
+
};
|
|
13
|
+
export const defaultLanguage: LanguagePack = english as LanguagePack;
|
|
14
|
+
export const builtinLanguages: readonly LanguagePack[] = [defaultLanguage, chinese as LanguagePack];
|
|
15
|
+
export type MessageValues = Readonly<Record<string, string | number>>;
|
|
16
|
+
const plurals = new Set(["zero", "one", "two", "few", "many", "other"]);
|
|
17
|
+
const record = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
18
|
+
const variants = (message: MessageValue): readonly string[] => typeof message === "string" ? [message] : Object.values(message);
|
|
19
|
+
const variables = (text: string): string[] => [...new Set([...text.matchAll(/\{(\w+)\}/gu)].map(match => match[1]!))].sort();
|
|
20
|
+
|
|
21
|
+
/** Read data, never executable translation code. Partial translations remain useful. */
|
|
22
|
+
export function readLanguagePack(value: unknown): LanguagePack {
|
|
23
|
+
if (!record(value) || typeof value.locale !== "string" || typeof value.name !== "string" || !value.name.trim()
|
|
24
|
+
|| (value.direction !== "ltr" && value.direction !== "rtl") || !record(value.messages)) {
|
|
25
|
+
throw new Error("A Studio language pack needs locale, name, direction (ltr/rtl) and messages.");
|
|
26
|
+
}
|
|
27
|
+
const locale = Intl.getCanonicalLocales(value.locale)[0];
|
|
28
|
+
if (!locale) throw new Error("A Studio language pack needs a BCP 47 locale.");
|
|
29
|
+
const messages: Record<string, MessageValue> = {};
|
|
30
|
+
for (const [id, message] of Object.entries(value.messages)) {
|
|
31
|
+
if (typeof message !== "string" && (!record(message) || typeof message.other !== "string"
|
|
32
|
+
|| Object.entries(message).some(([form, text]) => !plurals.has(form) || typeof text !== "string"))) {
|
|
33
|
+
throw new Error(`${id}: expected text or plural forms with an 'other' form.`);
|
|
34
|
+
}
|
|
35
|
+
const translated = message as MessageValue;
|
|
36
|
+
const original = Object.hasOwn(defaultLanguage.messages, id) ? defaultLanguage.messages[id] : undefined;
|
|
37
|
+
if (original !== undefined) {
|
|
38
|
+
const expected = variables(variants(original)[0]!).join(",");
|
|
39
|
+
for (const text of variants(translated)) {
|
|
40
|
+
if (variables(text).join(",") !== expected) throw new Error(`${id}: preserve the named variables {${expected.split(",").join("}, {")}}.`);
|
|
41
|
+
}
|
|
42
|
+
if (typeof translated !== "string" && !variables(variants(original)[0]!).includes("count")) {
|
|
43
|
+
throw new Error(`${id}: plural forms require a count variable.`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
messages[id] = translated;
|
|
47
|
+
}
|
|
48
|
+
return { locale, name: value.name, direction: value.direction, messages };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function languageCoverage(pack: LanguagePack): { missing: string[]; unknown: string[] } {
|
|
52
|
+
return {
|
|
53
|
+
missing: Object.keys(defaultLanguage.messages).filter(id => !Object.hasOwn(pack.messages, id)),
|
|
54
|
+
unknown: Object.keys(pack.messages).filter(id => !Object.hasOwn(defaultLanguage.messages, id)),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function chooseLanguage(packs: readonly LanguagePack[], preferences: readonly string[]): LanguagePack {
|
|
59
|
+
for (const preference of preferences) {
|
|
60
|
+
let locale: string;
|
|
61
|
+
try { locale = Intl.getCanonicalLocales(preference)[0]!; } catch { continue; }
|
|
62
|
+
const exact = packs.find(pack => pack.locale === locale);
|
|
63
|
+
if (exact) return exact;
|
|
64
|
+
// Match regional/script variants without a list of special language cases.
|
|
65
|
+
const requested = new Intl.Locale(locale).maximize();
|
|
66
|
+
const related = packs.find(pack => {
|
|
67
|
+
const candidate = new Intl.Locale(pack.locale).maximize();
|
|
68
|
+
return candidate.language === requested.language && candidate.script === requested.script;
|
|
69
|
+
});
|
|
70
|
+
if (related) return related;
|
|
71
|
+
}
|
|
72
|
+
return packs.find(pack => pack.locale === defaultLanguage.locale) ?? defaultLanguage;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function formatMessage(pack: LanguagePack, id: Message, values: MessageValues = {}): string {
|
|
76
|
+
const translated = pack.messages[id];
|
|
77
|
+
const message = translated ?? defaultLanguage.messages[id]!;
|
|
78
|
+
const locale = translated === undefined ? defaultLanguage.locale : pack.locale;
|
|
79
|
+
const template = typeof message === "string" ? message
|
|
80
|
+
: message[new Intl.PluralRules(locale).select(Number(values.count))] ?? message.other;
|
|
81
|
+
return template.replace(/\{(\w+)\}/gu, (match, key: string) => {
|
|
82
|
+
const value = values[key];
|
|
83
|
+
return typeof value === "number" ? new Intl.NumberFormat(pack.locale).format(value) : value ?? match;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -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;
|