@lingfan-engine/editor 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/package.json +39 -3
- package/src/chapters/build.ts +180 -0
- package/src/chapters/index.ts +34 -0
- package/src/chapters/types.ts +71 -0
- package/src/contracts/diagnostics.ts +62 -0
- package/src/contracts/editing.ts +26 -0
- package/src/contracts/forms.ts +69 -0
- package/src/contracts/index.ts +22 -0
- package/src/contracts/texts.ts +204 -0
- package/src/diagnostics/analyze.ts +161 -0
- package/src/diagnostics/grouping.ts +164 -0
- package/src/diagnostics/index.ts +35 -0
- package/src/diagnostics/message.ts +60 -0
- package/src/diagnostics/refs.ts +123 -0
- package/src/diagnostics/symbols.ts +221 -0
- package/src/editing/columnGrouping.ts +331 -0
- package/src/editing/graphConnect.ts +88 -0
- package/src/editing/index.ts +72 -0
- package/src/editing/pointers.ts +291 -0
- package/src/editing/saveNormalization.ts +58 -0
- package/src/editing/session.ts +134 -0
- package/src/editing/storyOps.ts +268 -0
- package/src/editing/tsSourceGuard.ts +40 -0
- package/src/element/drag.ts +22 -0
- package/src/element/dragCreate.ts +60 -0
- package/src/element/index.ts +11 -0
- package/src/i18n/coverage.ts +191 -0
- package/src/i18n/extract.ts +38 -0
- package/src/i18n/index.ts +47 -0
- package/src/i18n/reconcile.ts +71 -0
- package/src/i18n/skeleton.ts +112 -0
- package/src/i18n/storyKeys.ts +27 -0
- package/src/i18n/surfaces.ts +54 -0
- package/src/i18n/table.ts +235 -0
- package/src/index.ts +342 -0
- package/src/layout/index.ts +14 -0
- package/src/layout/steps.ts +378 -0
- package/src/schema/catalog.ts +300 -0
- package/src/schema/elementForms.ts +307 -0
- package/src/schema/elementPalette.ts +100 -0
- package/src/schema/forms.ts +196 -0
- package/src/schema/index.ts +55 -0
- package/src/schema/opSchemas/commands.ts +374 -0
- package/src/schema/opSchemas/fragments.ts +24 -0
- package/src/schema/opSchemas/index.ts +16 -0
- package/src/schema/opSchemas/map.ts +134 -0
- package/src/schema/opSchemas/types.ts +42 -0
- package/src/schema/opSchemas/validate.ts +184 -0
- package/src/schema/surface.ts +101 -0
- package/src/schema/validation.ts +92 -0
- package/src/schema/walk.ts +167 -0
- package/src/script/expr/audit/index.ts +8 -0
- package/src/script/expr/audit/parser.ts +370 -0
- package/src/script/expr/audit/token.ts +23 -0
- package/src/script/expr/build.ts +203 -0
- package/src/script/expr/index.ts +17 -0
- package/src/script/expr/registry.ts +130 -0
- package/src/script/index.ts +168 -0
- package/src/script/vocabulary-gen.ts +202 -0
- package/src/script/vocabulary.g.ts +175 -0
- package/src/script/words/audio.ts +114 -0
- package/src/script/words/cells.ts +78 -0
- package/src/script/words/compile-time.ts +27 -0
- package/src/script/words/composite.ts +125 -0
- package/src/script/words/elements.ts +202 -0
- package/src/script/words/extension.ts +25 -0
- package/src/script/words/flow.ts +391 -0
- package/src/script/words/index.ts +23 -0
- package/src/script/words/interaction.ts +44 -0
- package/src/script/words/minigame.ts +66 -0
- package/src/script/words/narrative.ts +261 -0
- package/src/script/words/save.ts +54 -0
- package/src/script/words/vars.ts +155 -0
- package/src/script/words/video.ts +80 -0
- package/src/shared/guards.ts +19 -0
- package/src/shared/index.ts +6 -0
- package/src/shared/pointer.ts +23 -0
- package/README.md +0 -3
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 列级/命令级映射器便捷操作(全部纯函数、不可变,基于指针原语):
|
|
3
|
+
* addColumn / removeColumn / renameColumn(引用同步更新)/ 命令容器定位与增删改移。
|
|
4
|
+
* renameColumn 更新 jump.target、menu.options[].target、navigate(scene ?? path)
|
|
5
|
+
* ——「列名即标签」,重命名必须保持全部列的引用图一致。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Story } from "@lingfan-engine/core";
|
|
9
|
+
import { isSafeFileNameSegment } from "@lingfan-engine/core";
|
|
10
|
+
import {
|
|
11
|
+
getAtPointer,
|
|
12
|
+
insertAtPointer,
|
|
13
|
+
moveAtPointer,
|
|
14
|
+
removeAtPointer,
|
|
15
|
+
setAtPointer,
|
|
16
|
+
} from "./pointers.ts";
|
|
17
|
+
|
|
18
|
+
/** 列的命令容器:flow → commands;scene → elements + entry */
|
|
19
|
+
export function columnContainers(column: unknown): {
|
|
20
|
+
field: "commands" | "elements" | "entry";
|
|
21
|
+
list: unknown[];
|
|
22
|
+
}[] {
|
|
23
|
+
if (column === null || typeof column !== "object") return [];
|
|
24
|
+
const record = column as Record<string, unknown>;
|
|
25
|
+
if (record.kind === "flow") {
|
|
26
|
+
return Array.isArray(record.commands)
|
|
27
|
+
? [{ field: "commands", list: record.commands }]
|
|
28
|
+
: [];
|
|
29
|
+
}
|
|
30
|
+
const out: { field: "elements" | "entry"; list: unknown[] }[] = [];
|
|
31
|
+
if (Array.isArray(record.elements)) {
|
|
32
|
+
out.push({ field: "elements", list: record.elements });
|
|
33
|
+
}
|
|
34
|
+
if (Array.isArray(record.entry))
|
|
35
|
+
out.push({ field: "entry", list: record.entry });
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 指向某列命令容器的数组指针(列不存在返回 null) */
|
|
40
|
+
export function containerPointer(
|
|
41
|
+
story: Story,
|
|
42
|
+
columnId: string,
|
|
43
|
+
field: "commands" | "elements" | "entry",
|
|
44
|
+
): string | null {
|
|
45
|
+
const index = story.columns.findIndex((column) => column.id === columnId);
|
|
46
|
+
if (index < 0) return null;
|
|
47
|
+
return `/columns/${index}/${field}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 语义化列 id 生成:建议 → 唯一化 → 兜底。 `hint` 是**建议**非命令:非法(空 / 不安全文件名字符)→ 回退 `column-N` 计数
|
|
52
|
+
* (不抛、不静默把作者输入改写成另一个语义 id);重名 → `-2`/`-3` 递增取空位。
|
|
53
|
+
* 列 id 语义:文件名 = 列 id 是存储不变量,运行时永不派生;
|
|
54
|
+
* 列序 = 文件路径码元序 ⇒ 语义化 id 优于计数 id(`column-N` 重排后落字母位,仅兜底)。
|
|
55
|
+
*/
|
|
56
|
+
export function suggestColumnId(
|
|
57
|
+
hint: string | null | undefined,
|
|
58
|
+
existingIds: readonly string[],
|
|
59
|
+
): string {
|
|
60
|
+
const taken = new Set(existingIds);
|
|
61
|
+
const cleaned = typeof hint === "string" ? hint.trim() : "";
|
|
62
|
+
if (cleaned !== "" && isSafeFileNameSegment(cleaned)) {
|
|
63
|
+
if (!taken.has(cleaned)) return cleaned;
|
|
64
|
+
let n = 2;
|
|
65
|
+
while (taken.has(`${cleaned}-${n}`)) n += 1;
|
|
66
|
+
return `${cleaned}-${n}`;
|
|
67
|
+
}
|
|
68
|
+
let n = existingIds.length + 1;
|
|
69
|
+
while (taken.has(`column-${n}`)) n += 1;
|
|
70
|
+
return `column-${n}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 追加列;id 缺省时按 `hint` 生成语义化 id(无建议 → column-N 兜底)并保证唯一;显式 id 撞名 = fail-closed 原样返回;scene 预置双容器 */
|
|
74
|
+
export function addColumn(
|
|
75
|
+
story: Story,
|
|
76
|
+
options: {
|
|
77
|
+
id?: string;
|
|
78
|
+
kind?: "scene" | "flow";
|
|
79
|
+
/**
|
|
80
|
+
* 运行语义轴(与 `kind` 正交):game 缺省 / menu 菜单 / ui 覆盖层。
|
|
81
|
+
* **缺省与 game 都不写字段**——默认值不显式存储(与写回保真同一口径:
|
|
82
|
+
* 新建列的内存形态必须与重开后的解析形态深等)。
|
|
83
|
+
*/
|
|
84
|
+
type?: "game" | "menu" | "ui";
|
|
85
|
+
/** 语义化建议(仅 id 缺省时参与;见 suggestColumnId) */
|
|
86
|
+
hint?: string | null;
|
|
87
|
+
} = {},
|
|
88
|
+
): { story: Story; id: string } {
|
|
89
|
+
const id =
|
|
90
|
+
options.id ??
|
|
91
|
+
suggestColumnId(
|
|
92
|
+
options.hint,
|
|
93
|
+
story.columns.map((column) => column.id),
|
|
94
|
+
);
|
|
95
|
+
if (
|
|
96
|
+
options.id !== undefined &&
|
|
97
|
+
story.columns.some((column) => column.id === id)
|
|
98
|
+
) {
|
|
99
|
+
return { story, id };
|
|
100
|
+
}
|
|
101
|
+
const kind = options.kind ?? "flow";
|
|
102
|
+
const base =
|
|
103
|
+
kind === "flow"
|
|
104
|
+
? { id, kind, commands: [] }
|
|
105
|
+
: { id, kind, elements: [], entry: [] };
|
|
106
|
+
// 仅非 game 的显式 type 才落字段(默认形态保持与旧工程逐字节一致)
|
|
107
|
+
const column =
|
|
108
|
+
options.type !== undefined && options.type !== "game"
|
|
109
|
+
? { ...base, type: options.type }
|
|
110
|
+
: base;
|
|
111
|
+
const next = insertAtPointer(story, "/columns", story.columns.length, column);
|
|
112
|
+
if (next === null) return { story, id };
|
|
113
|
+
return { story: next, id };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 删除列(引用完整性交诊断层报 missing-target,不做静默改指) */
|
|
117
|
+
export function removeColumn(story: Story, columnId: string): Story | null {
|
|
118
|
+
const index = story.columns.findIndex((column) => column.id === columnId);
|
|
119
|
+
if (index < 0) return null;
|
|
120
|
+
return removeAtPointer(story, `/columns/${index}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 反查:**哪个文件承载的第一个列**(列序;资源树点击故事文件时用)。
|
|
125
|
+
*
|
|
126
|
+
* 语义:一个 `.story` 文件可承载**多个列**(如 `chapter1.story` = 4 列),
|
|
127
|
+
* 「点击资源树里的故事文件」⇒ 打开该文件**列序第一**的列(确定性,不猜作者意图)。
|
|
128
|
+
* 匹配键 = 组装器回填的 `sourcePath`(工程级事实;默认路径 `Stories/<id>.json`
|
|
129
|
+
* 不显式存 `sourcePath`——那种文件由 `columnIdOfDocument` 先行命中,不走这里)。
|
|
130
|
+
*/
|
|
131
|
+
export function firstColumnIdOfSourcePath(
|
|
132
|
+
story: Story,
|
|
133
|
+
filePath: string,
|
|
134
|
+
): string | undefined {
|
|
135
|
+
return story.columns.find(
|
|
136
|
+
(column) =>
|
|
137
|
+
typeof column.sourcePath === "string" &&
|
|
138
|
+
column.sourcePath !== "" &&
|
|
139
|
+
column.sourcePath === filePath,
|
|
140
|
+
)?.id;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 引用同步面:jump.target / menu.options[].target / navigate(scene ?? path) */
|
|
144
|
+
function updateRefsDeep(value: unknown, from: string, to: string): unknown {
|
|
145
|
+
if (Array.isArray(value)) {
|
|
146
|
+
return value.map((item) => updateRefsDeep(item, from, to));
|
|
147
|
+
}
|
|
148
|
+
if (value === null || typeof value !== "object") return value;
|
|
149
|
+
const record = value as Record<string, unknown>;
|
|
150
|
+
let next = record;
|
|
151
|
+
const replace = () => {
|
|
152
|
+
next = { ...next };
|
|
153
|
+
};
|
|
154
|
+
if (next.op === "jump" && next.target === from) {
|
|
155
|
+
replace();
|
|
156
|
+
next.target = to;
|
|
157
|
+
}
|
|
158
|
+
if (next.op === "menu" && Array.isArray(next.options)) {
|
|
159
|
+
replace();
|
|
160
|
+
next.options = next.options.map((option) => {
|
|
161
|
+
if (
|
|
162
|
+
option !== null &&
|
|
163
|
+
typeof option === "object" &&
|
|
164
|
+
(option as Record<string, unknown>).target === from
|
|
165
|
+
) {
|
|
166
|
+
return { ...(option as Record<string, unknown>), target: to };
|
|
167
|
+
}
|
|
168
|
+
return option;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
if (next.op === "navigate") {
|
|
172
|
+
// 目标列 = scene ?? path:显式 scene 优先,无 scene 时 path 承载目标
|
|
173
|
+
if (
|
|
174
|
+
next.scene === from ||
|
|
175
|
+
(next.scene === undefined && next.path === from)
|
|
176
|
+
) {
|
|
177
|
+
replace();
|
|
178
|
+
if (next.scene === from) next.scene = to;
|
|
179
|
+
else next.path = to;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return next;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 重命名列并同步全部列内的引用(jump/menu/navigate);列不存在或新 id 撞名返回 null */
|
|
186
|
+
export function renameColumn(
|
|
187
|
+
story: Story,
|
|
188
|
+
from: string,
|
|
189
|
+
to: string,
|
|
190
|
+
): Story | null {
|
|
191
|
+
if (from === to) return story;
|
|
192
|
+
const index = story.columns.findIndex((column) => column.id === from);
|
|
193
|
+
if (index < 0) return null;
|
|
194
|
+
if (story.columns.some((column) => column.id === to)) return null;
|
|
195
|
+
const columns = story.columns.map((column, i) => {
|
|
196
|
+
const renamed = i === index;
|
|
197
|
+
const containers = columnContainers(column);
|
|
198
|
+
if (!renamed && containers.length === 0) return column;
|
|
199
|
+
const next: Record<string, unknown> = {
|
|
200
|
+
...(column as unknown as Record<string, unknown>),
|
|
201
|
+
};
|
|
202
|
+
if (renamed) {
|
|
203
|
+
next.id = to;
|
|
204
|
+
// **重命名 ⇒ 来源文件同步改名**。
|
|
205
|
+
// `sourcePath` 是「这个列来自哪个文件」;列 id 变了而文件名不变,
|
|
206
|
+
// 会写回旧文件名(`Stories/inn.json` 里躺着 id=tavern 的列)——
|
|
207
|
+
// 那是**分裂**:文件名与内容 id 不一致,下次打开会被组装器拒绝
|
|
208
|
+
// (「单列文件名必须等于列 id」)。
|
|
209
|
+
// 只改**文件名段**(basename),目录层级(作者的章节编排)保持不变。
|
|
210
|
+
const source = column.sourcePath;
|
|
211
|
+
if (typeof source === "string" && source !== "") {
|
|
212
|
+
const slash = source.lastIndexOf("/");
|
|
213
|
+
const dir = slash < 0 ? "" : source.slice(0, slash + 1);
|
|
214
|
+
const base = slash < 0 ? source : source.slice(slash + 1);
|
|
215
|
+
const dot = base.indexOf(".");
|
|
216
|
+
const ext = dot < 0 ? "" : base.slice(dot);
|
|
217
|
+
next.sourcePath = `${dir}${to}${ext}`;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
for (const container of containers) {
|
|
221
|
+
next[container.field] = updateRefsDeep(next[container.field], from, to);
|
|
222
|
+
}
|
|
223
|
+
return next as unknown as typeof column;
|
|
224
|
+
});
|
|
225
|
+
return { ...story, columns };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** 向列容器插入命令(index 缺省 = 末尾) */
|
|
229
|
+
export function insertCommand(
|
|
230
|
+
story: Story,
|
|
231
|
+
columnId: string,
|
|
232
|
+
field: "commands" | "elements" | "entry",
|
|
233
|
+
command: Record<string, unknown>,
|
|
234
|
+
index?: number,
|
|
235
|
+
): Story | null {
|
|
236
|
+
const pointer = containerPointer(story, columnId, field);
|
|
237
|
+
if (pointer === null) return null;
|
|
238
|
+
const list = getAtPointer(story, pointer);
|
|
239
|
+
if (!Array.isArray(list)) return null;
|
|
240
|
+
return insertAtPointer(story, pointer, index ?? list.length, command);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** 移除命令(elementPointer 指向命令对象) */
|
|
244
|
+
export function removeCommand(
|
|
245
|
+
story: Story,
|
|
246
|
+
elementPointer: string,
|
|
247
|
+
): Story | null {
|
|
248
|
+
return removeAtPointer(story, elementPointer);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** 更新命令负载字段(elementPointer 指向命令对象) */
|
|
252
|
+
export function updateCommandField(
|
|
253
|
+
story: Story,
|
|
254
|
+
elementPointer: string,
|
|
255
|
+
field: string,
|
|
256
|
+
value: unknown,
|
|
257
|
+
): Story | null {
|
|
258
|
+
return setAtPointer(story, `${elementPointer}/${field}`, value);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** 移动命令在容器内的位置(toIndex 为移除后目标位) */
|
|
262
|
+
export function moveCommand(
|
|
263
|
+
story: Story,
|
|
264
|
+
elementPointer: string,
|
|
265
|
+
toIndex: number,
|
|
266
|
+
): Story | null {
|
|
267
|
+
return moveAtPointer(story, elementPointer, toIndex);
|
|
268
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { MANIFEST_FILE, STORIES_DIR } from "@lingfan-engine/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TS 源工程只读守卫:启用 `Stories.src/` 的工程,其 JSON 产物(`Stories/**` 与
|
|
5
|
+
* `project.json`)由构建管线全量管理(`pnpm stories:build`),编辑器不得写回。
|
|
6
|
+
*
|
|
7
|
+
* 判据 = 清单标记键(非托管键,`serializeProject` 原位保留):构建编排写入
|
|
8
|
+
* `tsSource: true`,编辑器只读不动——浏览侧 FSA 取径拿不到 `Stories.src/` 的
|
|
9
|
+
* 探测能力(资源根外无父句柄),清单标记是两侧唯一的通信通道。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** 清单标记键(构建写入 / 编辑器读取;非托管键跨 `serializeProject` 保真) */
|
|
13
|
+
export const TS_SOURCE_MANIFEST_KEY = "tsSource";
|
|
14
|
+
|
|
15
|
+
/** TS 源工程的统一拒绝文案(编辑器三个写回入口共用) */
|
|
16
|
+
export const TS_SOURCE_READONLY_HINT =
|
|
17
|
+
"TS 源工程(Stories.src/)的产物归构建管线管理——请改 .ts 源后运行 pnpm stories:build,编辑器不写回 Stories/** 与 project.json";
|
|
18
|
+
|
|
19
|
+
/** 工程清单是否标记为 TS 源工程 */
|
|
20
|
+
export function isTsSourceProject(manifest: unknown): boolean {
|
|
21
|
+
return (
|
|
22
|
+
typeof manifest === "object" &&
|
|
23
|
+
manifest !== null &&
|
|
24
|
+
(manifest as Record<string, unknown>)[TS_SOURCE_MANIFEST_KEY] === true
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 在候选写回路径里挑出 TS 源工程禁写项(`Stories/**` 与清单;空数组 = 全部可写) */
|
|
29
|
+
export function tsSourceWriteBlockers(paths: readonly string[]): string[] {
|
|
30
|
+
return paths.filter(
|
|
31
|
+
(path) => path === MANIFEST_FILE || path.startsWith(`${STORIES_DIR}/`),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** fail-closed:TS 源工程下命中禁写路径即抛(含操作指引) */
|
|
36
|
+
export function assertTsSourceWritable(paths: readonly string[]): void {
|
|
37
|
+
const blocked = tsSourceWriteBlockers(paths);
|
|
38
|
+
if (blocked.length === 0) return;
|
|
39
|
+
throw new Error(`${TS_SOURCE_READONLY_HINT}(命中:${blocked.join("、")})`);
|
|
40
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 舞台编辑:拖拽位移 → 新坐标(纯函数,可测)。
|
|
3
|
+
*
|
|
4
|
+
* 元素 `x`/`y` 可为数字(px)或 CSS 长度串(如 `"50%"`)。**只有数字参与像素拖拽**:
|
|
5
|
+
* 百分比 / `calc()` / `em` 等无法与像素位移相加,拖拽时保持原值不动
|
|
6
|
+
* (改这类值请用属性面板显式输入)——静默改成 px 会丢失作者意图。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** 数值坐标(非有限数字 → null) */
|
|
10
|
+
export function parseNumericPosition(value: unknown): number | null {
|
|
11
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 拖拽落点:基准值可拖(数字或缺失)→ 新数值(四舍五入到整数像素);
|
|
16
|
+
* 字符串坐标 → `null`(调用方**保持原值**,不写回)。
|
|
17
|
+
*/
|
|
18
|
+
export function draggedPosition(base: unknown, delta: number): number | null {
|
|
19
|
+
if (typeof base === "string" && base !== "") return null;
|
|
20
|
+
const start = parseNumericPosition(base) ?? 0;
|
|
21
|
+
return Math.round(start + delta);
|
|
22
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 舞台编辑:组件面板 → 舞台**落点创建**元素的纯逻辑。
|
|
3
|
+
*
|
|
4
|
+
* 与 `drag.ts`(移动已有元素)同域:DOM/事件留在组件,**判定与数值只在这里**——
|
|
5
|
+
* - `createElementDraft`:新元素的**最小草稿**(`{type,x,y}`)——只含契约内、有渲染语义的
|
|
6
|
+
* 属性,绝不含 `UNIMPLEMENTED_ELEMENT_ATTRS`(未实现属性清单:写了也不生效的属性不给默认值);
|
|
7
|
+
* 未知类型 / 非有限坐标 fail-closed 返回 `null`(调用方忽略,不静默造坏节点)。
|
|
8
|
+
* - `planElementDrop`:落点规划——命中容器(组件侧已按 DOM rect 判定)→ **进 `children`**
|
|
9
|
+
* 且坐标换算为**相对容器原点**(与运行期渲染一致:子元素绝对定位于父容器内);
|
|
10
|
+
* 未命中 → 追加为顶级元素,落点即 `x`/`y`。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { ELEMENT_TYPES } from "@lingfan-engine/core";
|
|
14
|
+
|
|
15
|
+
/** 一次落点的规划结果:`parentIndex = null` → 顶级;否则 → 该下标容器的 `children`(坐标已换算为相对值) */
|
|
16
|
+
export interface ElementDropPlan {
|
|
17
|
+
parentIndex: number | null;
|
|
18
|
+
x: number;
|
|
19
|
+
y: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 组件侧的容器命中信息(`index` = 顶级元素下标;`originX/Y` = 容器块在画布内容坐标系的原点) */
|
|
23
|
+
export interface DropContainerHit {
|
|
24
|
+
index: number;
|
|
25
|
+
originX: number;
|
|
26
|
+
originY: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 落点规划:未命中容器 → 顶级(坐标 = 画布内容坐标,四舍五入到整数像素,与拖拽位移同口径);
|
|
31
|
+
* 命中 → 相对容器原点取整。纯数值运算,DOM rect 的提取留在组件。
|
|
32
|
+
*/
|
|
33
|
+
export function planElementDrop(
|
|
34
|
+
x: number,
|
|
35
|
+
y: number,
|
|
36
|
+
container: DropContainerHit | undefined,
|
|
37
|
+
): ElementDropPlan {
|
|
38
|
+
if (container === undefined) {
|
|
39
|
+
return { parentIndex: null, x: Math.round(x), y: Math.round(y) };
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
parentIndex: container.index,
|
|
43
|
+
x: Math.round(x - container.originX),
|
|
44
|
+
y: Math.round(y - container.originY),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 新元素草稿:**最小面** `{type, x, y}`(数字坐标;id 缺省 = 运行期 `{列id}#{序号}` 寻址,
|
|
50
|
+
* 不在创建期生成,避免撞号逻辑)。
|
|
51
|
+
*/
|
|
52
|
+
export function createElementDraft(
|
|
53
|
+
type: string,
|
|
54
|
+
x: number,
|
|
55
|
+
y: number,
|
|
56
|
+
): Record<string, unknown> | null {
|
|
57
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
|
58
|
+
if (!(ELEMENT_TYPES as readonly string[]).includes(type)) return null;
|
|
59
|
+
return { type, x: Math.round(x), y: Math.round(y) };
|
|
60
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 元素编辑域出口:舞台拖拽的位移换算与拖放落点规划(纯函数,可测)。
|
|
3
|
+
* 只有数字坐标参与像素拖拽;百分比等 CSS 长度保持原值不动。
|
|
4
|
+
*/
|
|
5
|
+
export { parseNumericPosition, draggedPosition } from "./drag.ts";
|
|
6
|
+
export {
|
|
7
|
+
planElementDrop,
|
|
8
|
+
createElementDraft,
|
|
9
|
+
type ElementDropPlan,
|
|
10
|
+
type DropContainerHit,
|
|
11
|
+
} from "./dragCreate.ts";
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地化工作台 · **覆盖率与状态判据**(纯函数,可测)。
|
|
3
|
+
*
|
|
4
|
+
* 为何不直接用 `reconcileTranslations` 就够了:它给的是**缺译/多译的键集合**,
|
|
5
|
+
* 而工作台要回答的是「这个语言**到什么程度**了」——需要**比率**与**三态**,
|
|
6
|
+
* 且比率的**分母口径**必须显式(见下)。这是 UI 判据,不该散在模板里算。
|
|
7
|
+
*
|
|
8
|
+
* **分母口径(本模块最重要的决定)**:覆盖率分母 = **原文键总数**(`sources.length`),
|
|
9
|
+
* 而非「已出现的译键数」。理由:若用后者,`sources=[]` 时会出现 `0/0`,
|
|
10
|
+
* 而 `0/0` 在 UI 上会被渲染成 `NaN%` 或 `Infinity%` ⇒ 用户看到"进度 100%"
|
|
11
|
+
* 或"进度未定义"。本模块把该情形**显式判为 `empty` 态**(见 `coverageStateOf`)。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** 单语言覆盖率 */
|
|
15
|
+
export interface LangCoverage {
|
|
16
|
+
readonly lang: string;
|
|
17
|
+
/** 原文键总数(分母) */
|
|
18
|
+
readonly total: number;
|
|
19
|
+
/** 该语言已译键数(分子;只数**原文里存在**的键,多译键不进分子) */
|
|
20
|
+
readonly translated: number;
|
|
21
|
+
/** 缺译键(稳定排序) */
|
|
22
|
+
readonly missing: readonly string[];
|
|
23
|
+
/** 多译键(该语言有、原文无;稳定排序) */
|
|
24
|
+
readonly unused: readonly string[];
|
|
25
|
+
/** 覆盖率 0~1(**分母为 0 时取 0**,绝不给 NaN) */
|
|
26
|
+
readonly ratio: number;
|
|
27
|
+
/** 原文**字符总数**(码点计:中文 1/字,emoji 不因代理对算 2) */
|
|
28
|
+
readonly totalChars: number;
|
|
29
|
+
/** 已译原文的**字符总数** */
|
|
30
|
+
readonly translatedChars: number;
|
|
31
|
+
/**
|
|
32
|
+
* **字数加权覆盖率** 0~1(分母为 0 时取 0)。
|
|
33
|
+
*
|
|
34
|
+
* 为什么要加权:条数比把「一句长台词」与「一个两字词」同等看待,
|
|
35
|
+
* 而译者体感是**长句没翻更显眼**。未译键里大量是短词
|
|
36
|
+
* ⇒ 条数比会**低估**译者的实际工作量。
|
|
37
|
+
*
|
|
38
|
+
* 权重取**原文长度**(不取译文长度):i18n 体系里**键 = 原文**,
|
|
39
|
+
* 所以无需值即可算;且「这句原话有多重要」只与原话长短有关,与译本无关。
|
|
40
|
+
* 用译文长度会**奖励「译得长」**(同一句翻成啰嗦的版本反而提升覆盖率)——语义错。
|
|
41
|
+
*/
|
|
42
|
+
readonly weightedRatio: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 键的权重 = **码点长度**(`[...s].length`:中文每字算 1;`s.length` 会把 emoji 算 2) */
|
|
46
|
+
function weightOf(key: string): number {
|
|
47
|
+
return [...key].length;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 单语言覆盖率。
|
|
52
|
+
*
|
|
53
|
+
* 分子的口径:`translated` 只数**原文与该语言译文的交集**——
|
|
54
|
+
* 多译键(译文有、原文无)**不计入**(它们不是"翻译了原文",是残留)。
|
|
55
|
+
*/
|
|
56
|
+
export function langCoverage(
|
|
57
|
+
sources: readonly string[],
|
|
58
|
+
lang: string,
|
|
59
|
+
overlayKeys: readonly string[],
|
|
60
|
+
): LangCoverage {
|
|
61
|
+
const sourceSet = new Set(sources);
|
|
62
|
+
const overlaySet = new Set(overlayKeys);
|
|
63
|
+
const missing: string[] = [];
|
|
64
|
+
let translated = 0;
|
|
65
|
+
let totalChars = 0;
|
|
66
|
+
let translatedChars = 0;
|
|
67
|
+
for (const source of sources) {
|
|
68
|
+
const w = weightOf(source);
|
|
69
|
+
totalChars += w;
|
|
70
|
+
if (overlaySet.has(source)) {
|
|
71
|
+
translated += 1;
|
|
72
|
+
translatedChars += w;
|
|
73
|
+
} else missing.push(source);
|
|
74
|
+
}
|
|
75
|
+
missing.sort();
|
|
76
|
+
const unused = [...overlaySet].filter((k) => !sourceSet.has(k)).sort();
|
|
77
|
+
return {
|
|
78
|
+
lang,
|
|
79
|
+
total: sources.length,
|
|
80
|
+
translated,
|
|
81
|
+
missing,
|
|
82
|
+
unused,
|
|
83
|
+
ratio: sources.length === 0 ? 0 : translated / sources.length,
|
|
84
|
+
totalChars,
|
|
85
|
+
translatedChars,
|
|
86
|
+
weightedRatio: totalChars === 0 ? 0 : translatedChars / totalChars,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 多语言工作台全貌(稳定排序:按语言码码元序) */
|
|
91
|
+
export interface WorkbenchOverview {
|
|
92
|
+
readonly coverages: readonly LangCoverage[];
|
|
93
|
+
/** 全部语言的多译键并集(= `unused-translation` 诊断同口径) */
|
|
94
|
+
readonly unusedAll: readonly string[];
|
|
95
|
+
/** 原文键总数(各语言共用同一分母) */
|
|
96
|
+
readonly totalSources: number;
|
|
97
|
+
/** 原文**字符总数**(= 字数加权覆盖率的分母;各语言共用) */
|
|
98
|
+
readonly totalSourceChars: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 汇总多语言覆盖率(`overlayKeysByLang` 的键 = 语言码) */
|
|
102
|
+
export function workbenchOverview(
|
|
103
|
+
sources: readonly string[],
|
|
104
|
+
overlayKeysByLang: Readonly<Record<string, readonly string[]>>,
|
|
105
|
+
): WorkbenchOverview {
|
|
106
|
+
const langs = Object.keys(overlayKeysByLang).sort();
|
|
107
|
+
const coverages = langs.map((lang) =>
|
|
108
|
+
langCoverage(sources, lang, overlayKeysByLang[lang] ?? []),
|
|
109
|
+
);
|
|
110
|
+
const unusedAll = [...new Set(coverages.flatMap((c) => c.unused))].sort();
|
|
111
|
+
return {
|
|
112
|
+
coverages,
|
|
113
|
+
unusedAll,
|
|
114
|
+
totalSources: sources.length,
|
|
115
|
+
// 各语言共用同一分母(原文长度与译本无关)⇒ 不取 coverages 的平均,避免与「同一分母」矛盾
|
|
116
|
+
totalSourceChars: sources.reduce((sum, s) => sum + weightOf(s), 0),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 覆盖率的**三态**(UI 据此选文案与配色,绝不裸显示 `NaN%`) */
|
|
121
|
+
export type CoverageState = "empty" | "untranslated" | "partial" | "complete";
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 三态判定。
|
|
125
|
+
*
|
|
126
|
+
* - `empty` = **原文为空**(无可译内容;`0/0` 归此类,**不是 100%**)
|
|
127
|
+
* - `complete` = 全部已译
|
|
128
|
+
* - `untranslated` = 一条都没译
|
|
129
|
+
* - `partial` = 其余
|
|
130
|
+
*/
|
|
131
|
+
export function coverageStateOf(c: LangCoverage): CoverageState {
|
|
132
|
+
if (c.total === 0) return "empty";
|
|
133
|
+
if (c.translated === c.total) return "complete";
|
|
134
|
+
if (c.translated === 0) return "untranslated";
|
|
135
|
+
return "partial";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 三态的显示文案(**状态口径**:描述当前状态,不叙述历史) */
|
|
139
|
+
export function coverageLabelOf(state: CoverageState): string {
|
|
140
|
+
switch (state) {
|
|
141
|
+
case "empty":
|
|
142
|
+
return "无可译内容";
|
|
143
|
+
case "complete":
|
|
144
|
+
return "已全部译出";
|
|
145
|
+
case "untranslated":
|
|
146
|
+
return "尚未开始";
|
|
147
|
+
default:
|
|
148
|
+
return "部分已译";
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 百分比文本(**永不出现 NaN/Infinity**;空态给「—」而不是 0%)——**按条数** */
|
|
153
|
+
export function coveragePercentOf(c: LangCoverage): string {
|
|
154
|
+
if (c.total === 0) return "—";
|
|
155
|
+
return `${Math.round(c.ratio * 100)}%`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* **字数加权**百分比文本(主显示口径)。
|
|
160
|
+
*
|
|
161
|
+
* 为何它是主显示:条数比把「一句长台词」与「一个两字词」同等看待,
|
|
162
|
+
* 而译者体感是**长句没翻更显眼**。诊断里「未使用的译文键」多为短词
|
|
163
|
+
* ⇒ 条数比会**高估**完成度(短词翻完就跳一大截)。
|
|
164
|
+
*
|
|
165
|
+
* 空态判据与按条数版**一致**(`total === 0` ⇒ 「—」):两者都是「无可译内容」的同一态,
|
|
166
|
+
* 不该一个「—」一个「0%」。而 `totalChars === 0` 只可能发生在所有键都是空串
|
|
167
|
+
* (已被上游 fail-closed 拦掉),但此处仍兜底。
|
|
168
|
+
*/
|
|
169
|
+
export function coverageWeightedPercentOf(c: LangCoverage): string {
|
|
170
|
+
if (c.total === 0 || c.totalChars === 0) return "—";
|
|
171
|
+
return `${Math.round(c.weightedRatio * 100)}%`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 骨架生成的分组依据(骨架布局决定 `Lang/{lang}/**` 怎么切) */
|
|
175
|
+
export type SkeletonLayoutChoice = "single" | "main" | "per-story";
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 按布局把「故事 → 该故事原文键」分好组,供 `planOverlaySkeleton` 消费。
|
|
179
|
+
*
|
|
180
|
+
* **键 = 故事文件相对 `Stories/` 的路径(不含扩展名)**(与 `SkeletonOptions` 契约一致)——
|
|
181
|
+
* 这里只做**分组**不做过滤(非法 id 由 `planOverlaySkeleton` fail-closed 抛)。
|
|
182
|
+
*/
|
|
183
|
+
export function groupKeysByStory(
|
|
184
|
+
storyKeys: ReadonlyMap<string, readonly string[]>,
|
|
185
|
+
): ReadonlyMap<string, readonly string[]> {
|
|
186
|
+
const out = new Map<string, readonly string[]>();
|
|
187
|
+
for (const [story, keys] of storyKeys) {
|
|
188
|
+
out.set(story, [...keys].sort());
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* i18n 工具链 · 原文键抽取器。
|
|
3
|
+
*
|
|
4
|
+
* 「原文即 key」的正向半边:遍历故事,产出**会被运行期 Translate 的全部原文**
|
|
5
|
+
* (去重、稳定排序)。口径 = `TRANSLATE_SURFACES`(命令流,含嵌套块体)+ 元素 `text`
|
|
6
|
+
* (含 children 递归)——与诊断 `indexStory.originals` 同表同源;互锁测试锁定
|
|
7
|
+
* 「抽取器 ≡ 诊断原文集合 ≡ 运行期真查的键」三方可证。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Story } from "@lingfan-engine/core";
|
|
11
|
+
import { walkStoryCommands, walkStoryElements } from "../schema/index.ts";
|
|
12
|
+
import { isPlainObject } from "../shared/index.ts";
|
|
13
|
+
import { TRANSLATE_SURFACES, valuesAtPath } from "./surfaces.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 从故事抽取可翻译原文键集合(= 运行期会查 overlay 表的全部字符串,插值前原文)。
|
|
17
|
+
* 畸形输入 fail-closed 返回空数组;输出**稳定排序**——骨架生成、
|
|
18
|
+
* 对账报告与快照对比的确定性都依赖它。
|
|
19
|
+
*/
|
|
20
|
+
export function extractStoryKeys(story: Story): string[] {
|
|
21
|
+
if (story === null || typeof story !== "object") return [];
|
|
22
|
+
const keys = new Set<string>();
|
|
23
|
+
walkStoryCommands(story, (cmd) => {
|
|
24
|
+
if (!isPlainObject(cmd) || typeof cmd.op !== "string") return;
|
|
25
|
+
for (const path of TRANSLATE_SURFACES[cmd.op] ?? []) {
|
|
26
|
+
for (const { value } of valuesAtPath(cmd, path)) {
|
|
27
|
+
if (typeof value === "string" && value !== "") keys.add(value);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
// 元素展示文字(运行期 translateElements 同口径:装载时 Translate,递归 children)
|
|
32
|
+
walkStoryElements(story, (node) => {
|
|
33
|
+
if (!isPlainObject(node)) return;
|
|
34
|
+
const text = node.text;
|
|
35
|
+
if (typeof text === "string" && text !== "") keys.add(text);
|
|
36
|
+
});
|
|
37
|
+
return [...keys].sort();
|
|
38
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* i18n 工具链(正向「从故事生成 i18n」):翻译面单一事实源(surfaces)+
|
|
3
|
+
* 原文键抽取 + overlay 骨架生成 + 缺译/多译对账。
|
|
4
|
+
* 全部纯函数:落盘 / CLI / UI 归调用方;与诊断 originals 消费同一张面表
|
|
5
|
+
* (互锁:抽取与诊断同表、骨架往返、覆盖率报告)。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { TRANSLATE_SURFACES, valuesAtPath } from "./surfaces.ts";
|
|
9
|
+
export { extractStoryKeys } from "./extract.ts";
|
|
10
|
+
export { columnStoryKey } from "./storyKeys.ts";
|
|
11
|
+
export {
|
|
12
|
+
planOverlaySkeleton,
|
|
13
|
+
type OverlaySkeletonFile,
|
|
14
|
+
type SkeletonLayout,
|
|
15
|
+
type SkeletonOptions,
|
|
16
|
+
type SkeletonPlaceholder,
|
|
17
|
+
} from "./skeleton.ts";
|
|
18
|
+
export {
|
|
19
|
+
formatTranslationReport,
|
|
20
|
+
reconcileTranslations,
|
|
21
|
+
type TranslationReconcileReport,
|
|
22
|
+
} from "./reconcile.ts";
|
|
23
|
+
export {
|
|
24
|
+
coverageLabelOf,
|
|
25
|
+
coveragePercentOf,
|
|
26
|
+
coverageWeightedPercentOf,
|
|
27
|
+
coverageStateOf,
|
|
28
|
+
groupKeysByStory,
|
|
29
|
+
langCoverage,
|
|
30
|
+
workbenchOverview,
|
|
31
|
+
type CoverageState,
|
|
32
|
+
type LangCoverage,
|
|
33
|
+
type SkeletonLayoutChoice,
|
|
34
|
+
type WorkbenchOverview,
|
|
35
|
+
} from "./coverage.ts";
|
|
36
|
+
export {
|
|
37
|
+
addTranslationRow,
|
|
38
|
+
isTableDirty,
|
|
39
|
+
parseTranslationTable,
|
|
40
|
+
removeTranslationRow,
|
|
41
|
+
renameTranslationRow,
|
|
42
|
+
serializeTranslationTable,
|
|
43
|
+
setTranslationValue,
|
|
44
|
+
type TableEdit,
|
|
45
|
+
type TableParse,
|
|
46
|
+
type TranslationRow,
|
|
47
|
+
} from "./table.ts";
|