@lingfan-engine/editor 0.0.0-stage → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Pointer(RFC 6901)读取与不可变树编辑:沿路径克隆(结构共享),
|
|
3
|
+
* 未命中路径 fail-closed 返回 null——编辑器映射器只做纯函数。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describeForm } from "../schema/index.ts";
|
|
7
|
+
import { BUILTIN_OP_SURFACE, type OpSurface } from "../schema/index.ts";
|
|
8
|
+
|
|
9
|
+
type PathSegment = string | number;
|
|
10
|
+
|
|
11
|
+
/** 组装 JSON Pointer(段转义 ~ 与 /) */
|
|
12
|
+
export function buildPointer(segments: readonly PathSegment[]): string {
|
|
13
|
+
if (segments.length === 0) return "";
|
|
14
|
+
return (
|
|
15
|
+
"/" +
|
|
16
|
+
segments
|
|
17
|
+
.map((segment) =>
|
|
18
|
+
String(segment).replaceAll("~", "~0").replaceAll("/", "~1"),
|
|
19
|
+
)
|
|
20
|
+
.join("/")
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parsePointer(pointer: string): PathSegment[] {
|
|
25
|
+
if (pointer === "" || !pointer.startsWith("/")) return [];
|
|
26
|
+
return pointer
|
|
27
|
+
.split("/")
|
|
28
|
+
.slice(1)
|
|
29
|
+
.map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"))
|
|
30
|
+
.map((segment) => (/^\d+$/.test(segment) ? Number(segment) : segment));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 读取指针处值;指针非法或路径未命中返回 undefined */
|
|
34
|
+
export function getAtPointer(root: unknown, pointer: string): unknown {
|
|
35
|
+
let current: unknown = root;
|
|
36
|
+
for (const segment of parsePointer(pointer)) {
|
|
37
|
+
if (typeof segment === "number") {
|
|
38
|
+
if (!Array.isArray(current)) return undefined;
|
|
39
|
+
current = current[segment];
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (current === null || typeof current !== "object") return undefined;
|
|
43
|
+
current = (current as Record<string, unknown>)[segment];
|
|
44
|
+
}
|
|
45
|
+
return current;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function shallowClone(
|
|
49
|
+
node: unknown,
|
|
50
|
+
): Record<string, unknown> | unknown[] | undefined {
|
|
51
|
+
if (Array.isArray(node)) return node.slice();
|
|
52
|
+
if (node !== null && typeof node === "object") {
|
|
53
|
+
return { ...(node as Record<string, unknown>) };
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 沿 parentSegments 克隆到目标父节点(结构共享),对父克隆应用 apply。
|
|
60
|
+
* apply 返回 false = 未命中(fail-closed);返回 undefined = 路径未命中。
|
|
61
|
+
*/
|
|
62
|
+
function editAt(
|
|
63
|
+
root: unknown,
|
|
64
|
+
parentSegments: readonly PathSegment[],
|
|
65
|
+
key: PathSegment,
|
|
66
|
+
apply: (parentClone: unknown, key: PathSegment) => boolean,
|
|
67
|
+
): unknown | undefined {
|
|
68
|
+
if (parentSegments.length === 0) {
|
|
69
|
+
const parentClone = shallowClone(root);
|
|
70
|
+
if (parentClone === undefined) return undefined;
|
|
71
|
+
return apply(parentClone, key) ? parentClone : undefined;
|
|
72
|
+
}
|
|
73
|
+
const [head, ...rest] = parentSegments;
|
|
74
|
+
if (typeof head === "number") {
|
|
75
|
+
if (!Array.isArray(root) || head < 0 || head >= root.length)
|
|
76
|
+
return undefined;
|
|
77
|
+
const child = editAt(root[head], rest, key, apply);
|
|
78
|
+
if (child === undefined) return undefined;
|
|
79
|
+
const clone = root.slice();
|
|
80
|
+
clone[head] = child;
|
|
81
|
+
return clone;
|
|
82
|
+
}
|
|
83
|
+
if (root === null || typeof root !== "object") return undefined;
|
|
84
|
+
const record = root as Record<string, unknown>;
|
|
85
|
+
if (!(head in record)) return undefined;
|
|
86
|
+
const child = editAt(record[head], rest, key, apply);
|
|
87
|
+
if (child === undefined) return undefined;
|
|
88
|
+
return { ...record, [head]: child };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function keyOf(
|
|
92
|
+
parent: unknown,
|
|
93
|
+
key: PathSegment,
|
|
94
|
+
): { get(): unknown; set(value: unknown): boolean } | null {
|
|
95
|
+
if (typeof key === "number") {
|
|
96
|
+
if (!Array.isArray(parent)) return null;
|
|
97
|
+
return {
|
|
98
|
+
get: () => parent[key],
|
|
99
|
+
set: (value) => {
|
|
100
|
+
if (key < 0 || key > parent.length) return false;
|
|
101
|
+
parent[key] = value;
|
|
102
|
+
return true;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (parent === null || typeof parent !== "object") return null;
|
|
107
|
+
const record = parent as Record<string, unknown>;
|
|
108
|
+
return {
|
|
109
|
+
get: () => record[key],
|
|
110
|
+
set: (value) => {
|
|
111
|
+
record[key] = value;
|
|
112
|
+
return true;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 写入指针处值(不可变;数组索引允许 == length 追加);路径未命中返回 null */
|
|
118
|
+
export function setAtPointer<T>(
|
|
119
|
+
root: T,
|
|
120
|
+
pointer: string,
|
|
121
|
+
value: unknown,
|
|
122
|
+
): T | null {
|
|
123
|
+
const segments = parsePointer(pointer);
|
|
124
|
+
if (segments.length === 0) return null;
|
|
125
|
+
const key = segments[segments.length - 1]!;
|
|
126
|
+
const next = editAt(root, segments.slice(0, -1), key, (parent, k) => {
|
|
127
|
+
const slot = keyOf(parent, k);
|
|
128
|
+
return slot !== null && slot.set(value);
|
|
129
|
+
});
|
|
130
|
+
return next === undefined ? null : (next as T);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 移除指针处值(数组按索引删,对象删键);不可变;未命中返回 null */
|
|
134
|
+
export function removeAtPointer<T>(root: T, pointer: string): T | null {
|
|
135
|
+
const segments = parsePointer(pointer);
|
|
136
|
+
if (segments.length === 0) return null;
|
|
137
|
+
const key = segments[segments.length - 1]!;
|
|
138
|
+
const next = editAt(root, segments.slice(0, -1), key, (parent, k) => {
|
|
139
|
+
if (typeof k === "number") {
|
|
140
|
+
if (!Array.isArray(parent) || k < 0 || k >= parent.length) return false;
|
|
141
|
+
parent.splice(k, 1);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
if (parent === null || typeof parent !== "object") return false;
|
|
145
|
+
if (!(k in (parent as Record<string, unknown>))) return false;
|
|
146
|
+
delete (parent as Record<string, unknown>)[k];
|
|
147
|
+
return true;
|
|
148
|
+
});
|
|
149
|
+
return next === undefined ? null : (next as T);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 数组插入:pointer 指向数组,index 为插入位;不可变;未命中返回 null */
|
|
153
|
+
export function insertAtPointer<T>(
|
|
154
|
+
root: T,
|
|
155
|
+
pointer: string,
|
|
156
|
+
index: number,
|
|
157
|
+
value: unknown,
|
|
158
|
+
): T | null {
|
|
159
|
+
const segments = parsePointer(pointer);
|
|
160
|
+
if (segments.length === 0) return null;
|
|
161
|
+
const key = segments[segments.length - 1]!;
|
|
162
|
+
const next = editAt(root, segments.slice(0, -1), key, (parent, k) => {
|
|
163
|
+
const slot = keyOf(parent, k);
|
|
164
|
+
if (slot === null || !Array.isArray(slot.get())) return false;
|
|
165
|
+
const list = slot.get() as unknown[];
|
|
166
|
+
if (index < 0 || index > list.length) return false;
|
|
167
|
+
const copy = list.slice();
|
|
168
|
+
copy.splice(index, 0, value);
|
|
169
|
+
return slot.set(copy);
|
|
170
|
+
});
|
|
171
|
+
return next === undefined ? null : (next as T);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 数组元素移动:elementPointer 指向元素,toIndex 为移除后目标位;不可变;未命中返回 null */
|
|
175
|
+
export function moveAtPointer<T>(
|
|
176
|
+
root: T,
|
|
177
|
+
elementPointer: string,
|
|
178
|
+
toIndex: number,
|
|
179
|
+
): T | null {
|
|
180
|
+
const segments = parsePointer(elementPointer);
|
|
181
|
+
if (segments.length === 0) return null;
|
|
182
|
+
const key = segments[segments.length - 1]!;
|
|
183
|
+
if (typeof key !== "number") return null;
|
|
184
|
+
const next = editAt(root, segments.slice(0, -1), key, (parent, k) => {
|
|
185
|
+
if (!Array.isArray(parent) || typeof k !== "number") return false;
|
|
186
|
+
if (k < 0 || k >= parent.length) return false;
|
|
187
|
+
if (toIndex < 0 || toIndex >= parent.length) return false;
|
|
188
|
+
const [moved] = parent.splice(k, 1);
|
|
189
|
+
parent.splice(toIndex, 0, moved);
|
|
190
|
+
return true;
|
|
191
|
+
});
|
|
192
|
+
return next === undefined ? null : (next as T);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function isCommandAt(root: unknown, pointer: string): boolean {
|
|
196
|
+
const node = getAtPointer(root, pointer);
|
|
197
|
+
return (
|
|
198
|
+
node !== null &&
|
|
199
|
+
typeof node === "object" &&
|
|
200
|
+
!Array.isArray(node) &&
|
|
201
|
+
typeof (node as Record<string, unknown>).op === "string"
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 归一到**最近的命令祖先指针**:诊断/引用给出的指针是字段级(精确到
|
|
207
|
+
* 字段),而时间线行选中与属性面板表单都锚在命令指针上——指针不是命令时沿前缀
|
|
208
|
+
* **从长到短**上溯包含它的命令(嵌套块体逐级上溯);本身已是命令、或无命令祖先
|
|
209
|
+
* (列指针 / 根)→ 原样返回(调用方既有语义不变)。
|
|
210
|
+
*/
|
|
211
|
+
export function nearestCommandPointer(story: unknown, pointer: string): string {
|
|
212
|
+
if (isCommandAt(story, pointer)) return pointer;
|
|
213
|
+
const segments = parsePointer(pointer);
|
|
214
|
+
for (let i = segments.length - 1; i >= 2; i -= 1) {
|
|
215
|
+
const prefix = buildPointer(segments.slice(0, i));
|
|
216
|
+
if (isCommandAt(story, prefix)) return prefix;
|
|
217
|
+
}
|
|
218
|
+
return pointer;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/* ------------------------------------------------------------------ *
|
|
222
|
+
* 选中态语义
|
|
223
|
+
* ------------------------------------------------------------------ */
|
|
224
|
+
|
|
225
|
+
/** 选中态分类;**除 `unknown-op` 外都不是错误**(列 / 元素 / 数组项都是正常选中) */
|
|
226
|
+
export type SelectionKind = "none" | "non-command" | "unknown-op" | "command";
|
|
227
|
+
|
|
228
|
+
/** 非命令选中的细分(仅供文案取用,不改变 `kind`) */
|
|
229
|
+
export type SelectionNodeKind = "column" | "element" | "item" | "other";
|
|
230
|
+
|
|
231
|
+
export interface SelectionDescription {
|
|
232
|
+
kind: SelectionKind;
|
|
233
|
+
/** `non-command` / `unknown-op` 时有意义 */
|
|
234
|
+
nodeKind?: SelectionNodeKind;
|
|
235
|
+
/** `command` / `unknown-op` 时有意义 */
|
|
236
|
+
opName?: string;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** 按指针形状细分非命令目标:`/columns/<i>` = 列;`…/elements/<j>` = 元素;`…/commands/<j>` = 组内项 */
|
|
240
|
+
function selectionNodeKind(pointer: string): SelectionNodeKind {
|
|
241
|
+
const segments = parsePointer(pointer);
|
|
242
|
+
if (segments[0] !== "columns") return "other";
|
|
243
|
+
if (segments.length === 2) return "column";
|
|
244
|
+
if (segments[2] === "elements") return "element";
|
|
245
|
+
if (segments[2] === "commands") return "item";
|
|
246
|
+
return "other";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 描述「当前选中了什么」——**四态**,而不是「是命令 / 不是命令」两态。
|
|
251
|
+
*
|
|
252
|
+
* **要点**:属性面板**不得**把「选中的是一列」渲染成错误——「所选位置不是命令
|
|
253
|
+
* (op 缺失)——诊断面板有详情」会与**同一时刻**诊断面板显示的「✓ 无诊断」
|
|
254
|
+
* 互相矛盾,且把用户的**正常操作**说成故障。真实语义有四种:
|
|
255
|
+
*
|
|
256
|
+
* | kind | 触发 | 该说什么 |
|
|
257
|
+
* |---|---|---|
|
|
258
|
+
* | `none` | `pointer === null` | 中性引导:选一条命令 |
|
|
259
|
+
* | `non-command` | 指针落在列 / 元素 / 数组项 | **中性陈述**,**不得出现「错误/缺失/诊断」** |
|
|
260
|
+
* | `unknown-op` | 落在命令上,但**无表单描述符** | **真问题**(提诊断、可删除) |
|
|
261
|
+
* | `command` | 正常命令 | 现有表单 |
|
|
262
|
+
*
|
|
263
|
+
* 判定顺序:`null` → `none`;节点是对象且 `op` 为字符串 → 有描述符即 `command`,
|
|
264
|
+
* 否则 `unknown-op`(把操作数也带上,即 `opName`);其余 → `non-command`。
|
|
265
|
+
* **指针悬空**(`getAtPointer` 未命中)同样归 `non-command` —— fail-soft:面板给
|
|
266
|
+
* 中性文案,**不谎报成错误**(悬空多由撤销后的陈旧选中造成,不属 op 缺失)。
|
|
267
|
+
*
|
|
268
|
+
* `surface` 与 `describeForm` 同口径(缺省 = 内建 op 面)——**不得**在这里换一套
|
|
269
|
+
* op 面,否则会与面板表单的判断打架。
|
|
270
|
+
*/
|
|
271
|
+
export function describeSelection(
|
|
272
|
+
story: unknown,
|
|
273
|
+
pointer: string | null,
|
|
274
|
+
surface: OpSurface = BUILTIN_OP_SURFACE,
|
|
275
|
+
): SelectionDescription {
|
|
276
|
+
if (pointer === null) return { kind: "none" };
|
|
277
|
+
const nodeKind = selectionNodeKind(pointer);
|
|
278
|
+
const node = getAtPointer(story, pointer);
|
|
279
|
+
if (
|
|
280
|
+
node !== null &&
|
|
281
|
+
typeof node === "object" &&
|
|
282
|
+
!Array.isArray(node) &&
|
|
283
|
+
typeof (node as Record<string, unknown>).op === "string"
|
|
284
|
+
) {
|
|
285
|
+
const opName = (node as Record<string, unknown>).op as string;
|
|
286
|
+
return describeForm(opName, surface) === undefined
|
|
287
|
+
? { kind: "unknown-op", nodeKind, opName }
|
|
288
|
+
: { kind: "command", nodeKind, opName };
|
|
289
|
+
}
|
|
290
|
+
return { kind: "non-command", nodeKind };
|
|
291
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 保存前规范化提示:
|
|
3
|
+
* 「不再提示」偏好持久化 + 提示文案组装。
|
|
4
|
+
*
|
|
5
|
+
* 分层:**检测**归引擎(`detectWriteNormalization`,与 `serializeProject` 同一布局
|
|
6
|
+
* 知识族);本模块只管编辑器视图侧的两件事——偏好是**本机视图偏好**(边界:
|
|
7
|
+
* 不入故事 JSON、任何存储失败全静默,与列分组相同降级策略),文案负责把 finding
|
|
8
|
+
* 翻成与真实写回行为一一对应的行。
|
|
9
|
+
*/
|
|
10
|
+
import type { WriteNormalizationFinding } from "@lingfan-engine/core";
|
|
11
|
+
import type { KeyValueStorage } from "./columnGrouping.ts";
|
|
12
|
+
|
|
13
|
+
/** 「不再提示」键(全局一次性偏好,非按工程——规范化行为是引擎布局规则,不随工程变) */
|
|
14
|
+
export const NORMALIZATION_NOTICE_PREF_KEY =
|
|
15
|
+
"lingfan-editor-skip-normalization-notice";
|
|
16
|
+
|
|
17
|
+
/** 读偏好:键缺失 / 坏值 / 存储失败一律 `false`(提示默认开启,fail-open 到可见侧) */
|
|
18
|
+
export function readSkipNormalizationNotice(
|
|
19
|
+
storage: KeyValueStorage | undefined,
|
|
20
|
+
): boolean {
|
|
21
|
+
try {
|
|
22
|
+
return storage?.getItem(NORMALIZATION_NOTICE_PREF_KEY) === "1";
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 写偏好:`true` 记 `"1"`、`false` 记 `"0"`(读侧只认 `"1"`);失败静默 = 仅本次会话有效 */
|
|
29
|
+
export function writeSkipNormalizationNotice(
|
|
30
|
+
storage: KeyValueStorage | undefined,
|
|
31
|
+
skip: boolean,
|
|
32
|
+
): void {
|
|
33
|
+
try {
|
|
34
|
+
storage?.setItem(NORMALIZATION_NOTICE_PREF_KEY, skip ? "1" : "0");
|
|
35
|
+
} catch {
|
|
36
|
+
/* 存储不可用(配额 / 隐私模式)= 偏好仅本次会话有效 */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* finding → 提示行(每行对应一个真实文件级动作;码元序稳定输出)。
|
|
42
|
+
* 空 finding → 空数组(界面据此不渲染提示)。
|
|
43
|
+
*/
|
|
44
|
+
export function describeNormalization(
|
|
45
|
+
finding: WriteNormalizationFinding,
|
|
46
|
+
): string[] {
|
|
47
|
+
const lines: string[] = [];
|
|
48
|
+
for (const path of finding.toConvert) {
|
|
49
|
+
const json = `${path.slice(0, -".story".length)}.json`;
|
|
50
|
+
lines.push(`「${path}」将转换为标准 JSON 列文件「${json}」(内容等价)`);
|
|
51
|
+
}
|
|
52
|
+
for (const path of finding.toRemove) {
|
|
53
|
+
lines.push(
|
|
54
|
+
`「${path}」将从磁盘移除(内容重组为按列命名的单列文件,或已不被当前故事引用)`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return lines;
|
|
58
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 编辑器会话(视图族共享中枢):所有视图经同一 session 提交编辑——
|
|
3
|
+
* 一处改动全视图同步(订阅通知)+ 统一 undo 栈(一次提交 = 一个 undo 单元,
|
|
4
|
+
* 深度可配置)。故事树不可变:提交即整体换引用,视图零 diff 成本。
|
|
5
|
+
* 统一 undo:跨视图同步、一次提交一个单元。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Story } from "@lingfan-engine/core";
|
|
9
|
+
import type { StoryListener, UndoEntry } from "../contracts/index.ts";
|
|
10
|
+
|
|
11
|
+
const DEFAULT_UNDO_CAPACITY = 100;
|
|
12
|
+
|
|
13
|
+
export class EditorSession {
|
|
14
|
+
private current: Story;
|
|
15
|
+
/** 已保存基线(引用比较:commit 恒换引用、undo 恢复原引用 → 撤销回已保存态即干净) */
|
|
16
|
+
private saved: Story;
|
|
17
|
+
private readonly undoStack: UndoEntry[] = [];
|
|
18
|
+
private readonly redoStack: UndoEntry[] = [];
|
|
19
|
+
private readonly listeners = new Set<StoryListener>();
|
|
20
|
+
private readonly capacity: number;
|
|
21
|
+
|
|
22
|
+
constructor(story: Story, options: { undoCapacity?: number } = {}) {
|
|
23
|
+
this.current = story;
|
|
24
|
+
this.saved = story;
|
|
25
|
+
this.capacity = Math.max(1, options.undoCapacity ?? DEFAULT_UNDO_CAPACITY);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get story(): Story {
|
|
29
|
+
return this.current;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 有未保存更改。引用比较:唯一代价是「内容相同的不同引用」会多亮一次按钮,
|
|
34
|
+
* **不会漏报**未保存(安全方向正确);深比较每次渲染全树遍历,不划算。
|
|
35
|
+
*/
|
|
36
|
+
get dirty(): boolean {
|
|
37
|
+
return this.current !== this.saved;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 保存成功:把基线钉到**实际写出的那个引用**(不是当前引用)——
|
|
42
|
+
* 保存期间用户又编辑时,`current !== reference` → 仍 dirty,不会误清。
|
|
43
|
+
*/
|
|
44
|
+
markSaved(reference: Story = this.current): void {
|
|
45
|
+
if (this.saved === reference) return;
|
|
46
|
+
this.saved = reference;
|
|
47
|
+
this.notify();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* **换基线**(结构刷新,不产生历史):把当前树与已保存基线**同时**换掉。
|
|
52
|
+
*
|
|
53
|
+
* 为何不用 `commit`:那是**用户编辑**的语义(压一个 undo 单元)。工程结构变化
|
|
54
|
+
* (如新增一列 ⇒ 既有列的切分投影跟着变)需要的是「这些文档本来就干净,
|
|
55
|
+
* 让它们继续干净」,而不是给每个文档记一条假历史(撤销键会堆满噪声)。
|
|
56
|
+
*
|
|
57
|
+
* **有未保存改动时拒绝**(返回 false):那属于编辑,必须走 `commit`
|
|
58
|
+
* 才能撤销 —— 静默丢弃作者的改动不可接受。
|
|
59
|
+
*/
|
|
60
|
+
replaceClean(next: Story): boolean {
|
|
61
|
+
if (this.dirty) return false;
|
|
62
|
+
this.current = next;
|
|
63
|
+
this.saved = next;
|
|
64
|
+
this.notify();
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get canUndo(): boolean {
|
|
69
|
+
return this.undoStack.length > 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get canRedo(): boolean {
|
|
73
|
+
return this.redoStack.length > 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get undoDepth(): number {
|
|
77
|
+
return this.undoStack.length;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get redoDepth(): number {
|
|
81
|
+
return this.redoStack.length;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 提交一次编辑(一个 undo 单元):同引用提交为 no-op(无变更不产生历史噪声)。
|
|
86
|
+
* 提交后清空 redo 尾(新分支时间线,与菜单重选开新时间线同思想)。
|
|
87
|
+
*/
|
|
88
|
+
commit(label: string, next: Story): void {
|
|
89
|
+
if (next === this.current) return;
|
|
90
|
+
this.undoStack.push({ label, before: this.current, after: next });
|
|
91
|
+
if (this.undoStack.length > this.capacity) this.undoStack.shift();
|
|
92
|
+
this.redoStack.length = 0;
|
|
93
|
+
this.current = next;
|
|
94
|
+
this.notify();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 由编辑函数派生新故事并提交;编辑函数返回 null = 未命中,不产生历史单元 */
|
|
98
|
+
apply(label: string, edit: (story: Story) => Story | null): boolean {
|
|
99
|
+
const next = edit(this.current);
|
|
100
|
+
if (next === null) return false;
|
|
101
|
+
this.commit(label, next);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
undo(): boolean {
|
|
106
|
+
const entry = this.undoStack.pop();
|
|
107
|
+
if (entry === undefined) return false;
|
|
108
|
+
this.redoStack.push(entry);
|
|
109
|
+
this.current = entry.before;
|
|
110
|
+
this.notify();
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
redo(): boolean {
|
|
115
|
+
const entry = this.redoStack.pop();
|
|
116
|
+
if (entry === undefined) return false;
|
|
117
|
+
this.undoStack.push(entry);
|
|
118
|
+
this.current = entry.after;
|
|
119
|
+
this.notify();
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 订阅故事变更(视图同步接缝);返回退订函数 */
|
|
124
|
+
subscribe(listener: StoryListener): () => void {
|
|
125
|
+
this.listeners.add(listener);
|
|
126
|
+
return () => {
|
|
127
|
+
this.listeners.delete(listener);
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private notify(): void {
|
|
132
|
+
for (const listener of this.listeners) listener(this.current);
|
|
133
|
+
}
|
|
134
|
+
}
|