cortico 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.
Files changed (311) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +47 -0
  3. package/package.json +43 -0
  4. package/src/boot.ts +46 -0
  5. package/src/bot.ts +1342 -0
  6. package/src/config-file.ts +41 -0
  7. package/src/core/README.md +92 -0
  8. package/src/core/billing.ts +26 -0
  9. package/src/core/blobs.ts +106 -0
  10. package/src/core/bus.ts +311 -0
  11. package/src/core/config-schema.ts +201 -0
  12. package/src/core/config.ts +262 -0
  13. package/src/core/core.ts +699 -0
  14. package/src/core/cost.ts +244 -0
  15. package/src/core/event-store.ts +304 -0
  16. package/src/core/fork.ts +113 -0
  17. package/src/core/generation.ts +118 -0
  18. package/src/core/instance-lock.ts +215 -0
  19. package/src/core/ipc-logger.ts +105 -0
  20. package/src/core/language.ts +62 -0
  21. package/src/core/log-context.ts +38 -0
  22. package/src/core/loop.ts +1858 -0
  23. package/src/core/markers.ts +48 -0
  24. package/src/core/prefix.ts +121 -0
  25. package/src/core/run.ts +95 -0
  26. package/src/core/secrets.ts +18 -0
  27. package/src/core/session.ts +56 -0
  28. package/src/core/sessions.ts +261 -0
  29. package/src/core/state.ts +104 -0
  30. package/src/core/template.ts +72 -0
  31. package/src/core/timers.ts +129 -0
  32. package/src/core/tool-log.ts +135 -0
  33. package/src/core/transcript.ts +92 -0
  34. package/src/core/truncate.ts +121 -0
  35. package/src/core/types.ts +1140 -0
  36. package/src/core/usage-log.ts +79 -0
  37. package/src/core/util.ts +386 -0
  38. package/src/deploy-listing.ts +160 -0
  39. package/src/deploy.ts +162 -0
  40. package/src/extensions/README.md +72 -0
  41. package/src/extensions/dry-mount.ts +390 -0
  42. package/src/extensions/manifest.ts +153 -0
  43. package/src/extensions/runtime.ts +42 -0
  44. package/src/extensions.ts +519 -0
  45. package/src/launcher.ts +285 -0
  46. package/src/paths.ts +155 -0
  47. package/src/protocol/open-responses/LICENSE +201 -0
  48. package/src/protocol/open-responses/README.md +14 -0
  49. package/src/protocol/open-responses/context-helpers.ts +27 -0
  50. package/src/protocol/open-responses/context-log.ts +59 -0
  51. package/src/protocol/open-responses/context.ts +89 -0
  52. package/src/protocol/open-responses/generated.ts +115 -0
  53. package/src/protocol/open-responses/index.ts +33 -0
  54. package/src/protocol/open-responses/openapi.json +4230 -0
  55. package/src/protocol/open-responses/stream.ts +146 -0
  56. package/src/protocol/open-responses/tokens.ts +31 -0
  57. package/src/providers/README.md +86 -0
  58. package/src/providers/base.ts +111 -0
  59. package/src/providers/configuration.ts +97 -0
  60. package/src/providers/console/config.ts +21 -0
  61. package/src/providers/console/settings.ts +443 -0
  62. package/src/providers/console/strings.ts +74 -0
  63. package/src/providers/console/types.ts +11 -0
  64. package/src/providers/llamacpp/archive.ts +148 -0
  65. package/src/providers/llamacpp/catalog.ts +247 -0
  66. package/src/providers/llamacpp/config.ts +36 -0
  67. package/src/providers/llamacpp/console/client.ts +6 -0
  68. package/src/providers/llamacpp/console/models-panel.ts +120 -0
  69. package/src/providers/llamacpp/console/runtime-panel.ts +173 -0
  70. package/src/providers/llamacpp/console/server.ts +153 -0
  71. package/src/providers/llamacpp/index.ts +95 -0
  72. package/src/providers/llamacpp/native.ts +51 -0
  73. package/src/providers/llamacpp/options.ts +117 -0
  74. package/src/providers/llamacpp/runtime-store.ts +139 -0
  75. package/src/providers/llamacpp/runtime.ts +214 -0
  76. package/src/providers/llamacpp/server.ts +286 -0
  77. package/src/providers/llamacpp/strings.ts +230 -0
  78. package/src/providers/openai-responses-compat/config.ts +20 -0
  79. package/src/providers/openai-responses-compat/index.ts +89 -0
  80. package/src/providers/openai-responses-compat/native.ts +127 -0
  81. package/src/providers/openai-responses-compat/strings.ts +19 -0
  82. package/src/providers/pricebook.ts +75 -0
  83. package/src/providers/registry.ts +137 -0
  84. package/src/providers/strings.ts +71 -0
  85. package/src/providers/transport/chat.ts +156 -0
  86. package/src/providers/transport/errors.ts +48 -0
  87. package/src/providers/transport/history.ts +68 -0
  88. package/src/providers/transport/native-input.ts +83 -0
  89. package/src/providers/transport/native-types.ts +13 -0
  90. package/src/providers/transport/response-assembly.ts +191 -0
  91. package/src/providers/transport/response-http.ts +196 -0
  92. package/src/providers/transport/response-meters.ts +27 -0
  93. package/src/providers/transport/responses-input.ts +56 -0
  94. package/src/web/README.md +84 -0
  95. package/src/web/client/console-pages/builtins/llm-settings/panel.ts +625 -0
  96. package/src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts +168 -0
  97. package/src/web/client/console-pages/builtins/llm-settings/strings.ts +213 -0
  98. package/src/web/client/console-pages/builtins.ts +10 -0
  99. package/src/web/client/console-pages/context.ts +135 -0
  100. package/src/web/client/console-pages/host.ts +633 -0
  101. package/src/web/client/console-pages/loader.ts +132 -0
  102. package/src/web/client/console-pages/strings.ts +90 -0
  103. package/src/web/client/console-pages/tools/strings.ts +49 -0
  104. package/src/web/client/console-pages/tools/view.ts +215 -0
  105. package/src/web/client/core/api.ts +200 -0
  106. package/src/web/client/core/language.ts +56 -0
  107. package/src/web/client/core/lifecycle.ts +132 -0
  108. package/src/web/client/core/router.ts +199 -0
  109. package/src/web/client/core/stream.ts +157 -0
  110. package/src/web/client/core/websocket.ts +82 -0
  111. package/src/web/client/features/appearance/index.ts +333 -0
  112. package/src/web/client/features/appearance/strings.ts +87 -0
  113. package/src/web/client/features/config/index.ts +11 -0
  114. package/src/web/client/features/config/strings.ts +41 -0
  115. package/src/web/client/features/config/view.ts +428 -0
  116. package/src/web/client/features/core/events.ts +197 -0
  117. package/src/web/client/features/core/index.ts +254 -0
  118. package/src/web/client/features/core/run.ts +56 -0
  119. package/src/web/client/features/core/runlog.ts +214 -0
  120. package/src/web/client/features/core/sessions.ts +53 -0
  121. package/src/web/client/features/core/strings.ts +157 -0
  122. package/src/web/client/features/extensions/index.ts +422 -0
  123. package/src/web/client/features/extensions/strings.ts +164 -0
  124. package/src/web/client/features/feature.ts +93 -0
  125. package/src/web/client/features/live/context.ts +280 -0
  126. package/src/web/client/features/live/fork.ts +156 -0
  127. package/src/web/client/features/live/index.ts +392 -0
  128. package/src/web/client/features/live/onboarding.ts +113 -0
  129. package/src/web/client/features/live/protocol.ts +122 -0
  130. package/src/web/client/features/live/sessions.ts +71 -0
  131. package/src/web/client/features/live/status.ts +68 -0
  132. package/src/web/client/features/live/strings.ts +252 -0
  133. package/src/web/client/features/live/timeline.ts +590 -0
  134. package/src/web/client/features/prompts/editor.ts +292 -0
  135. package/src/web/client/features/prompts/index.ts +346 -0
  136. package/src/web/client/features/prompts/strings.ts +136 -0
  137. package/src/web/client/features/prompts/view.ts +256 -0
  138. package/src/web/client/features/providers/index.ts +100 -0
  139. package/src/web/client/features/providers/strings.ts +23 -0
  140. package/src/web/client/features/settings/general.ts +28 -0
  141. package/src/web/client/features/settings/index.ts +82 -0
  142. package/src/web/client/features/settings/strings.ts +33 -0
  143. package/src/web/client/features/storage/index.ts +1 -0
  144. package/src/web/client/features/storage/strings.ts +45 -0
  145. package/src/web/client/features/storage/view.ts +154 -0
  146. package/src/web/client/features/usage/chart.ts +486 -0
  147. package/src/web/client/features/usage/index.ts +407 -0
  148. package/src/web/client/features/usage/labels.ts +46 -0
  149. package/src/web/client/features/usage/range.ts +36 -0
  150. package/src/web/client/features/usage/state.ts +37 -0
  151. package/src/web/client/features/usage/strings.ts +191 -0
  152. package/src/web/client/features/usage/tooltip.ts +86 -0
  153. package/src/web/client/features/usage/types.ts +96 -0
  154. package/src/web/client/features/worlds/index.ts +424 -0
  155. package/src/web/client/features/worlds/strings.ts +121 -0
  156. package/src/web/client/main.ts +273 -0
  157. package/src/web/client/shell/avatar.ts +233 -0
  158. package/src/web/client/shell/index.ts +549 -0
  159. package/src/web/client/shell/strings.ts +89 -0
  160. package/src/web/client/theme/handoff.ts +43 -0
  161. package/src/web/client/theme/palette.ts +109 -0
  162. package/src/web/client/theme/registry.ts +130 -0
  163. package/src/web/client/theme/storage.ts +72 -0
  164. package/src/web/client/theme/strings.ts +123 -0
  165. package/src/web/client/theme/studio.ts +366 -0
  166. package/src/web/client/ui/actions.ts +109 -0
  167. package/src/web/client/ui/data.ts +185 -0
  168. package/src/web/client/ui/dom.ts +23 -0
  169. package/src/web/client/ui/fields.ts +191 -0
  170. package/src/web/client/ui/format.ts +75 -0
  171. package/src/web/client/ui/icons.ts +183 -0
  172. package/src/web/client/ui/images.ts +91 -0
  173. package/src/web/client/ui/index.ts +89 -0
  174. package/src/web/client/ui/lamp.ts +125 -0
  175. package/src/web/client/ui/log.ts +98 -0
  176. package/src/web/client/ui/overlay.ts +184 -0
  177. package/src/web/client/ui/page.ts +8 -0
  178. package/src/web/client/ui/prompt-input.tsx +311 -0
  179. package/src/web/client/ui/sheet.ts +131 -0
  180. package/src/web/client/ui/strings.ts +71 -0
  181. package/src/web/console-pages.ts +367 -0
  182. package/src/web/files.ts +107 -0
  183. package/src/web/path-picker.ts +296 -0
  184. package/src/web/public/index.html +17 -0
  185. package/src/web/public/styles.css +1715 -0
  186. package/src/web/server.ts +1883 -0
  187. package/src/web/shared/client-panel.ts +603 -0
  188. package/src/web/shared/console-protocol.ts +560 -0
  189. package/src/web/shared/css.d.ts +1 -0
  190. package/src/web/shared/path-picker.ts +23 -0
  191. package/src/web/shared/theme.ts +256 -0
  192. package/src/web/theme-store.ts +37 -0
  193. package/src/world.ts +449 -0
  194. package/src/worlds/bilibili/ENV_PROMPT.md +5 -0
  195. package/src/worlds/bilibili/README.md +176 -0
  196. package/src/worlds/bilibili/audience-admission.ts +995 -0
  197. package/src/worlds/bilibili/client.ts +528 -0
  198. package/src/worlds/bilibili/coalescing-buffer.ts +125 -0
  199. package/src/worlds/bilibili/config.ts +185 -0
  200. package/src/worlds/bilibili/console/client.ts +202 -0
  201. package/src/worlds/bilibili/definition.ts +35 -0
  202. package/src/worlds/bilibili/gift-frame.ts +131 -0
  203. package/src/worlds/bilibili/normalize.ts +452 -0
  204. package/src/worlds/bilibili/overlay/announcement.ts +76 -0
  205. package/src/worlds/bilibili/overlay/assets.ts +82 -0
  206. package/src/worlds/bilibili/overlay/model.ts +494 -0
  207. package/src/worlds/bilibili/overlay/project.ts +202 -0
  208. package/src/worlds/bilibili/overlay/server.ts +286 -0
  209. package/src/worlds/bilibili/overlay/types.ts +217 -0
  210. package/src/worlds/bilibili/overlay/web/app.js +690 -0
  211. package/src/worlds/bilibili/overlay/web/editor.css +297 -0
  212. package/src/worlds/bilibili/overlay/web/editor.html +102 -0
  213. package/src/worlds/bilibili/overlay/web/editor.js +2116 -0
  214. package/src/worlds/bilibili/overlay/web/overlay.html +13 -0
  215. package/src/worlds/bilibili/overlay/web/styles.css +258 -0
  216. package/src/worlds/bilibili/protobuf.ts +123 -0
  217. package/src/worlds/bilibili/wire.ts +94 -0
  218. package/src/worlds/bilibili/world.ts +1389 -0
  219. package/src/worlds/console-fixture/ENV_PROMPT.md +1 -0
  220. package/src/worlds/console-fixture/console/client.ts +72 -0
  221. package/src/worlds/console-fixture/world.ts +73 -0
  222. package/src/worlds/index.ts +15 -0
  223. package/src/worlds/minecraft/ENV_PROMPT.md +32 -0
  224. package/src/worlds/minecraft/ENV_PROMPT_CAMERA.md +2 -0
  225. package/src/worlds/minecraft/LICENSE-mineflayer-pathfinder.txt +21 -0
  226. package/src/worlds/minecraft/README.md +917 -0
  227. package/src/worlds/minecraft/blueprint-plan.ts +915 -0
  228. package/src/worlds/minecraft/blueprint-registry.ts +566 -0
  229. package/src/worlds/minecraft/blueprint-repair.ts +236 -0
  230. package/src/worlds/minecraft/blueprint-resource.ts +272 -0
  231. package/src/worlds/minecraft/blueprint.ts +492 -0
  232. package/src/worlds/minecraft/body-lease.ts +340 -0
  233. package/src/worlds/minecraft/bridge.ts +749 -0
  234. package/src/worlds/minecraft/check.ts +651 -0
  235. package/src/worlds/minecraft/chests.ts +292 -0
  236. package/src/worlds/minecraft/client-launch.ts +272 -0
  237. package/src/worlds/minecraft/client-options.ts +85 -0
  238. package/src/worlds/minecraft/client-skins.ts +164 -0
  239. package/src/worlds/minecraft/client-window.ps1 +91 -0
  240. package/src/worlds/minecraft/client.ts +423 -0
  241. package/src/worlds/minecraft/combat-context.ts +109 -0
  242. package/src/worlds/minecraft/combat.ts +1552 -0
  243. package/src/worlds/minecraft/config.ts +499 -0
  244. package/src/worlds/minecraft/console/access.ts +218 -0
  245. package/src/worlds/minecraft/console/client.ts +244 -0
  246. package/src/worlds/minecraft/console/log.ts +110 -0
  247. package/src/worlds/minecraft/console/mount.ts +420 -0
  248. package/src/worlds/minecraft/console/skin.ts +257 -0
  249. package/src/worlds/minecraft/console/style.css +154 -0
  250. package/src/worlds/minecraft/console/world.ts +449 -0
  251. package/src/worlds/minecraft/deaths.ts +135 -0
  252. package/src/worlds/minecraft/definition.ts +18 -0
  253. package/src/worlds/minecraft/engine-child.ts +274 -0
  254. package/src/worlds/minecraft/engine-ipc.ts +98 -0
  255. package/src/worlds/minecraft/entity-facts.ts +132 -0
  256. package/src/worlds/minecraft/escape.ts +407 -0
  257. package/src/worlds/minecraft/executor.ts +14494 -0
  258. package/src/worlds/minecraft/explored.ts +156 -0
  259. package/src/worlds/minecraft/geometry.ts +205 -0
  260. package/src/worlds/minecraft/goal-plan.ts +647 -0
  261. package/src/worlds/minecraft/item-break.ts +126 -0
  262. package/src/worlds/minecraft/item-facts.ts +146 -0
  263. package/src/worlds/minecraft/item-pick.ts +67 -0
  264. package/src/worlds/minecraft/level-dat.ts +176 -0
  265. package/src/worlds/minecraft/log.ts +132 -0
  266. package/src/worlds/minecraft/mineflayer-fixes.ts +1180 -0
  267. package/src/worlds/minecraft/names.ts +377 -0
  268. package/src/worlds/minecraft/pathfinder-lib.d.ts +13 -0
  269. package/src/worlds/minecraft/pathfinder-perf.ts +796 -0
  270. package/src/worlds/minecraft/piglin.ts +49 -0
  271. package/src/worlds/minecraft/policy.ts +547 -0
  272. package/src/worlds/minecraft/precheck.ts +655 -0
  273. package/src/worlds/minecraft/proxy.ts +445 -0
  274. package/src/worlds/minecraft/ranged.ts +635 -0
  275. package/src/worlds/minecraft/readouts.ts +102 -0
  276. package/src/worlds/minecraft/round.ts +26 -0
  277. package/src/worlds/minecraft/search-observation.ts +92 -0
  278. package/src/worlds/minecraft/server-config.ts +467 -0
  279. package/src/worlds/minecraft/server.ts +658 -0
  280. package/src/worlds/minecraft/show.ts +84 -0
  281. package/src/worlds/minecraft/skills.ts +1763 -0
  282. package/src/worlds/minecraft/terrain.ts +1735 -0
  283. package/src/worlds/minecraft/window.ts +96 -0
  284. package/src/worlds/minecraft/works.ts +179 -0
  285. package/src/worlds/minecraft/world.ts +6438 -0
  286. package/src/worlds/qq/ENV_PROMPT.md +5 -0
  287. package/src/worlds/qq/config.ts +108 -0
  288. package/src/worlds/qq/console/client.ts +126 -0
  289. package/src/worlds/qq/console/events.ts +155 -0
  290. package/src/worlds/qq/console/gate.ts +232 -0
  291. package/src/worlds/qq/console/roster.ts +203 -0
  292. package/src/worlds/qq/console/style.css +65 -0
  293. package/src/worlds/qq/conversation.ts +28 -0
  294. package/src/worlds/qq/definition.ts +62 -0
  295. package/src/worlds/qq/driver.ts +375 -0
  296. package/src/worlds/qq/history-tools.ts +209 -0
  297. package/src/worlds/qq/normalize.ts +323 -0
  298. package/src/worlds/qq/qface-map.ts +282 -0
  299. package/src/worlds/qq/vision-prompt.ts +27 -0
  300. package/src/worlds/qq/vision.ts +642 -0
  301. package/src/worlds/qq/vlm.ts +142 -0
  302. package/src/worlds/qq/world.ts +1503 -0
  303. package/src/worlds/terminal/ENV_PROMPT.md +11 -0
  304. package/src/worlds/terminal/config.ts +9 -0
  305. package/src/worlds/terminal/definition.ts +14 -0
  306. package/src/worlds/terminal/world.ts +700 -0
  307. package/src/worlds/websearch/ENV_PROMPT.md +1 -0
  308. package/src/worlds/websearch/brave-client.ts +261 -0
  309. package/src/worlds/websearch/config.ts +75 -0
  310. package/src/worlds/websearch/definition.ts +20 -0
  311. package/src/worlds/websearch/world.ts +171 -0
@@ -0,0 +1,2116 @@
1
+ /**
2
+ * 单页 Overlay 编辑器。样式、组件与布局共享一份草稿;保存才写入运行态。
3
+ * 中央 iframe 复用 OBS 渲染器,选择框与手柄由父页的独立交互层承载。
4
+ */
5
+ (() => {
6
+ 'use strict';
7
+
8
+ const WALL_MODE_KEY = 'bilibili.overlay-editor.wall.v1';
9
+ /** 须与 src/web/client/theme/handoff.ts 的 THEME_HANDOFF_FRAGMENT_KEY 一致。 */
10
+ const THEME_FRAGMENT_KEY = 'cortico-theme';
11
+ const THEME_KEYS = [
12
+ 'paper', 'paper-2', 'sheet', 'sheet-2', 'sheet-3',
13
+ 'ink', 'ink-soft', 'ink-dim', 'line', 'line-2', 'line-strong',
14
+ 'accent', 'accent-2', 'on-accent', 'danger',
15
+ ];
16
+
17
+ applyInheritedTheme();
18
+
19
+ const refs = {
20
+ inspector: byId('inspector'),
21
+ title: byId('workspace-title'),
22
+ kicker: byId('workspace-kicker'),
23
+ create: byId('create'),
24
+ save: byId('save'),
25
+ adoptDesign: byId('adopt-design'),
26
+ saveState: byId('save-state'),
27
+ undo: byId('undo'),
28
+ redo: byId('redo'),
29
+ frame: byId('preview-frame'),
30
+ viewport: byId('canvas-viewport'),
31
+ sizer: byId('canvas-sizer'),
32
+ shell: byId('canvas-shell'),
33
+ layer: byId('interaction-layer'),
34
+ loading: byId('loading'),
35
+ canvasSize: byId('canvas-size'),
36
+ selectionStatus: byId('selection-status'),
37
+ zoomLabel: byId('zoom-label'),
38
+ snapGrid: byId('snap-grid'),
39
+ wallButtons: [...document.querySelectorAll('[data-wall-mode]')],
40
+ modal: byId('modal'),
41
+ modalContent: byId('modal-content'),
42
+ toasts: byId('toasts'),
43
+ };
44
+
45
+ const workspaceNames = {
46
+ styles: ['STYLE', '样式编辑器'],
47
+ components: ['COMPONENT', '组件编辑器'],
48
+ layout: ['LAYOUT', '布局编辑器'],
49
+ };
50
+ const componentKinds = {
51
+ danmaku: ['弹幕机', '弹'],
52
+ 'scroll-notice': ['滚动公告', '滚'],
53
+ 'fixed-notice': ['固定公告', '固'],
54
+ 'agent-notice': ['Agent 公告', 'A'],
55
+ image: ['图片', '图'],
56
+ };
57
+ const fontChoices = [
58
+ 'Microsoft YaHei, sans-serif',
59
+ 'PingFang SC, sans-serif',
60
+ 'SimHei, sans-serif',
61
+ 'SimSun, serif',
62
+ 'KaiTi, serif',
63
+ 'Noto Sans CJK SC, sans-serif',
64
+ 'Source Han Sans SC, sans-serif',
65
+ 'monospace',
66
+ ];
67
+ const audienceFields = [
68
+ ['uid', '用户 UID', 'text'], ['guardLevel', '大航海等级', 'number'], ['medalLevel', '粉丝牌等级', 'number'],
69
+ ['medalName', '粉丝牌名称', 'text'], ['medalAnchorName', '粉丝牌主播', 'text'], ['medalRoomId', '粉丝牌房间', 'number'],
70
+ ['medalColor', '粉丝牌颜色值', 'number'], ['isAdmin', '房管', 'boolean'], ['vip', 'VIP', 'boolean'],
71
+ ['svip', 'SVIP', 'boolean'], ['rank', '排名', 'number'], ['nameColor', '昵称颜色', 'text'],
72
+ ['userLevel', '用户等级', 'number'], ['eventKind', '事件类型', 'event'],
73
+ ];
74
+ let fieldSequence = 0;
75
+
76
+ const editor = {
77
+ state: null,
78
+ design: null,
79
+ saved: '',
80
+ mode: 'styles',
81
+ styleTab: 'appearance',
82
+ selectedStyle: '',
83
+ selectedGroup: '',
84
+ selectedComponent: '',
85
+ selectedPlacement: '',
86
+ zoom: 1,
87
+ fitScale: 1,
88
+ displayScale: 1,
89
+ frameReady: false,
90
+ history: [],
91
+ historyIndex: -1,
92
+ historyTimer: 0,
93
+ previewFrame: 0,
94
+ saving: false,
95
+ previewEvents: [],
96
+ announcementDraft: '',
97
+ announcementBaseRevision: 0,
98
+ announcementDirty: false,
99
+ announcementConflict: false,
100
+ announcementSaving: false,
101
+ eventSource: null,
102
+ designConflict: false,
103
+ wallMode: readWallMode(),
104
+ mockComponentId: '',
105
+ };
106
+
107
+ document.querySelectorAll('.mode-button[data-mode]').forEach((button) => {
108
+ button.addEventListener('click', () => switchMode(button.dataset.mode));
109
+ });
110
+ refs.create.addEventListener('click', createForMode);
111
+ refs.save.addEventListener('click', () => void saveDesign());
112
+ refs.adoptDesign.addEventListener('click', adoptRemoteDesign);
113
+ refs.undo.addEventListener('click', undo);
114
+ refs.redo.addEventListener('click', redo);
115
+ byId('zoom-in').addEventListener('click', () => setZoom(editor.zoom * 1.15));
116
+ byId('zoom-out').addEventListener('click', () => setZoom(editor.zoom / 1.15));
117
+ byId('zoom-reset').addEventListener('click', () => setZoom(1));
118
+ byId('demo-danmaku').addEventListener('click', () => previewAudience('danmaku'));
119
+ byId('demo-gift').addEventListener('click', () => previewAudience('gift'));
120
+ refs.wallButtons.forEach((button) => button.addEventListener('click', () => setWallMode(button.dataset.wallMode)));
121
+ byId('help').addEventListener('click', showHelp);
122
+ refs.frame.addEventListener('load', () => {
123
+ editor.frameReady = true;
124
+ sendPreview();
125
+ });
126
+ addEventListener('message', (event) => {
127
+ if (event.origin !== location.origin || event.source !== refs.frame.contentWindow) return;
128
+ if (event.data?.type !== 'overlay-editor-ready') return;
129
+ editor.frameReady = true;
130
+ sendPreview();
131
+ });
132
+ refs.frame.src = refs.frame.dataset.src;
133
+ refs.viewport.addEventListener('pointerdown', (event) => {
134
+ if (event.target === refs.viewport || event.target === refs.sizer || event.target === refs.layer) selectPlacement('');
135
+ });
136
+ new ResizeObserver(() => fitCanvas()).observe(refs.viewport);
137
+ addEventListener('beforeunload', (event) => {
138
+ if (!isDirty()) return;
139
+ event.preventDefault();
140
+ event.returnValue = '';
141
+ });
142
+ addEventListener('keydown', onKeyDown);
143
+
144
+ applyWallMode();
145
+
146
+ void loadEditor();
147
+
148
+ async function loadEditor() {
149
+ try {
150
+ const state = await api('/api/editor/state');
151
+ editor.state = state;
152
+ editor.design = structuredClone(state.design);
153
+ editor.saved = JSON.stringify(state.design);
154
+ editor.selectedStyle = state.design.styles[0]?.id || state.builtinStyles[0]?.id || '';
155
+ editor.selectedComponent = state.design.components[0]?.id || '';
156
+ editor.selectedPlacement = state.design.placements[0]?.id || '';
157
+ editor.selectedGroup = state.design.groups[0]?.id || '';
158
+ editor.announcementDraft = state.agentAnnouncement?.text || '';
159
+ editor.announcementBaseRevision = state.agentAnnouncement?.revision || 0;
160
+ editor.history = [{ json: JSON.stringify(editor.design), design: structuredClone(editor.design) }];
161
+ editor.historyIndex = 0;
162
+ refs.loading.classList.add('hidden');
163
+ renderAll();
164
+ startEditorEvents();
165
+ requestAnimationFrame(fitCanvas);
166
+ setSaveState('saved', '已保存');
167
+ } catch (error) {
168
+ refs.loading.replaceChildren(h('strong', null, '编辑器装入失败'), h('span', null, errorText(error)));
169
+ setSaveState('error', '连接失败');
170
+ }
171
+ }
172
+
173
+ async function api(path, method = 'GET', body) {
174
+ const response = await fetch(path, {
175
+ method,
176
+ ...(body === undefined ? {} : { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
177
+ });
178
+ let payload;
179
+ try { payload = await response.json(); } catch { payload = {}; }
180
+ if (!response.ok) {
181
+ const error = new Error(payload.error || `请求失败 (${response.status})`);
182
+ error.status = response.status;
183
+ throw error;
184
+ }
185
+ return payload;
186
+ }
187
+
188
+ function renderAll() {
189
+ if (!editor.design) return;
190
+ ensureSelections();
191
+ renderWorkspace();
192
+ renderInteraction();
193
+ updateCanvasMetrics();
194
+ updateHistoryButtons();
195
+ updateSaveState();
196
+ sendPreviewSoon();
197
+ }
198
+
199
+ function renderWorkspace() {
200
+ const [kicker, title] = workspaceNames[editor.mode];
201
+ refs.kicker.textContent = kicker;
202
+ refs.title.textContent = title;
203
+ document.querySelectorAll('.mode-button[data-mode]').forEach((button) => {
204
+ const active = button.dataset.mode === editor.mode;
205
+ button.classList.toggle('active', active);
206
+ if (active) button.setAttribute('aria-current', 'page');
207
+ else button.removeAttribute('aria-current');
208
+ });
209
+ refs.inspector.replaceChildren();
210
+ if (editor.mode === 'styles') renderStylesWorkspace();
211
+ else if (editor.mode === 'components') renderComponentsWorkspace();
212
+ else renderLayoutWorkspace();
213
+ }
214
+
215
+ function switchMode(mode) {
216
+ if (!workspaceNames[mode] || editor.mode === mode) return;
217
+ editor.mode = mode;
218
+ renderWorkspace();
219
+ renderInteraction();
220
+ }
221
+
222
+ function ensureSelections() {
223
+ const design = editor.design;
224
+ const allStyles = [...editor.state.builtinStyles, ...design.styles];
225
+ if (!allStyles.some((item) => item.id === editor.selectedStyle)) editor.selectedStyle = allStyles[0]?.id || '';
226
+ if (!design.components.some((item) => item.id === editor.selectedComponent)) editor.selectedComponent = design.components[0]?.id || '';
227
+ if (!design.components.some((item) => item.id === editor.mockComponentId)) editor.mockComponentId = '';
228
+ if (!design.placements.some((item) => item.id === editor.selectedPlacement)) editor.selectedPlacement = design.placements[0]?.id || '';
229
+ if (!design.groups.some((item) => item.id === editor.selectedGroup)) editor.selectedGroup = design.groups[0]?.id || '';
230
+ }
231
+
232
+ function selectComponentId(id) {
233
+ editor.selectedComponent = id;
234
+ if (editor.mockComponentId && editor.mockComponentId !== id) {
235
+ editor.mockComponentId = '';
236
+ sendPreviewSoon();
237
+ }
238
+ }
239
+
240
+ function mutate(change, options = {}) {
241
+ change();
242
+ markChanged(options);
243
+ }
244
+
245
+ function markChanged({ render = false, interaction = true, checkpoint = true } = {}) {
246
+ const current = editor.history[editor.historyIndex];
247
+ if (editor.historyIndex < editor.history.length - 1 && current?.json !== JSON.stringify(editor.design)) {
248
+ editor.history.splice(editor.historyIndex + 1);
249
+ updateHistoryButtons();
250
+ }
251
+ if (checkpoint) scheduleCheckpoint();
252
+ updateSaveState();
253
+ if (interaction) renderInteraction();
254
+ if (render) renderWorkspace();
255
+ sendPreviewSoon();
256
+ }
257
+
258
+ function scheduleCheckpoint() {
259
+ clearTimeout(editor.historyTimer);
260
+ editor.historyTimer = setTimeout(checkpoint, 350);
261
+ }
262
+
263
+ function checkpoint() {
264
+ clearTimeout(editor.historyTimer);
265
+ editor.historyTimer = 0;
266
+ const json = JSON.stringify(editor.design);
267
+ if (editor.history[editor.historyIndex]?.json === json) return;
268
+ editor.history.splice(editor.historyIndex + 1);
269
+ editor.history.push({ json, design: structuredClone(editor.design) });
270
+ if (editor.history.length > 80) editor.history.shift();
271
+ editor.historyIndex = editor.history.length - 1;
272
+ updateHistoryButtons();
273
+ }
274
+
275
+ function undo() {
276
+ checkpoint();
277
+ if (editor.historyIndex <= 0) return;
278
+ editor.historyIndex -= 1;
279
+ restoreHistory();
280
+ }
281
+
282
+ function redo() {
283
+ if (editor.history[editor.historyIndex]?.json !== JSON.stringify(editor.design)) {
284
+ checkpoint();
285
+ return;
286
+ }
287
+ if (editor.historyIndex >= editor.history.length - 1) return;
288
+ editor.historyIndex += 1;
289
+ restoreHistory();
290
+ }
291
+
292
+ function restoreHistory() {
293
+ editor.design = structuredClone(editor.history[editor.historyIndex].design);
294
+ renderAll();
295
+ }
296
+
297
+ function resetHistory() {
298
+ editor.history = [{ json: JSON.stringify(editor.design), design: structuredClone(editor.design) }];
299
+ editor.historyIndex = 0;
300
+ clearTimeout(editor.historyTimer);
301
+ editor.historyTimer = 0;
302
+ updateHistoryButtons();
303
+ }
304
+
305
+ function updateHistoryButtons() {
306
+ refs.undo.disabled = editor.historyIndex <= 0;
307
+ refs.redo.disabled = editor.historyIndex >= editor.history.length - 1;
308
+ }
309
+
310
+ function isDesignDirty() {
311
+ return Boolean(editor.design) && JSON.stringify(editor.design) !== editor.saved;
312
+ }
313
+
314
+ function isDirty() {
315
+ return isDesignDirty() || editor.announcementDirty;
316
+ }
317
+
318
+ function updateSaveState() {
319
+ if (editor.saving) return setSaveState('dirty', '正在保存');
320
+ if (editor.announcementSaving) return setSaveState('dirty', '正在写入公告');
321
+ if (editor.designConflict) return setSaveState('error', '远端设计已更新');
322
+ if (isDesignDirty()) return setSaveState('dirty', '有未保存修改');
323
+ if (editor.announcementDirty) return setSaveState('dirty', '公告草稿未写入');
324
+ setSaveState('saved', '已保存');
325
+ }
326
+
327
+ function setSaveState(kind, label) {
328
+ refs.saveState.className = `save-state ${kind}`;
329
+ refs.saveState.querySelector('span').textContent = label;
330
+ refs.saveState.title = label;
331
+ }
332
+
333
+ async function saveDesign() {
334
+ if (!editor.design || editor.saving) return;
335
+ checkpoint();
336
+ const submitted = structuredClone(editor.design);
337
+ const submittedJson = JSON.stringify(submitted);
338
+ const baseRevision = editor.state.designRevision;
339
+ editor.saving = true;
340
+ refs.save.disabled = true;
341
+ updateSaveState();
342
+ try {
343
+ const state = await api('/api/editor/design', 'PUT', { design: submitted, baseRevision });
344
+ const hasNewerDraft = JSON.stringify(editor.design) !== submittedJson;
345
+ editor.state = { ...editor.state, ...state };
346
+ if (state.agentAnnouncement) applyAnnouncementState(state.agentAnnouncement);
347
+ editor.saved = JSON.stringify(state.design);
348
+ if (!hasNewerDraft) editor.design = structuredClone(state.design);
349
+ editor.designConflict = false;
350
+ refs.save.textContent = '保存设计';
351
+ refs.adoptDesign.classList.add('hidden');
352
+ checkpoint();
353
+ toast(hasNewerDraft ? '提交时版本已保存;后续修改仍留在草稿中' : state.message || '设计已保存并热更新');
354
+ if (!hasNewerDraft) renderAll();
355
+ } catch (error) {
356
+ if (error.status === 409) {
357
+ try {
358
+ const latest = await api('/api/editor/state');
359
+ editor.state = { ...editor.state, ...latest };
360
+ if (latest.agentAnnouncement) applyAnnouncementState(latest.agentAnnouncement);
361
+ editor.saved = JSON.stringify(latest.design);
362
+ editor.designConflict = true;
363
+ refs.save.textContent = '覆盖远端设计';
364
+ refs.adoptDesign.classList.remove('hidden');
365
+ toast('服务器设计已更新;本地草稿已保留,再次保存将明确覆盖远端', true);
366
+ return;
367
+ } catch { /* 保留原始冲突错误。 */ }
368
+ }
369
+ setSaveState('error', '保存失败');
370
+ toast(errorText(error), true);
371
+ } finally {
372
+ editor.saving = false;
373
+ refs.save.disabled = false;
374
+ updateSaveState();
375
+ }
376
+ }
377
+
378
+ function adoptRemoteDesign() {
379
+ if (!editor.designConflict || !editor.state?.design) return;
380
+ editor.design = structuredClone(editor.state.design);
381
+ editor.saved = JSON.stringify(editor.design);
382
+ editor.designConflict = false;
383
+ refs.save.textContent = '保存设计';
384
+ refs.adoptDesign.classList.add('hidden');
385
+ resetHistory();
386
+ renderAll();
387
+ toast('已采用服务器上的最新设计');
388
+ }
389
+
390
+ function updateCanvasMetrics() {
391
+ const canvas = editor.design.canvas;
392
+ refs.canvasSize.textContent = `${canvas.width} × ${canvas.height}`;
393
+ refs.shell.style.width = `${canvas.width}px`;
394
+ refs.shell.style.height = `${canvas.height}px`;
395
+ fitCanvas();
396
+ }
397
+
398
+ function fitCanvas() {
399
+ if (!editor.design) return;
400
+ const canvas = editor.design.canvas;
401
+ const availableWidth = Math.max(200, refs.viewport.clientWidth - 112);
402
+ const availableHeight = Math.max(120, refs.viewport.clientHeight - 112);
403
+ editor.fitScale = Math.min(1, availableWidth / canvas.width, availableHeight / canvas.height);
404
+ editor.displayScale = editor.fitScale * editor.zoom;
405
+ refs.shell.style.transform = `scale(${editor.displayScale})`;
406
+ refs.sizer.style.width = `${canvas.width * editor.displayScale}px`;
407
+ refs.sizer.style.height = `${canvas.height * editor.displayScale}px`;
408
+ refs.zoomLabel.textContent = editor.zoom === 1 ? '适合' : `${Math.round(editor.zoom * 100)}%`;
409
+ }
410
+
411
+ function setZoom(value) {
412
+ editor.zoom = Math.max(.35, Math.min(3, value));
413
+ fitCanvas();
414
+ }
415
+
416
+ function setWallMode(value) {
417
+ if (value !== 'light' && value !== 'dark') return;
418
+ editor.wallMode = value;
419
+ try { localStorage.setItem(WALL_MODE_KEY, value); } catch { /* 本机偏好写不进去不影响编辑。 */ }
420
+ applyWallMode();
421
+ sendPreviewSoon();
422
+ }
423
+
424
+ function applyWallMode() {
425
+ refs.viewport.dataset.wallMode = editor.wallMode;
426
+ refs.wallButtons.forEach((button) => {
427
+ const active = button.dataset.wallMode === editor.wallMode;
428
+ button.classList.toggle('active', active);
429
+ button.setAttribute('aria-pressed', String(active));
430
+ });
431
+ }
432
+
433
+ function sendPreviewSoon() {
434
+ cancelAnimationFrame(editor.previewFrame);
435
+ editor.previewFrame = requestAnimationFrame(sendPreview);
436
+ }
437
+
438
+ function sendPreview() {
439
+ if (!editor.frameReady || !editor.design) return;
440
+ refs.frame.contentWindow.postMessage({
441
+ type: 'overlay-editor-preview',
442
+ design: editor.design,
443
+ builtinStyles: editor.state.builtinStyles,
444
+ events: editor.previewEvents,
445
+ wallMode: editor.wallMode,
446
+ mockComponentId: editor.mockComponentId,
447
+ }, location.origin);
448
+ }
449
+
450
+ function startEditorEvents() {
451
+ if (editor.eventSource) return;
452
+ const source = new EventSource('/stream');
453
+ editor.eventSource = source;
454
+ source.onmessage = (message) => {
455
+ let packet;
456
+ try { packet = JSON.parse(message.data); } catch { return; }
457
+ if ((packet.type === 'snapshot' || packet.type === 'state' || packet.type === 'announcement') && packet.agentAnnouncement) {
458
+ applyAnnouncementState(packet.agentAnnouncement);
459
+ }
460
+ };
461
+ }
462
+
463
+ function applyAnnouncementState(next) {
464
+ const revision = Number(next.revision) || 0;
465
+ editor.state.agentAnnouncement = next;
466
+ if (!editor.announcementDirty) {
467
+ editor.announcementDraft = next.text || '';
468
+ editor.announcementBaseRevision = revision;
469
+ editor.announcementConflict = false;
470
+ if (editor.mode === 'components' && componentById(editor.selectedComponent)?.kind === 'agent-notice') renderWorkspace();
471
+ return;
472
+ }
473
+ if (revision !== editor.announcementBaseRevision) {
474
+ editor.announcementConflict = true;
475
+ if (editor.mode === 'components' && componentById(editor.selectedComponent)?.kind === 'agent-notice') renderWorkspace();
476
+ }
477
+ updateSaveState();
478
+ }
479
+
480
+ function previewAudience(kind) {
481
+ if (!editor.frameReady) return;
482
+ const event = kind === 'gift'
483
+ ? { eventKind: 'gift', username: '示例舰长', body: '赠送 小电视 ×1', avatarUrl: '', facts: { eventKind: 'gift', guardLevel: 1 } }
484
+ : { eventKind: 'danmaku', username: '示例观众', body: '这是一条实时草稿弹幕', avatarUrl: '', facts: { eventKind: 'danmaku', userLevel: 12 } };
485
+ const group = [...editor.design.groups]
486
+ .filter((item) => item.enabled && testPreviewRule(item.rule, event.facts))
487
+ .sort((a, b) => b.priority - a.priority || a.id.localeCompare(b.id))[0];
488
+ if (group) event.groupId = group.id;
489
+ editor.previewEvents.push(event);
490
+ if (editor.previewEvents.length > 8) editor.previewEvents.shift();
491
+ refs.frame.contentWindow.postMessage({ type: 'overlay-editor-audience', event }, location.origin);
492
+ }
493
+
494
+ function testPreviewRule(rule, facts) {
495
+ if (rule.op === 'all') return rule.rules.every((child) => testPreviewRule(child, facts));
496
+ if (rule.op === 'any') return rule.rules.some((child) => testPreviewRule(child, facts));
497
+ const actual = facts[rule.field];
498
+ if (rule.compare === 'exists') return actual !== undefined && actual !== null && actual !== '';
499
+ if (actual === undefined || actual === null) return false;
500
+ if (rule.compare === 'contains') return String(actual).includes(String(rule.value ?? ''));
501
+ if (rule.compare === 'gte') return Number(actual) >= Number(rule.value);
502
+ if (rule.compare === 'lte') return Number(actual) <= Number(rule.value);
503
+ return actual === rule.value || String(actual) === String(rule.value);
504
+ }
505
+
506
+ function createForMode() {
507
+ if (!editor.design) return;
508
+ if (editor.mode === 'styles') createStyle();
509
+ else if (editor.mode === 'components') showCreateComponent();
510
+ else showAddPlacement();
511
+ }
512
+
513
+ function byId(id) { return document.getElementById(id); }
514
+ function h(tag, className, text) {
515
+ const element = document.createElement(tag);
516
+ if (className) element.className = className;
517
+ if (text !== undefined) element.textContent = text;
518
+ return element;
519
+ }
520
+ function button(label, onClick, className = 'small-button') {
521
+ const element = h('button', className, label);
522
+ element.type = 'button';
523
+ element.addEventListener('click', onClick);
524
+ return element;
525
+ }
526
+ function section(title, body, suffix) {
527
+ const wrap = h('section', 'section');
528
+ const head = h('header');
529
+ head.append(h('strong', null, title));
530
+ if (suffix) head.append(h('small', null, suffix));
531
+ const content = h('div', 'section-body');
532
+ if (body) content.append(body);
533
+ wrap.append(head, content);
534
+ return wrap;
535
+ }
536
+ function flushSection(title, body, suffix) {
537
+ const wrap = section(title, body, suffix);
538
+ wrap.classList.add('resource-section');
539
+ wrap.querySelector('.section-body').classList.add('flush');
540
+ return wrap;
541
+ }
542
+ function field(label, control, note) {
543
+ const wrap = h('div', 'field');
544
+ const labelable = control.matches?.('button, input, meter, output, progress, select, textarea');
545
+ const caption = h(labelable ? 'label' : 'span', 'field-caption', label);
546
+ if (labelable) {
547
+ if (!control.id) control.id = `overlay-field-${++fieldSequence}`;
548
+ caption.htmlFor = control.id;
549
+ }
550
+ wrap.append(caption, control);
551
+ if (!note) return wrap;
552
+ const outer = h('div');
553
+ outer.append(wrap, h('p', 'field-note', note));
554
+ return outer;
555
+ }
556
+ function textInput(value, setter, options = {}) {
557
+ const input = h(options.multiline ? 'textarea' : 'input');
558
+ input.value = value ?? '';
559
+ if (options.placeholder) input.placeholder = options.placeholder;
560
+ if (options.maxLength) input.maxLength = options.maxLength;
561
+ input.addEventListener('input', () => {
562
+ setter(input.value);
563
+ markChanged({ interaction: options.interaction !== false, checkpoint: false });
564
+ if (options.afterInput) options.afterInput(input.value);
565
+ });
566
+ input.addEventListener('change', checkpoint);
567
+ return input;
568
+ }
569
+ function numberInput(value, setter, min, max, step = 1, options = {}) {
570
+ const input = h('input');
571
+ input.type = 'number';
572
+ input.value = value ?? '';
573
+ input.min = String(min);
574
+ input.max = String(max);
575
+ input.step = String(step);
576
+ input.placeholder = options.placeholder || '';
577
+ input.addEventListener('input', () => {
578
+ if (input.value === '' && options.optional) setter(undefined);
579
+ else if (!Number.isFinite(input.valueAsNumber) || input.valueAsNumber < min || input.valueAsNumber > max) return;
580
+ else setter(input.valueAsNumber);
581
+ markChanged({ interaction: options.interaction !== false, checkpoint: false });
582
+ options.afterInput?.();
583
+ });
584
+ input.addEventListener('change', () => {
585
+ if (input.value !== '' || !options.optional) {
586
+ const value = Number.isFinite(input.valueAsNumber) ? Math.min(max, Math.max(min, input.valueAsNumber)) : min;
587
+ setter(value);
588
+ input.value = String(value);
589
+ markChanged({ interaction: options.interaction !== false, checkpoint: false });
590
+ options.afterInput?.();
591
+ }
592
+ checkpoint();
593
+ });
594
+ return input;
595
+ }
596
+ function selectInput(value, choices, setter, options = {}) {
597
+ const select = h('select');
598
+ for (const choice of choices) {
599
+ const option = h('option', null, choice[1]);
600
+ option.value = choice[0];
601
+ select.append(option);
602
+ }
603
+ select.value = value;
604
+ select.addEventListener('change', () => mutate(() => setter(select.value), { render: options.render !== false }));
605
+ return select;
606
+ }
607
+ function checkInput(label, checked, setter, options = {}) {
608
+ const wrap = h('label', 'check-field');
609
+ const input = h('input');
610
+ input.type = 'checkbox';
611
+ input.checked = checked;
612
+ input.addEventListener('change', () => mutate(() => setter(input.checked), { render: Boolean(options.render) }));
613
+ wrap.append(input, h('span', null, label));
614
+ return wrap;
615
+ }
616
+ function divider() { return h('div', 'divider'); }
617
+ function uniqueId(prefix) { return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; }
618
+ function errorText(error) { return error instanceof Error ? error.message : String(error); }
619
+ function readWallMode() {
620
+ try { return localStorage.getItem(WALL_MODE_KEY) === 'light' ? 'light' : 'dark'; }
621
+ catch { return 'dark'; }
622
+ }
623
+ function applyInheritedTheme() {
624
+ const root = document.documentElement;
625
+ const encoded = new URLSearchParams(location.hash.slice(1)).get(THEME_FRAGMENT_KEY);
626
+ if (encoded) {
627
+ try {
628
+ const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/');
629
+ const payload = JSON.parse(atob(base64 + '='.repeat((4 - base64.length % 4) % 4)));
630
+ if (payload?.v === 1 && (payload.appearance === 'light' || payload.appearance === 'dark') && payload.palette && typeof payload.palette === 'object') {
631
+ for (const key of THEME_KEYS) {
632
+ const value = payload.palette[key];
633
+ if (/^#[0-9a-f]{6}$/i.test(String(value || ''))) root.style.setProperty(`--${key}`, value);
634
+ }
635
+ root.dataset.colorMode = payload.appearance;
636
+ root.style.colorScheme = payload.appearance;
637
+ return;
638
+ }
639
+ } catch { /* 无效交接数据使用本页内置主题。 */ }
640
+ }
641
+ const system = matchMedia('(prefers-color-scheme: dark)');
642
+ const applySystem = () => {
643
+ root.dataset.colorMode = system.matches ? 'dark' : 'light';
644
+ root.style.colorScheme = system.matches ? 'dark' : 'light';
645
+ };
646
+ applySystem();
647
+ system.addEventListener?.('change', applySystem);
648
+ }
649
+ function toast(message, bad = false) {
650
+ const item = h('div', `toast${bad ? ' bad' : ''}`, message);
651
+ refs.toasts.append(item);
652
+ setTimeout(() => item.remove(), 3400);
653
+ }
654
+
655
+ // ── 样式 ────────────────────────────────────────────────────────────────
656
+
657
+ function allStyles() {
658
+ return [...editor.state.builtinStyles, ...editor.design.styles];
659
+ }
660
+
661
+ function styleById(id) {
662
+ return allStyles().find((item) => item.id === id) || editor.state.builtinStyles[0];
663
+ }
664
+
665
+ function renderStylesWorkspace() {
666
+ const tabs = h('div', 'subtabs');
667
+ const tabDefs = [['appearance', '外观'], ['text', '文字'], ['nine', '九宫格'], ['groups', '全局用户组']];
668
+ tabs.style.gridTemplateColumns = 'repeat(4,1fr)';
669
+ for (const [id, label] of tabDefs) {
670
+ const tab = button(label, () => {
671
+ editor.styleTab = id;
672
+ renderWorkspace();
673
+ }, editor.styleTab === id ? 'active' : '');
674
+ tabs.append(tab);
675
+ }
676
+ if (editor.styleTab === 'groups') {
677
+ refs.inspector.append(tabs);
678
+ renderGroupsEditor();
679
+ return;
680
+ }
681
+
682
+ const list = h('div', 'resource-list');
683
+ for (const style of allStyles()) {
684
+ const row = h('button', `resource-row${style.id === editor.selectedStyle ? ' active' : ''}`);
685
+ row.type = 'button';
686
+ const swatch = h('span', 'swatch');
687
+ const fill = h('i');
688
+ fill.style.background = style.background;
689
+ swatch.append(fill);
690
+ const copy = h('span', 'resource-copy');
691
+ copy.append(h('strong', null, style.name), h('span', null, style.id));
692
+ row.append(swatch, copy, h('span', 'row-badge', style.id.startsWith('builtin:') ? '内置' : '自定义'));
693
+ row.addEventListener('click', () => {
694
+ editor.selectedStyle = style.id;
695
+ renderWorkspace();
696
+ });
697
+ list.append(row);
698
+ }
699
+ refs.inspector.append(flushSection('样式库', list, `${allStyles().length} 项`));
700
+ refs.inspector.append(tabs);
701
+
702
+ const style = styleById(editor.selectedStyle);
703
+ if (!style) return;
704
+ const builtin = style.id.startsWith('builtin:');
705
+ if (builtin) {
706
+ const body = h('div');
707
+ body.append(
708
+ h('p', 'placeholder', '复制后可编辑。'),
709
+ button('复制为自定义样式', () => createStyle(style), 'small-button accent'),
710
+ );
711
+ refs.inspector.append(section('内置样式', body));
712
+ }
713
+ if (editor.styleTab === 'appearance') refs.inspector.append(renderAppearance(style, builtin));
714
+ if (editor.styleTab === 'text') refs.inspector.append(renderTextStyleEditor(style, builtin));
715
+ if (editor.styleTab === 'nine') refs.inspector.append(renderNineSliceEditor(style, builtin));
716
+
717
+ if (!builtin) {
718
+ const actions = h('div', 'button-row split');
719
+ actions.append(
720
+ button('复制', () => createStyle(style)),
721
+ button('删除样式', () => deleteStyle(style), 'small-button danger'),
722
+ );
723
+ refs.inspector.append(section('样式操作', actions));
724
+ }
725
+ }
726
+
727
+ function createStyle(source) {
728
+ const next = structuredClone(source || editor.state.builtinStyles[0]);
729
+ next.id = uniqueId('style');
730
+ next.name = source ? `${source.name} 副本` : '新样式';
731
+ next.radius = source && Number.isFinite(source.radius) ? source.radius : 0;
732
+ mutate(() => {
733
+ editor.design.styles.push(next);
734
+ editor.selectedStyle = next.id;
735
+ editor.styleTab = 'appearance';
736
+ }, { render: true });
737
+ }
738
+
739
+ function deleteStyle(style) {
740
+ const used = editor.design.components.find((component) => component.styleId === style.id);
741
+ if (used) return toast(`样式仍被组件「${used.name}」使用`, true);
742
+ confirmModal('删除样式', `将删除「${style.name}」。`, '删除', () => {
743
+ mutate(() => {
744
+ editor.design.styles = editor.design.styles.filter((item) => item.id !== style.id);
745
+ editor.selectedStyle = editor.state.builtinStyles[0]?.id || '';
746
+ }, { render: true });
747
+ });
748
+ }
749
+
750
+ function renderAppearance(style, readonly) {
751
+ const body = h('div');
752
+ const name = textInput(style.name, (value) => { style.name = value; }, { interaction: false });
753
+ name.disabled = readonly;
754
+ body.append(field('名称', name));
755
+ body.append(field('背景 RGBA', colorControl(style.background, (value) => { style.background = value; }, readonly)));
756
+ body.append(field('边框 RGBA', colorControl(style.borderColor, (value) => { style.borderColor = value; }, readonly)));
757
+ const borderWidth = numberInput(style.borderWidth, (value) => { style.borderWidth = value; }, 0, 64, .5);
758
+ const radius = numberInput(style.radius, (value) => { style.radius = value; }, 0, 200, 1);
759
+ const padding = numberInput(style.padding, (value) => { style.padding = value; }, 0, 200, 1);
760
+ borderWidth.disabled = radius.disabled = padding.disabled = readonly;
761
+ body.append(
762
+ field('边框宽度', borderWidth),
763
+ field('圆角半径', radius),
764
+ field('内边距', padding),
765
+ );
766
+ const preview = h('div', 'font-preview');
767
+ preview.style.background = style.background;
768
+ preview.style.border = `${style.borderWidth}px solid ${style.borderColor}`;
769
+ preview.style.borderRadius = `${style.radius}px`;
770
+ preview.style.padding = `${Math.min(20, style.padding)}px`;
771
+ const username = h('span', null, '示例观众');
772
+ const line = h('span', null, '这是一条弹幕正文');
773
+ applyTextPreview(username, style.username);
774
+ applyTextPreview(line, style.body);
775
+ preview.append(username, line);
776
+ body.append(divider(), preview);
777
+ return section('面板外观', body);
778
+ }
779
+
780
+ function renderTextStyleEditor(style, readonly) {
781
+ const body = h('div');
782
+ body.append(h('div', 'field-label', '用户名'));
783
+ body.append(textStyleFields(style.username, readonly));
784
+ body.append(divider(), h('div', 'field-label', '弹幕 / 公告正文'));
785
+ body.append(textStyleFields(style.body, readonly));
786
+ const preview = h('div', 'font-preview');
787
+ preview.style.background = style.background;
788
+ const username = h('span', null, '示例观众');
789
+ const line = h('span', null, '透明度、字体与描边实时预览');
790
+ applyTextPreview(username, style.username);
791
+ applyTextPreview(line, style.body);
792
+ preview.append(username, line);
793
+ body.append(divider(), preview);
794
+ return section('文字', body);
795
+ }
796
+
797
+ function textStyleFields(textStyle, readonly) {
798
+ const wrap = h('div');
799
+ const family = textInput(textStyle.fontFamily, (value) => { textStyle.fontFamily = value; });
800
+ family.setAttribute('list', 'overlay-fonts');
801
+ family.disabled = readonly;
802
+ if (!document.getElementById('overlay-fonts')) {
803
+ const datalist = h('datalist');
804
+ datalist.id = 'overlay-fonts';
805
+ for (const value of fontChoices) {
806
+ const option = h('option');
807
+ option.value = value;
808
+ datalist.append(option);
809
+ }
810
+ document.body.append(datalist);
811
+ }
812
+ const size = numberInput(textStyle.fontSize, (value) => { textStyle.fontSize = value; }, 8, 240, 1);
813
+ const weight = numberInput(textStyle.fontWeight, (value) => { textStyle.fontWeight = value; }, 100, 900, 100);
814
+ const stroke = numberInput(textStyle.strokeWidth, (value) => { textStyle.strokeWidth = value; }, 0, 12, .5);
815
+ size.disabled = weight.disabled = stroke.disabled = readonly;
816
+ wrap.append(
817
+ field('字体', family), field('字号', size), field('字重', weight),
818
+ field('文字 RGBA', colorControl(textStyle.color, (value) => { textStyle.color = value; }, readonly)),
819
+ field('描边 RGBA', colorControl(textStyle.strokeColor, (value) => { textStyle.strokeColor = value; }, readonly)),
820
+ field('描边宽度', stroke),
821
+ );
822
+ return wrap;
823
+ }
824
+
825
+ function applyTextPreview(element, textStyle) {
826
+ element.style.fontFamily = textStyle.fontFamily;
827
+ element.style.fontSize = `${Math.min(30, textStyle.fontSize)}px`;
828
+ element.style.fontWeight = String(textStyle.fontWeight);
829
+ element.style.color = textStyle.color;
830
+ element.style.webkitTextStroke = `${textStyle.strokeWidth}px ${textStyle.strokeColor}`;
831
+ element.style.paintOrder = 'stroke fill';
832
+ }
833
+
834
+ function colorControl(value, setter, disabled = false) {
835
+ let rgba = parseColor(value);
836
+ const wrap = h('div');
837
+ const row = h('div', 'color-control');
838
+ const chip = h('label', 'color-chip');
839
+ const picker = h('input');
840
+ picker.type = 'color';
841
+ const chipFill = h('i');
842
+ chip.append(picker, chipFill);
843
+ const values = h('div', 'color-values');
844
+ const hex = h('input');
845
+ hex.maxLength = 9;
846
+ hex.setAttribute('aria-label', '八位十六进制 RGBA');
847
+ const alphaWrap = h('div', 'alpha-wrap');
848
+ const alpha = h('input');
849
+ alpha.type = 'number';
850
+ alpha.min = '0'; alpha.max = '100'; alpha.step = '1';
851
+ alpha.setAttribute('aria-label', 'Alpha 百分比');
852
+ alphaWrap.append(alpha);
853
+ values.append(hex, alphaWrap);
854
+ row.append(chip, values);
855
+ const alphaSlider = h('input', 'alpha-slider');
856
+ alphaSlider.type = 'range';
857
+ alphaSlider.min = '0'; alphaSlider.max = '100'; alphaSlider.step = '1';
858
+ alphaSlider.setAttribute('aria-label', 'Alpha 滑条');
859
+ const channels = h('div', 'rgba-grid');
860
+ const channelInputs = ['R', 'G', 'B'].map((label) => {
861
+ const line = h('label');
862
+ const input = h('input');
863
+ input.type = 'number';
864
+ input.min = '0'; input.max = '255'; input.step = '1';
865
+ line.append(h('span', null, label), input);
866
+ channels.append(line);
867
+ return input;
868
+ });
869
+ wrap.append(row, alphaSlider, channels);
870
+
871
+ function sync(commitValue = false, preserveHex = false) {
872
+ const canonical = composeColor(rgba);
873
+ picker.value = canonical.slice(0, 7);
874
+ chipFill.style.background = canonical;
875
+ if (!preserveHex) hex.value = canonical.toUpperCase();
876
+ alpha.value = String(Math.round(rgba.a / 255 * 100));
877
+ alphaSlider.value = alpha.value;
878
+ channelInputs[0].value = String(rgba.r);
879
+ channelInputs[1].value = String(rgba.g);
880
+ channelInputs[2].value = String(rgba.b);
881
+ if (commitValue) {
882
+ setter(canonical);
883
+ markChanged({ checkpoint: false });
884
+ }
885
+ }
886
+ picker.addEventListener('input', () => {
887
+ const picked = parseColor(picker.value);
888
+ rgba = { ...picked, a: rgba.a };
889
+ sync(true);
890
+ });
891
+ hex.addEventListener('input', () => {
892
+ if (!/^#[0-9a-f]{6}(?:[0-9a-f]{2})?$/i.test(hex.value)) return;
893
+ rgba = parseColor(hex.value);
894
+ sync(true, true);
895
+ });
896
+ hex.addEventListener('blur', () => sync());
897
+ alpha.addEventListener('input', () => {
898
+ rgba.a = Math.round(clamp(Number(alpha.value), 0, 100) / 100 * 255);
899
+ sync(true);
900
+ });
901
+ alphaSlider.addEventListener('input', () => {
902
+ rgba.a = Math.round(Number(alphaSlider.value) / 100 * 255);
903
+ sync(true);
904
+ });
905
+ channelInputs.forEach((input, index) => input.addEventListener('input', () => {
906
+ rgba[['r', 'g', 'b'][index]] = Math.round(clamp(Number(input.value), 0, 255));
907
+ sync(true);
908
+ }));
909
+ [picker, hex, alpha, alphaSlider, ...channelInputs].forEach((input) => {
910
+ input.disabled = disabled;
911
+ input.addEventListener('change', checkpoint);
912
+ });
913
+ sync();
914
+ return wrap;
915
+ }
916
+
917
+ function parseColor(value) {
918
+ const text = /^#[0-9a-f]{6}(?:[0-9a-f]{2})?$/i.test(value || '') ? value.slice(1) : '000000ff';
919
+ return {
920
+ r: Number.parseInt(text.slice(0, 2), 16),
921
+ g: Number.parseInt(text.slice(2, 4), 16),
922
+ b: Number.parseInt(text.slice(4, 6), 16),
923
+ a: text.length === 8 ? Number.parseInt(text.slice(6, 8), 16) : 255,
924
+ };
925
+ }
926
+
927
+ function composeColor({ r, g, b, a }) {
928
+ return `#${[r, g, b, a].map((part) => Math.round(clamp(part, 0, 255)).toString(16).padStart(2, '0')).join('')}`;
929
+ }
930
+
931
+ function clamp(value, min, max) {
932
+ return Math.min(max, Math.max(min, Number.isFinite(value) ? value : min));
933
+ }
934
+
935
+ // ── 九宫格与素材 ────────────────────────────────────────────────────────
936
+
937
+ function renderNineSliceEditor(style, readonly) {
938
+ const body = h('div');
939
+ if (readonly) {
940
+ body.append(h('p', 'placeholder', '复制内置样式后即可绑定素材并手动划分九宫格。'));
941
+ return section('Nine-slice', body);
942
+ }
943
+ const assets = editor.state.assets || [];
944
+ const gallery = h('div', 'asset-grid');
945
+ for (const asset of assets) {
946
+ const cell = h('div', 'asset-cell');
947
+ const card = h('button', `asset-card${style.nineSlice?.assetId === asset.id ? ' active' : ''}`);
948
+ card.type = 'button';
949
+ const image = h('img');
950
+ image.src = asset.url;
951
+ image.alt = '';
952
+ card.append(image, h('span', null, asset.id.slice(0, 10)));
953
+ card.addEventListener('click', () => {
954
+ if (style.nineSlice?.assetId === asset.id) return;
955
+ mutate(() => {
956
+ style.nineSlice = {
957
+ assetId: asset.id,
958
+ slice: { top: 20, right: 20, bottom: 20, left: 20 },
959
+ width: { top: 20, right: 20, bottom: 20, left: 20 },
960
+ fill: true,
961
+ repeat: 'stretch',
962
+ };
963
+ }, { render: true });
964
+ });
965
+ const remove = button('×', () => void deleteAsset(asset.id), 'asset-remove');
966
+ remove.title = '删除素材';
967
+ cell.append(card, remove);
968
+ gallery.append(cell);
969
+ }
970
+ body.append(gallery);
971
+ const upload = h('label', 'small-button accent file-button', '上传素材');
972
+ const file = h('input');
973
+ file.type = 'file';
974
+ file.accept = 'image/png,image/jpeg,image/webp,image/gif';
975
+ file.addEventListener('change', () => void uploadAsset(file.files?.[0], style));
976
+ upload.append(file);
977
+ const assetActions = h('div', 'button-row');
978
+ assetActions.append(upload);
979
+ if (style.nineSlice) {
980
+ assetActions.append(button('停用九宫格', () => mutate(() => { delete style.nineSlice; }, { render: true })));
981
+ }
982
+ body.append(assetActions);
983
+ if (!style.nineSlice) {
984
+ body.append(h('p', 'placeholder', assets.length ? '选择一张素材开始划分。' : '上传 PNG、JPEG、WebP 或 GIF 素材。'));
985
+ return section('Nine-slice', body);
986
+ }
987
+ const asset = assets.find((item) => item.id === style.nineSlice.assetId);
988
+ if (!asset) {
989
+ body.append(h('p', 'placeholder', '当前素材不存在,请重新选择。'));
990
+ return section('Nine-slice', body);
991
+ }
992
+ body.append(divider(), renderSliceSource(style.nineSlice, asset));
993
+ body.append(renderSliceOptions(style.nineSlice, asset));
994
+ return section('Nine-slice', body);
995
+ }
996
+
997
+ function renderSliceSource(nine, asset) {
998
+ const wrap = h('div');
999
+ const source = h('div', 'slice-source');
1000
+ const imageWrap = h('div', 'slice-image-wrap');
1001
+ const image = h('img');
1002
+ image.src = asset.url;
1003
+ image.alt = 'Nine-slice 源图';
1004
+ imageWrap.append(image);
1005
+ source.append(imageWrap);
1006
+ wrap.append(source);
1007
+ image.addEventListener('load', () => {
1008
+ const before = JSON.stringify(nine.slice);
1009
+ constrainSlices(nine.slice, image.naturalWidth, image.naturalHeight);
1010
+ if (JSON.stringify(nine.slice) !== before) markChanged({ interaction: false });
1011
+ drawSliceGuides(imageWrap, image, nine);
1012
+ const values = renderInsets(nine.slice, '源图切线', 0, 65535, (side) => {
1013
+ constrainSlices(nine.slice, image.naturalWidth, image.naturalHeight, side);
1014
+ drawSliceGuides(imageWrap, image, nine);
1015
+ redrawNineTarget(nine);
1016
+ });
1017
+ wrap.append(values);
1018
+ }, { once: true });
1019
+ return wrap;
1020
+ }
1021
+
1022
+ function drawSliceGuides(imageWrap, image, nine) {
1023
+ imageWrap.querySelectorAll('.slice-guide').forEach((item) => item.remove());
1024
+ const w = image.clientWidth;
1025
+ const hgt = image.clientHeight;
1026
+ const naturalW = image.naturalWidth;
1027
+ const naturalH = image.naturalHeight;
1028
+ const defs = [
1029
+ ['left', 'vertical', nine.slice.left / naturalW * w],
1030
+ ['right', 'vertical', (naturalW - nine.slice.right) / naturalW * w],
1031
+ ['top', 'horizontal', nine.slice.top / naturalH * hgt],
1032
+ ['bottom', 'horizontal', (naturalH - nine.slice.bottom) / naturalH * hgt],
1033
+ ];
1034
+ for (const [side, axis, position] of defs) {
1035
+ const guide = h('span', `slice-guide ${axis}`);
1036
+ guide.dataset.side = side;
1037
+ guide.style[axis === 'vertical' ? 'left' : 'top'] = `${position}px`;
1038
+ guide.addEventListener('pointerdown', (event) => startSliceDrag(event, guide, imageWrap, image, nine));
1039
+ imageWrap.append(guide);
1040
+ }
1041
+ }
1042
+
1043
+ function positionSliceGuides(imageWrap, image, nine) {
1044
+ const w = image.clientWidth;
1045
+ const hgt = image.clientHeight;
1046
+ const positions = {
1047
+ left: nine.slice.left / image.naturalWidth * w,
1048
+ right: (image.naturalWidth - nine.slice.right) / image.naturalWidth * w,
1049
+ top: nine.slice.top / image.naturalHeight * hgt,
1050
+ bottom: (image.naturalHeight - nine.slice.bottom) / image.naturalHeight * hgt,
1051
+ };
1052
+ imageWrap.querySelectorAll('.slice-guide').forEach((guide) => {
1053
+ const side = guide.dataset.side;
1054
+ guide.style[side === 'left' || side === 'right' ? 'left' : 'top'] = `${positions[side]}px`;
1055
+ });
1056
+ }
1057
+
1058
+ function startSliceDrag(event, guide, imageWrap, image, nine) {
1059
+ event.preventDefault();
1060
+ guide.setPointerCapture(event.pointerId);
1061
+ const side = guide.dataset.side;
1062
+ const vertical = side === 'left' || side === 'right';
1063
+ const original = { ...nine.slice };
1064
+ let finished = false;
1065
+ const move = (pointer) => {
1066
+ const rect = imageWrap.getBoundingClientRect();
1067
+ const ratio = vertical
1068
+ ? clamp(pointer.clientX - rect.left, 0, rect.width) / rect.width
1069
+ : clamp(pointer.clientY - rect.top, 0, rect.height) / rect.height;
1070
+ if (side === 'left') nine.slice.left = Math.round(ratio * image.naturalWidth);
1071
+ if (side === 'right') nine.slice.right = Math.round((1 - ratio) * image.naturalWidth);
1072
+ if (side === 'top') nine.slice.top = Math.round(ratio * image.naturalHeight);
1073
+ if (side === 'bottom') nine.slice.bottom = Math.round((1 - ratio) * image.naturalHeight);
1074
+ constrainSlices(nine.slice, image.naturalWidth, image.naturalHeight, side);
1075
+ markChanged({ interaction: false, checkpoint: false });
1076
+ positionSliceGuides(imageWrap, image, nine);
1077
+ redrawNineTarget(nine);
1078
+ };
1079
+ const finish = (cancel = false) => {
1080
+ if (finished) return;
1081
+ finished = true;
1082
+ guide.removeEventListener('pointermove', move);
1083
+ guide.removeEventListener('pointerup', commit);
1084
+ guide.removeEventListener('pointercancel', rollback);
1085
+ removeEventListener('keydown', cancelWithEscape);
1086
+ if (guide.hasPointerCapture(event.pointerId)) guide.releasePointerCapture(event.pointerId);
1087
+ if (cancel) {
1088
+ Object.assign(nine.slice, original);
1089
+ markChanged({ interaction: false, checkpoint: false });
1090
+ } else {
1091
+ checkpoint();
1092
+ }
1093
+ renderWorkspace();
1094
+ };
1095
+ const commit = () => finish(false);
1096
+ const rollback = () => finish(true);
1097
+ const cancelWithEscape = (keyEvent) => {
1098
+ if (keyEvent.key !== 'Escape') return;
1099
+ keyEvent.preventDefault();
1100
+ finish(true);
1101
+ };
1102
+ guide.addEventListener('pointermove', move);
1103
+ guide.addEventListener('pointerup', commit, { once: true });
1104
+ guide.addEventListener('pointercancel', rollback, { once: true });
1105
+ addEventListener('keydown', cancelWithEscape);
1106
+ }
1107
+
1108
+ function constrainSlices(slice, width, height, activeSide = '') {
1109
+ const horizontal = constrainSlicePair(slice.left, slice.right, Math.max(0, width - 1), activeSide, 'left', 'right');
1110
+ const vertical = constrainSlicePair(slice.top, slice.bottom, Math.max(0, height - 1), activeSide, 'top', 'bottom');
1111
+ slice.left = horizontal[0];
1112
+ slice.right = horizontal[1];
1113
+ slice.top = vertical[0];
1114
+ slice.bottom = vertical[1];
1115
+ }
1116
+
1117
+ function constrainSlicePair(first, second, limit, activeSide, firstSide, secondSide) {
1118
+ let a = Math.round(clamp(first, 0, limit));
1119
+ let b = Math.round(clamp(second, 0, limit));
1120
+ if (a + b <= limit) return [a, b];
1121
+ if (activeSide === firstSide) return [limit - b, b];
1122
+ if (activeSide === secondSide) return [a, limit - a];
1123
+ const total = a + b;
1124
+ a = total > 0 ? Math.round(a / total * limit) : 0;
1125
+ b = limit - a;
1126
+ return [a, b];
1127
+ }
1128
+
1129
+ function renderSliceOptions(nine, asset) {
1130
+ const wrap = h('div');
1131
+ const preview = h('div', 'nine-preview');
1132
+ const redraw = () => applyNinePreview(preview, nine, asset.url);
1133
+ wrap.append(renderInsets(nine.width, '输出边框宽度', 0, 512, redraw));
1134
+ wrap.append(field('拉伸方式', selectInput(nine.repeat, [['stretch', '拉伸'], ['repeat', '平铺'], ['round', '整片平铺']], (value) => { nine.repeat = value; })));
1135
+ wrap.append(checkInput('绘制中心区域', nine.fill, (value) => { nine.fill = value; }, { render: true }));
1136
+ preview.append(h('span', null, '拖动右下角验证不同尺寸'));
1137
+ applyNinePreview(preview, nine, asset.url);
1138
+ wrap.append(preview);
1139
+ return wrap;
1140
+ }
1141
+
1142
+ function renderInsets(insets, label, min, max, afterInput) {
1143
+ const outer = h('div');
1144
+ outer.append(h('div', 'field-label', label));
1145
+ const grid = h('div', 'slice-values');
1146
+ for (const [key, text] of [['top', '上 T'], ['right', '右 R'], ['bottom', '下 B'], ['left', '左 L']]) {
1147
+ const line = h('label');
1148
+ let input;
1149
+ input = numberInput(insets[key], (value) => { insets[key] = value; }, min, max, 1, {
1150
+ interaction: false,
1151
+ afterInput: () => {
1152
+ afterInput?.(key);
1153
+ input.value = String(insets[key]);
1154
+ },
1155
+ });
1156
+ line.append(h('span', null, text), input);
1157
+ grid.append(line);
1158
+ }
1159
+ outer.append(grid);
1160
+ return outer;
1161
+ }
1162
+
1163
+ function applyNinePreview(element, nine, assetUrl) {
1164
+ const s = nine.slice;
1165
+ const w = nine.width;
1166
+ element.style.borderWidth = `${w.top}px ${w.right}px ${w.bottom}px ${w.left}px`;
1167
+ element.style.borderImageSource = `url('${assetUrl}')`;
1168
+ element.style.borderImageSlice = `${s.top} ${s.right} ${s.bottom} ${s.left}${nine.fill ? ' fill' : ''}`;
1169
+ element.style.borderImageWidth = `${w.top}px ${w.right}px ${w.bottom}px ${w.left}px`;
1170
+ element.style.borderImageRepeat = nine.repeat;
1171
+ }
1172
+
1173
+ function redrawNineTarget(nine) {
1174
+ const preview = refs.inspector.querySelector('.nine-preview');
1175
+ const asset = editor.state.assets.find((item) => item.id === nine.assetId);
1176
+ if (preview && asset) applyNinePreview(preview, nine, asset.url);
1177
+ }
1178
+
1179
+ async function uploadAsset(file, style) {
1180
+ if (!file) return;
1181
+ if (file.size > 8 * 1024 * 1024) return toast('素材不能超过 8 MiB', true);
1182
+ try {
1183
+ const bytes = new Uint8Array(await file.arrayBuffer());
1184
+ let binary = '';
1185
+ for (let offset = 0; offset < bytes.length; offset += 0x8000) {
1186
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
1187
+ }
1188
+ const out = await api('/api/editor/assets', 'POST', { base64: btoa(binary) });
1189
+ editor.state.assets = out.assets;
1190
+ if (style && out.asset) {
1191
+ style.nineSlice = {
1192
+ assetId: out.asset.id,
1193
+ slice: { top: 20, right: 20, bottom: 20, left: 20 },
1194
+ width: { top: 20, right: 20, bottom: 20, left: 20 },
1195
+ fill: true,
1196
+ repeat: 'stretch',
1197
+ };
1198
+ markChanged();
1199
+ }
1200
+ toast('素材已上传');
1201
+ renderWorkspace();
1202
+ } catch (error) {
1203
+ toast(errorText(error), true);
1204
+ }
1205
+ }
1206
+
1207
+ async function deleteAsset(id) {
1208
+ const localStyle = editor.design.styles.find((item) => item.nineSlice?.assetId === id);
1209
+ const localImage = editor.design.components.find((item) => item.kind === 'image' && item.source === 'upload' && item.assetId === id);
1210
+ if (localStyle || localImage) {
1211
+ const owner = localStyle ? `样式「${localStyle.name}」` : `组件「${localImage.name}」`;
1212
+ return toast(`素材仍被${owner}使用;先解除引用并保存`, true);
1213
+ }
1214
+ confirmModal('删除素材', '素材文件将从本机素材库中删除,并清空当前撤销记录。', '删除', async () => {
1215
+ try {
1216
+ const out = await api(`/api/editor/assets/${encodeURIComponent(id)}`, 'DELETE');
1217
+ editor.state.assets = out.assets;
1218
+ if (out.deleted) resetHistory();
1219
+ renderWorkspace();
1220
+ toast(out.deleted ? '素材已删除' : '素材已经不存在');
1221
+ } catch (error) { toast(errorText(error), true); }
1222
+ });
1223
+ }
1224
+
1225
+ // ── 用户组 ──────────────────────────────────────────────────────────────
1226
+
1227
+ function renderGroupsEditor() {
1228
+ const list = h('div', 'resource-list');
1229
+ for (const group of editor.design.groups) {
1230
+ const row = h('button', `resource-row${group.id === editor.selectedGroup ? ' active' : ''}`);
1231
+ row.type = 'button';
1232
+ const icon = h('span', 'resource-icon', '组');
1233
+ const copy = h('span', 'resource-copy');
1234
+ copy.append(h('strong', null, group.name), h('span', null, `优先级 ${group.priority}`));
1235
+ row.append(icon, copy, h('span', `row-badge${group.enabled ? '' : ' hidden-badge'}`, group.enabled ? '启用' : '停用'));
1236
+ row.addEventListener('click', () => { editor.selectedGroup = group.id; renderWorkspace(); });
1237
+ list.append(row);
1238
+ }
1239
+ if (!editor.design.groups.length) list.append(h('div', 'placeholder', '还没有用户组。'));
1240
+ const add = button('新建用户组', createGroup, 'small-button accent');
1241
+ const note = h('p', 'field-note', '全局生效;命中后覆写所有弹幕机中的用户名与正文样式。');
1242
+ note.style.margin = '0 0 10px';
1243
+ refs.inspector.append(
1244
+ note,
1245
+ flushSection('全局用户组策略', list, `${editor.design.groups.length} 组`),
1246
+ section('组操作', add),
1247
+ );
1248
+ const group = editor.design.groups.find((item) => item.id === editor.selectedGroup);
1249
+ if (!group) return;
1250
+ const basics = h('div');
1251
+ basics.append(
1252
+ field('名称', textInput(group.name, (value) => { group.name = value; }, { interaction: false })),
1253
+ field('优先级', numberInput(group.priority, (value) => { group.priority = value; }, -10000, 10000, 1, { interaction: false })),
1254
+ checkInput('启用这个用户组', group.enabled, (value) => { group.enabled = value; }, { render: true }),
1255
+ );
1256
+ refs.inspector.append(section('组定义', basics));
1257
+ const ruleBody = h('div');
1258
+ ruleBody.append(renderRule(group.rule, (next) => { group.rule = next; }));
1259
+ refs.inspector.append(section('匹配条件', ruleBody));
1260
+ const overrides = h('div');
1261
+ overrides.append(h('div', 'field-label', '用户名覆写'), textPatchFields(group.username));
1262
+ overrides.append(divider(), h('div', 'field-label', '正文覆写'), textPatchFields(group.body));
1263
+ refs.inspector.append(section('分组字体覆写', overrides));
1264
+ refs.inspector.append(section('组操作', button('删除用户组', () => {
1265
+ mutate(() => {
1266
+ editor.design.groups = editor.design.groups.filter((item) => item.id !== group.id);
1267
+ editor.selectedGroup = editor.design.groups[0]?.id || '';
1268
+ }, { render: true });
1269
+ }, 'small-button danger')));
1270
+ }
1271
+
1272
+ function createGroup() {
1273
+ const group = {
1274
+ id: uniqueId('group'), name: '新用户组', enabled: true, priority: 10,
1275
+ rule: { op: 'all', rules: [{ op: 'leaf', field: 'guardLevel', compare: 'gte', value: 1 }] },
1276
+ username: {}, body: {},
1277
+ };
1278
+ mutate(() => {
1279
+ editor.design.groups.push(group);
1280
+ editor.selectedGroup = group.id;
1281
+ editor.styleTab = 'groups';
1282
+ }, { render: true });
1283
+ }
1284
+
1285
+ function renderRule(rule, replace, canDelete = false) {
1286
+ const card = h('div', 'rule-card');
1287
+ const head = h('div', 'button-row');
1288
+ const op = selectInput(rule.op, [['all', '全部满足'], ['any', '任一满足'], ['leaf', '单个条件']], (value) => {
1289
+ replace(value === 'leaf'
1290
+ ? { op: 'leaf', field: 'guardLevel', compare: 'gte', value: 1 }
1291
+ : { op: value, rules: [{ op: 'leaf', field: 'guardLevel', compare: 'gte', value: 1 }] });
1292
+ });
1293
+ head.append(op);
1294
+ if (canDelete) head.append(button('移除', () => replace(null), 'small-button danger'));
1295
+ card.append(head);
1296
+ if (rule.op === 'leaf') {
1297
+ card.append(renderLeafRule(rule));
1298
+ return card;
1299
+ }
1300
+ const children = h('div', 'rule-children');
1301
+ rule.rules.forEach((child, index) => {
1302
+ children.append(renderRule(child, (next) => {
1303
+ if (next) rule.rules[index] = next;
1304
+ else rule.rules.splice(index, 1);
1305
+ if (!rule.rules.length) rule.rules.push({ op: 'leaf', field: 'guardLevel', compare: 'gte', value: 1 });
1306
+ markChanged({ render: true });
1307
+ }, true));
1308
+ });
1309
+ const actions = h('div', 'button-row');
1310
+ actions.append(
1311
+ button('添加条件', () => mutate(() => rule.rules.push({ op: 'leaf', field: 'guardLevel', compare: 'gte', value: 1 }), { render: true })),
1312
+ button('添加子组', () => mutate(() => rule.rules.push({ op: 'all', rules: [{ op: 'leaf', field: 'guardLevel', compare: 'gte', value: 1 }] }), { render: true })),
1313
+ );
1314
+ card.append(children, actions);
1315
+ return card;
1316
+ }
1317
+
1318
+ function renderLeafRule(rule) {
1319
+ const line = h('div', 'rule-line');
1320
+ const fieldSelect = selectInput(rule.field, audienceFields.map(([id, label]) => [id, label]), (value) => {
1321
+ rule.field = value;
1322
+ rule.compare = value === 'uid' || value.includes('Name') ? 'eq' : 'eq';
1323
+ rule.value = defaultRuleValue(value);
1324
+ });
1325
+ const compares = compareChoices(rule.field);
1326
+ const compare = selectInput(rule.compare, compares, (value) => {
1327
+ rule.compare = value;
1328
+ if (value === 'exists') delete rule.value;
1329
+ else if (rule.value === undefined) rule.value = defaultRuleValue(rule.field);
1330
+ });
1331
+ let valueControl;
1332
+ const kind = audienceFields.find(([id]) => id === rule.field)?.[2] || 'text';
1333
+ if (rule.compare === 'exists') valueControl = h('span', 'row-badge', '只检查存在');
1334
+ else if (kind === 'boolean') valueControl = selectInput(String(rule.value), [['true', '是'], ['false', '否']], (value) => { rule.value = value === 'true'; }, { render: false });
1335
+ else if (kind === 'event') valueControl = selectInput(String(rule.value), [['danmaku', '弹幕'], ['gift', '礼物']], (value) => { rule.value = value; }, { render: false });
1336
+ else if (kind === 'number') valueControl = numberInput(Number(rule.value), (value) => { rule.value = value; }, -1000000000, 1000000000, 1, { interaction: false });
1337
+ else valueControl = textInput(String(rule.value ?? ''), (value) => { rule.value = value; }, { interaction: false });
1338
+ line.append(fieldSelect, compare, valueControl);
1339
+ return line;
1340
+ }
1341
+
1342
+ function compareChoices(fieldName) {
1343
+ const kind = audienceFields.find(([id]) => id === fieldName)?.[2];
1344
+ if (kind === 'number') return [['eq', '等于'], ['gte', '大于等于'], ['lte', '小于等于'], ['exists', '存在']];
1345
+ if (kind === 'text') return [['eq', '等于'], ['contains', '包含'], ['exists', '存在']];
1346
+ return [['eq', '等于'], ['exists', '存在']];
1347
+ }
1348
+
1349
+ function defaultRuleValue(fieldName) {
1350
+ const kind = audienceFields.find(([id]) => id === fieldName)?.[2];
1351
+ if (kind === 'number') return 1;
1352
+ if (kind === 'boolean') return true;
1353
+ if (kind === 'event') return 'danmaku';
1354
+ return '';
1355
+ }
1356
+
1357
+ function textPatchFields(patch) {
1358
+ const wrap = h('div');
1359
+ wrap.append(
1360
+ field('字体', optionalTextInput(patch, 'fontFamily', '留空继承')),
1361
+ field('字号', optionalNumberInput(patch, 'fontSize', 8, 240, 1)),
1362
+ field('字重', optionalNumberInput(patch, 'fontWeight', 100, 900, 100)),
1363
+ field('文字 RGBA', optionalColorControl(patch, 'color')),
1364
+ field('描边 RGBA', optionalColorControl(patch, 'strokeColor')),
1365
+ field('描边宽度', optionalNumberInput(patch, 'strokeWidth', 0, 12, .5)),
1366
+ );
1367
+ return wrap;
1368
+ }
1369
+
1370
+ function optionalTextInput(target, key, placeholder) {
1371
+ return textInput(target[key] || '', (value) => {
1372
+ if (value) target[key] = value;
1373
+ else delete target[key];
1374
+ }, { placeholder });
1375
+ }
1376
+
1377
+ function optionalNumberInput(target, key, min, max, step) {
1378
+ return numberInput(target[key], (value) => {
1379
+ if (value === undefined) delete target[key];
1380
+ else target[key] = value;
1381
+ }, min, max, step, { optional: true, placeholder: '继承' });
1382
+ }
1383
+
1384
+ function optionalColorControl(target, key) {
1385
+ const outer = h('div');
1386
+ if (target[key]) {
1387
+ outer.append(colorControl(target[key], (value) => { target[key] = value; }));
1388
+ outer.append(button('恢复继承', () => mutate(() => { delete target[key]; }, { render: true })));
1389
+ } else {
1390
+ outer.append(button('设置覆写颜色', () => mutate(() => { target[key] = '#ffffffff'; }, { render: true })));
1391
+ }
1392
+ return outer;
1393
+ }
1394
+
1395
+ // ── 组件 ────────────────────────────────────────────────────────────────
1396
+
1397
+ function renderComponentsWorkspace() {
1398
+ const list = h('div', 'resource-list');
1399
+ for (const component of editor.design.components) {
1400
+ const row = h('button', `resource-row${component.id === editor.selectedComponent ? ' active' : ''}`);
1401
+ row.type = 'button';
1402
+ const [kind, glyph] = componentKinds[component.kind] || [component.kind, '?'];
1403
+ const icon = h('span', 'resource-icon', glyph);
1404
+ const copy = h('span', 'resource-copy');
1405
+ copy.append(h('strong', null, component.name), h('span', null, kind));
1406
+ const count = editor.design.placements.filter((item) => item.componentId === component.id).length;
1407
+ row.append(icon, copy, h('span', 'row-badge', `${count} 处`));
1408
+ row.addEventListener('click', () => {
1409
+ selectComponentId(component.id);
1410
+ renderWorkspace();
1411
+ });
1412
+ list.append(row);
1413
+ }
1414
+ if (!editor.design.components.length) list.append(h('div', 'placeholder', '还没有组件。'));
1415
+ refs.inspector.append(flushSection('组件库', list, `${editor.design.components.length} 项`));
1416
+ const component = editor.design.components.find((item) => item.id === editor.selectedComponent);
1417
+ if (!component) return;
1418
+
1419
+ const basics = h('div');
1420
+ const mockActive = editor.mockComponentId === component.id;
1421
+ const mock = button(mockActive ? '停止 Mock' : 'Mock 测试', () => mockComponent(component), 'small-button accent');
1422
+ mock.setAttribute('aria-pressed', String(mockActive));
1423
+ const previewActions = h('div', 'button-row');
1424
+ previewActions.append(mock);
1425
+ basics.append(
1426
+ previewActions,
1427
+ field('名称', textInput(component.name, (value) => { component.name = value; }, { interaction: false })),
1428
+ field('类型', h('span', 'row-badge', componentKinds[component.kind]?.[0] || component.kind)),
1429
+ field('使用样式', selectInput(component.styleId, allStyles().map((style) => [style.id, style.name]), (value) => { component.styleId = value; }, { render: false })),
1430
+ );
1431
+ refs.inspector.append(section('组件定义', basics));
1432
+ refs.inspector.append(renderComponentBehavior(component));
1433
+ refs.inspector.append(renderComponentTitle(component));
1434
+ if (component.kind === 'agent-notice') refs.inspector.append(renderAnnouncementWriter());
1435
+
1436
+ const actions = h('div', 'button-row split');
1437
+ actions.append(
1438
+ button('添加到布局', () => addPlacement(component)),
1439
+ h('span'),
1440
+ button('复制', () => duplicateComponent(component)),
1441
+ button('删除', () => deleteComponent(component), 'small-button danger'),
1442
+ );
1443
+ refs.inspector.append(section('组件操作', actions));
1444
+ }
1445
+
1446
+ function renderComponentBehavior(component) {
1447
+ const body = h('div');
1448
+ if (component.kind === 'danmaku') {
1449
+ body.append(
1450
+ field('滚动方向', selectInput(component.axis, [['vertical', '纵向列表'], ['horizontal', '横向弹幕']], (value) => { component.axis = value; }, { render: false })),
1451
+ field('显示内容', selectInput(component.admission, [['danmaku', '仅弹幕'], ['gift', '仅礼物'], ['all', '弹幕 + 礼物']], (value) => { component.admission = value; }, { render: false })),
1452
+ checkInput('显示用户头像', component.showAvatar, (value) => { component.showAvatar = value; }),
1453
+ field('速度 px/s', numberInput(component.speed, (value) => { component.speed = value; }, 10, 500, 1)),
1454
+ field('条目间距', numberInput(component.gap, (value) => { component.gap = value; }, 0, 500, 1)),
1455
+ field('最多保留', numberInput(component.maxItems, (value) => { component.maxItems = value; }, 1, 100, 1)),
1456
+ field('用户名显示上限', numberInput(component.usernameMaxChars, (value) => { component.usernameMaxChars = value; }, 0, 5000, 1), '0 表示完整显示;省略号计入上限。'),
1457
+ field('内容显示上限', numberInput(component.bodyMaxChars, (value) => { component.bodyMaxChars = value; }, 0, 5000, 1), '0 表示完整显示;省略号计入上限。'),
1458
+ field('边缘淡出 px', numberInput(component.edgeFadePx, (value) => { component.edgeFadePx = value; }, 0, 512, 1), '沿滚动方向的两端淡出;设为 0 可关闭。'),
1459
+ );
1460
+ } else if (component.kind === 'scroll-notice') {
1461
+ body.append(
1462
+ field('滚动方向', selectInput(component.axis, [['horizontal', '横向'], ['vertical', '纵向']], (value) => { component.axis = value; }, { render: false })),
1463
+ field('公告文本', textInput(component.text, (value) => { component.text = value; }, { multiline: true, maxLength: 5000 }), '每个非空行是一条公告。'),
1464
+ field('行间暂留 ms', numberInput(component.lineHoldMs, (value) => { component.lineHoldMs = value; }, 0, 60000, 1)),
1465
+ field('行间交替时长 ms', numberInput(component.lineTransitionMs, (value) => { component.lineTransitionMs = value; }, 0, 10000, 1), '0 表示整段连续滚动。'),
1466
+ field('单行速度 px/s', numberInput(component.speed, (value) => { component.speed = value; }, 10, 500, 1)),
1467
+ field('连续滚动间距', numberInput(component.gap, (value) => { component.gap = value; }, 0, 500, 1)),
1468
+ field('边缘淡出 px', numberInput(component.edgeFadePx, (value) => { component.edgeFadePx = value; }, 0, 512, 1), '沿滚动方向的两端淡出;设为 0 可关闭。'),
1469
+ );
1470
+ } else if (component.kind === 'fixed-notice') {
1471
+ body.append(field('公告文本', textInput(component.text, (value) => { component.text = value; }, { multiline: true, maxLength: 5000 })));
1472
+ } else if (component.kind === 'agent-notice') {
1473
+ body.append(
1474
+ field('空白占位', textInput(component.emptyText, (value) => { component.emptyText = value; }, { maxLength: 500 })),
1475
+ checkInput('无公告时隐藏组件', component.hideWhenEmpty, (value) => { component.hideWhenEmpty = value; }),
1476
+ field('打字间隔 ms', numberInput(component.typingMs, (value) => { component.typingMs = value; }, 0, 2000, 1), '逐字出现的间隔;设为 0 可关闭动画。'),
1477
+ );
1478
+ } else if (component.kind === 'image') {
1479
+ body.append(
1480
+ field('图片来源', selectInput(component.source, [['external', '外部 URL'], ['upload', '素材库']], (value) => { component.source = value; }, { render: true })),
1481
+ field('填充方式', selectInput(component.fit, [['contain', '完整显示'], ['cover', '铺满裁切'], ['fill', '拉伸铺满']], (value) => { component.fit = value; }, { render: false })),
1482
+ field('整体透明度', numberInput(component.opacity, (value) => { component.opacity = value; }, 0, 1, .01)),
1483
+ );
1484
+ if (component.source === 'external') {
1485
+ body.append(field('图片 URL', textInput(component.url, (value) => { component.url = value; }, { placeholder: 'https://…' })));
1486
+ } else {
1487
+ body.append(renderImageAssetPicker(component));
1488
+ }
1489
+ }
1490
+ return section('行为参数', body);
1491
+ }
1492
+
1493
+ function renderComponentTitle(component) {
1494
+ const body = h('div');
1495
+ body.append(checkInput('显示组件标题', Boolean(component.title), (enabled) => {
1496
+ if (enabled) component.title = {
1497
+ text: component.name || '组件标题',
1498
+ position: 'top',
1499
+ align: 'left',
1500
+ style: defaultTitleStyle(),
1501
+ };
1502
+ else delete component.title;
1503
+ }, { render: true }));
1504
+ if (!component.title) return section('组件标题', body, '未启用');
1505
+ const title = component.title;
1506
+ body.append(
1507
+ field('标题内容', textInput(title.text, (value) => { title.text = value; }, { maxLength: 500 })),
1508
+ field('所在边', selectInput(title.position, [
1509
+ ['top', '上边'], ['right', '右边'], ['bottom', '下边'], ['left', '左边'],
1510
+ ], (value) => { title.position = value; }, { render: false })),
1511
+ field('标题对齐', selectInput(title.align, [
1512
+ ['left', '靠左 / 起点'], ['center', '居中'], ['right', '靠右 / 终点'],
1513
+ ], (value) => { title.align = value; }, { render: false })),
1514
+ divider(),
1515
+ h('div', 'field-label', '标题字体'),
1516
+ textStyleFields(title.style, false),
1517
+ );
1518
+ return section('组件标题', body, '独立字体');
1519
+ }
1520
+
1521
+ function defaultTitleStyle() {
1522
+ return {
1523
+ fontFamily: 'Microsoft YaHei, sans-serif',
1524
+ fontSize: 26,
1525
+ fontWeight: 700,
1526
+ color: '#ffffffff',
1527
+ strokeColor: '#17324dff',
1528
+ strokeWidth: 0,
1529
+ };
1530
+ }
1531
+
1532
+ function mockComponent(component) {
1533
+ if (!editor.frameReady) return toast('预览画布仍在装入', true);
1534
+ editor.mockComponentId = editor.mockComponentId === component.id ? '' : component.id;
1535
+ renderWorkspace();
1536
+ sendPreview();
1537
+ }
1538
+
1539
+ function renderImageAssetPicker(component) {
1540
+ const outer = h('div');
1541
+ const gallery = h('div', 'asset-grid');
1542
+ for (const asset of editor.state.assets || []) {
1543
+ const cell = h('div', 'asset-cell');
1544
+ const card = h('button', `asset-card${component.assetId === asset.id ? ' active' : ''}`);
1545
+ card.type = 'button';
1546
+ const image = h('img');
1547
+ image.src = asset.url;
1548
+ image.alt = '';
1549
+ card.append(image, h('span', null, asset.id.slice(0, 10)));
1550
+ card.addEventListener('click', () => mutate(() => { component.assetId = asset.id; }, { render: true }));
1551
+ const remove = button('×', () => void deleteAsset(asset.id), 'asset-remove');
1552
+ remove.title = '删除素材';
1553
+ cell.append(card, remove);
1554
+ gallery.append(cell);
1555
+ }
1556
+ const upload = h('label', 'small-button accent file-button', '上传图片');
1557
+ const file = h('input');
1558
+ file.type = 'file';
1559
+ file.accept = 'image/png,image/jpeg,image/webp,image/gif';
1560
+ file.addEventListener('change', async () => {
1561
+ const selected = file.files?.[0];
1562
+ if (!selected) return;
1563
+ try {
1564
+ const bytes = new Uint8Array(await selected.arrayBuffer());
1565
+ let binary = '';
1566
+ for (let offset = 0; offset < bytes.length; offset += 0x8000) binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
1567
+ const out = await api('/api/editor/assets', 'POST', { base64: btoa(binary) });
1568
+ editor.state.assets = out.assets;
1569
+ component.assetId = out.asset.id;
1570
+ markChanged({ render: true });
1571
+ } catch (error) { toast(errorText(error), true); }
1572
+ });
1573
+ upload.append(file);
1574
+ outer.append(gallery, upload);
1575
+ return outer;
1576
+ }
1577
+
1578
+ function renderAnnouncementWriter() {
1579
+ const state = editor.state.agentAnnouncement || { text: '', revision: 0 };
1580
+ const max = editor.state.maxAnnouncementChars || 200;
1581
+ const body = h('div');
1582
+ const counter = h('span', 'row-badge', `${Array.from(editor.announcementDraft).length}/${max}`);
1583
+ const text = h('textarea');
1584
+ text.value = editor.announcementDraft;
1585
+ text.addEventListener('input', () => {
1586
+ const chars = Array.from(text.value);
1587
+ if (chars.length > max) text.value = chars.slice(0, max).join('');
1588
+ editor.announcementDraft = text.value;
1589
+ const current = editor.state.agentAnnouncement || state;
1590
+ editor.announcementDirty = text.value !== (current.text || '');
1591
+ if (!editor.announcementDirty) {
1592
+ editor.announcementBaseRevision = Number(current.revision) || 0;
1593
+ editor.announcementConflict = false;
1594
+ }
1595
+ counter.textContent = `${Array.from(text.value).length}/${max}`;
1596
+ updateSaveState();
1597
+ });
1598
+ const actions = h('div', 'button-row split');
1599
+ const write = button(editor.announcementConflict ? '保留草稿并覆盖' : '写入公告栏', async () => {
1600
+ if (editor.announcementSaving) return;
1601
+ const submitted = editor.announcementDraft;
1602
+ const expectedRevision = editor.announcementConflict
1603
+ ? Number(editor.state.agentAnnouncement?.revision) || 0
1604
+ : editor.announcementBaseRevision;
1605
+ editor.announcementSaving = true;
1606
+ write.disabled = true;
1607
+ updateSaveState();
1608
+ try {
1609
+ const next = await api('/api/editor/announcement', 'PUT', { text: submitted, expectedRevision });
1610
+ editor.state.agentAnnouncement = next;
1611
+ editor.announcementBaseRevision = Number(next.revision) || 0;
1612
+ editor.announcementConflict = false;
1613
+ if (editor.announcementDraft === submitted) editor.announcementDraft = next.text || '';
1614
+ editor.announcementDirty = editor.announcementDraft !== (next.text || '');
1615
+ toast(submitted ? 'Agent 公告已更新' : 'Agent 公告已清空');
1616
+ } catch (error) {
1617
+ if (error.status === 409) {
1618
+ try {
1619
+ const latest = await api('/api/editor/state');
1620
+ editor.state = { ...editor.state, ...latest };
1621
+ applyAnnouncementState(latest.agentAnnouncement);
1622
+ } catch { /* 下面仍显示原始冲突。 */ }
1623
+ }
1624
+ toast(errorText(error), true);
1625
+ } finally {
1626
+ editor.announcementSaving = false;
1627
+ updateSaveState();
1628
+ renderWorkspace();
1629
+ }
1630
+ }, 'small-button accent');
1631
+ actions.append(counter, write);
1632
+ if (editor.announcementConflict) {
1633
+ const warning = h('div', 'inline-warning');
1634
+ warning.append(
1635
+ h('span', null, 'Agent 已更新服务器公告。你的草稿仍保留。'),
1636
+ button('采用服务器内容', () => {
1637
+ editor.announcementDraft = editor.state.agentAnnouncement?.text || '';
1638
+ editor.announcementBaseRevision = Number(editor.state.agentAnnouncement?.revision) || 0;
1639
+ editor.announcementDirty = false;
1640
+ editor.announcementConflict = false;
1641
+ updateSaveState();
1642
+ renderWorkspace();
1643
+ }, 'small-button'),
1644
+ );
1645
+ body.append(warning);
1646
+ }
1647
+ body.append(field('纯文本内容', text), actions);
1648
+ return section('Agent 公告写入', body, `上限 ${max} 字`);
1649
+ }
1650
+
1651
+ function showCreateComponent() {
1652
+ const body = h('div', 'modal-body');
1653
+ body.append(h('h2', null, '新建组件'));
1654
+ const grid = h('div', 'asset-grid');
1655
+ for (const [kind, [label, glyph]] of Object.entries(componentKinds)) {
1656
+ const choose = button(`${glyph} ${label}`, () => {
1657
+ refs.modal.close();
1658
+ createComponent(kind);
1659
+ }, 'small-button');
1660
+ choose.style.minHeight = '52px';
1661
+ grid.append(choose);
1662
+ }
1663
+ body.append(grid);
1664
+ showModal(body);
1665
+ }
1666
+
1667
+ function createComponent(kind) {
1668
+ const base = { id: uniqueId('component'), name: componentKinds[kind][0], kind, styleId: editor.selectedStyle || 'builtin:sky' };
1669
+ let component;
1670
+ if (kind === 'danmaku') component = { ...base, axis: 'vertical', admission: 'all', showAvatar: true, speed: 90, gap: 12, maxItems: 12, usernameMaxChars: 24, bodyMaxChars: 80, edgeFadePx: 32 };
1671
+ else if (kind === 'scroll-notice') component = { ...base, axis: 'horizontal', text: '欢迎来到直播间\n关注主播,不错过开播通知', speed: 70, gap: 60, lineHoldMs: 1600, lineTransitionMs: 420, edgeFadePx: 32 };
1672
+ else if (kind === 'fixed-notice') component = { ...base, text: '固定公告' };
1673
+ else if (kind === 'agent-notice') component = { ...base, emptyText: '公告栏待更新', hideWhenEmpty: false, typingMs: 42 };
1674
+ else component = { ...base, source: 'external', url: '', assetId: '', fit: 'contain', opacity: 1 };
1675
+ mutate(() => {
1676
+ editor.design.components.push(component);
1677
+ selectComponentId(component.id);
1678
+ }, { render: true });
1679
+ }
1680
+
1681
+ function duplicateComponent(component) {
1682
+ const next = structuredClone(component);
1683
+ next.id = uniqueId('component');
1684
+ next.name = `${component.name} 副本`;
1685
+ mutate(() => {
1686
+ editor.design.components.push(next);
1687
+ selectComponentId(next.id);
1688
+ }, { render: true });
1689
+ }
1690
+
1691
+ function deleteComponent(component) {
1692
+ const count = editor.design.placements.filter((item) => item.componentId === component.id).length;
1693
+ confirmModal('删除组件', count ? `组件与它的 ${count} 个布局实例都会删除。` : `将删除「${component.name}」。`, '删除', () => {
1694
+ mutate(() => {
1695
+ editor.design.components = editor.design.components.filter((item) => item.id !== component.id);
1696
+ editor.design.placements = editor.design.placements.filter((item) => item.componentId !== component.id);
1697
+ selectComponentId(editor.design.components[0]?.id || '');
1698
+ }, { render: true });
1699
+ });
1700
+ }
1701
+
1702
+ // ── 布局 ────────────────────────────────────────────────────────────────
1703
+
1704
+ function renderLayoutWorkspace() {
1705
+ const canvasBody = h('div');
1706
+ canvasBody.append(
1707
+ field('画布宽度', numberInput(editor.design.canvas.width, (value) => { editor.design.canvas.width = value; }, 320, 7680, 1, { afterInput: updateCanvasMetrics })),
1708
+ field('画布高度', numberInput(editor.design.canvas.height, (value) => { editor.design.canvas.height = value; }, 180, 4320, 1, { afterInput: updateCanvasMetrics })),
1709
+ );
1710
+ refs.inspector.append(section('直播画布', canvasBody));
1711
+
1712
+ const list = h('div', 'resource-list');
1713
+ const placements = [...editor.design.placements].sort((a, b) => b.z - a.z);
1714
+ for (const placement of placements) {
1715
+ const component = componentById(placement.componentId);
1716
+ const row = h('button', `resource-row${placement.id === editor.selectedPlacement ? ' active' : ''}`);
1717
+ row.type = 'button';
1718
+ const icon = h('span', 'resource-icon', componentKinds[component?.kind]?.[1] || '?');
1719
+ const copy = h('span', 'resource-copy');
1720
+ copy.append(h('strong', null, component?.name || '丢失组件'), h('span', null, `${Math.round(placement.x)}, ${Math.round(placement.y)} · ${Math.round(placement.width)} × ${Math.round(placement.height)}`));
1721
+ const label = !placement.visible ? '隐藏' : placement.locked ? '锁定' : `z ${placement.z}`;
1722
+ row.append(icon, copy, h('span', `row-badge${placement.visible ? '' : ' hidden-badge'}`, label));
1723
+ row.addEventListener('click', () => selectPlacement(placement.id));
1724
+ list.append(row);
1725
+ }
1726
+ if (!placements.length) list.append(h('div', 'placeholder', '还没有布局项。'));
1727
+ refs.inspector.append(flushSection('图层', list, `${placements.length} 项`));
1728
+ const placement = editor.design.placements.find((item) => item.id === editor.selectedPlacement);
1729
+ if (!placement) return;
1730
+ const component = componentById(placement.componentId);
1731
+ const geometry = h('div');
1732
+ geometry.append(
1733
+ field('组件', h('span', 'row-badge', component?.name || placement.componentId)),
1734
+ field('X', placementNumber(placement, 'x', -7680, 7680)),
1735
+ field('Y', placementNumber(placement, 'y', -4320, 4320)),
1736
+ field('宽度', placementNumber(placement, 'width', 20, 7680)),
1737
+ field('高度', placementNumber(placement, 'height', 20, 4320)),
1738
+ field('层级 Z', placementNumber(placement, 'z', -10000, 10000)),
1739
+ checkInput('显示这个布局项', placement.visible, (value) => { placement.visible = value; }, { render: true }),
1740
+ checkInput('锁定位置与尺寸', placement.locked, (value) => { placement.locked = value; }, { render: true }),
1741
+ );
1742
+ refs.inspector.append(section('位置与尺寸', geometry));
1743
+ const actions = h('div', 'button-row');
1744
+ actions.append(
1745
+ button('移到最上层', () => mutate(() => { placement.z = Math.max(0, ...editor.design.placements.map((item) => item.z)) + 1; }, { render: true })),
1746
+ button('复制实例', () => duplicatePlacement(placement)),
1747
+ button('删除实例', () => deletePlacement(placement), 'small-button danger'),
1748
+ );
1749
+ refs.inspector.append(section('布局操作', actions));
1750
+ }
1751
+
1752
+ function placementNumber(placement, key, min, max) {
1753
+ const input = numberInput(placement[key], (value) => { placement[key] = key === 'z' ? Math.round(value) : value; }, min, max, 1, { afterInput: renderInteraction });
1754
+ input.disabled = placement.locked && key !== 'z';
1755
+ return input;
1756
+ }
1757
+
1758
+ function showAddPlacement() {
1759
+ if (!editor.design.components.length) return toast('请先创建组件', true);
1760
+ const body = h('div', 'modal-body');
1761
+ body.append(h('h2', null, '添加到布局'), h('p', null, '同一个组件可以在画布中放置多次。'));
1762
+ const list = h('div', 'resource-list');
1763
+ for (const component of editor.design.components) {
1764
+ const row = h('button', 'resource-row');
1765
+ row.type = 'button';
1766
+ const icon = h('span', 'resource-icon', componentKinds[component.kind]?.[1] || '?');
1767
+ const copy = h('span', 'resource-copy');
1768
+ copy.append(h('strong', null, component.name), h('span', null, componentKinds[component.kind]?.[0] || component.kind));
1769
+ row.append(icon, copy);
1770
+ row.addEventListener('click', () => { refs.modal.close(); addPlacement(component); });
1771
+ list.append(row);
1772
+ }
1773
+ body.append(list);
1774
+ showModal(body);
1775
+ }
1776
+
1777
+ function addPlacement(component) {
1778
+ const canvas = editor.design.canvas;
1779
+ const width = component.kind === 'agent-notice' || component.kind.includes('notice') ? Math.min(960, canvas.width * .6) : Math.min(600, canvas.width * .35);
1780
+ const height = component.kind === 'danmaku' ? Math.min(560, canvas.height * .65) : Math.min(160, canvas.height * .2);
1781
+ const placement = {
1782
+ id: uniqueId('placement'), componentId: component.id,
1783
+ x: Math.round((canvas.width - width) / 2), y: Math.round((canvas.height - height) / 2),
1784
+ width: Math.round(width), height: Math.round(height),
1785
+ z: Math.max(0, ...editor.design.placements.map((item) => item.z)) + 1,
1786
+ visible: true, locked: false,
1787
+ };
1788
+ mutate(() => {
1789
+ editor.design.placements.push(placement);
1790
+ editor.selectedPlacement = placement.id;
1791
+ selectComponentId(component.id);
1792
+ editor.mode = 'layout';
1793
+ }, { render: true });
1794
+ }
1795
+
1796
+ function duplicatePlacement(placement) {
1797
+ const next = structuredClone(placement);
1798
+ next.id = uniqueId('placement');
1799
+ next.x += 20; next.y += 20; next.z += 1;
1800
+ mutate(() => {
1801
+ editor.design.placements.push(next);
1802
+ editor.selectedPlacement = next.id;
1803
+ }, { render: true });
1804
+ }
1805
+
1806
+ function deletePlacement(placement) {
1807
+ mutate(() => {
1808
+ editor.design.placements = editor.design.placements.filter((item) => item.id !== placement.id);
1809
+ editor.selectedPlacement = editor.design.placements[0]?.id || '';
1810
+ }, { render: true });
1811
+ }
1812
+
1813
+ function componentById(id) {
1814
+ return editor.design.components.find((item) => item.id === id);
1815
+ }
1816
+
1817
+ function selectPlacement(id) {
1818
+ editor.selectedPlacement = id;
1819
+ renderInteraction();
1820
+ if (editor.mode === 'layout') renderWorkspace();
1821
+ }
1822
+
1823
+ function renderInteraction() {
1824
+ if (!editor.design) return;
1825
+ refs.layer.replaceChildren();
1826
+ refs.layer.style.pointerEvents = 'auto';
1827
+ if (editor.mode !== 'layout') {
1828
+ for (const placement of [...editor.design.placements].filter((item) => item.visible).sort((a, b) => a.z - b.z)) {
1829
+ const component = componentById(placement.componentId);
1830
+ if (!component) continue;
1831
+ const selected = editor.mode === 'components'
1832
+ ? component.id === editor.selectedComponent
1833
+ : component.styleId === editor.selectedStyle;
1834
+ const hotspot = h('button', `placement-hotspot${selected ? ' selected' : ''}`);
1835
+ hotspot.type = 'button';
1836
+ hotspot.title = editor.mode === 'components' ? `编辑组件:${component.name}` : `编辑样式:${styleById(component.styleId)?.name || component.styleId}`;
1837
+ Object.assign(hotspot.style, {
1838
+ left: `${placement.x}px`, top: `${placement.y}px`, width: `${placement.width}px`, height: `${placement.height}px`, zIndex: String(10000 + placement.z),
1839
+ });
1840
+ hotspot.addEventListener('click', (event) => {
1841
+ event.stopPropagation();
1842
+ if (editor.mode === 'components') selectComponentId(component.id);
1843
+ else {
1844
+ editor.selectedStyle = component.styleId;
1845
+ if (editor.styleTab === 'groups') editor.styleTab = 'appearance';
1846
+ }
1847
+ renderWorkspace();
1848
+ renderInteraction();
1849
+ });
1850
+ refs.layer.append(hotspot);
1851
+ }
1852
+ refs.selectionStatus.textContent = '草稿预览';
1853
+ return;
1854
+ }
1855
+ const sorted = [...editor.design.placements].filter((item) => item.visible).sort((a, b) => a.z - b.z);
1856
+ for (const placement of sorted) {
1857
+ const component = componentById(placement.componentId);
1858
+ const box = h('div', `placement-box${placement.id === editor.selectedPlacement ? ' selected' : ''}${placement.locked ? ' locked' : ''}`);
1859
+ box.dataset.placementId = placement.id;
1860
+ Object.assign(box.style, {
1861
+ left: `${placement.x}px`, top: `${placement.y}px`, width: `${placement.width}px`, height: `${placement.height}px`, zIndex: String(10000 + placement.z),
1862
+ });
1863
+ box.append(h('span', 'placement-label', `${component?.name || placement.componentId}${placement.locked ? ' · 已锁定' : ''}`));
1864
+ for (const dir of ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']) {
1865
+ const handle = h('span', 'resize-handle');
1866
+ handle.dataset.dir = dir;
1867
+ box.append(handle);
1868
+ }
1869
+ box.addEventListener('pointerdown', (event) => startPlacementGesture(event, placement, box));
1870
+ refs.layer.append(box);
1871
+ }
1872
+ const selected = editor.design.placements.find((item) => item.id === editor.selectedPlacement);
1873
+ const component = selected ? componentById(selected.componentId) : null;
1874
+ refs.selectionStatus.textContent = selected
1875
+ ? `${component?.name || selected.componentId} · X ${Math.round(selected.x)} · Y ${Math.round(selected.y)} · ${Math.round(selected.width)} × ${Math.round(selected.height)}`
1876
+ : '请选择一个布局项';
1877
+ }
1878
+
1879
+ function startPlacementGesture(event, placement, box) {
1880
+ event.stopPropagation();
1881
+ editor.selectedPlacement = placement.id;
1882
+ refs.layer.querySelectorAll('.placement-box').forEach((item) => item.classList.toggle('selected', item === box));
1883
+ renderWorkspace();
1884
+ if (placement.locked || event.button !== 0) return;
1885
+ event.preventDefault();
1886
+ const handle = event.target.closest('.resize-handle');
1887
+ const dir = handle?.dataset.dir || 'move';
1888
+ const start = { x: event.clientX, y: event.clientY, rect: structuredClone(placement) };
1889
+ let finished = false;
1890
+ box.setPointerCapture(event.pointerId);
1891
+ const move = (pointer) => {
1892
+ const dx = (pointer.clientX - start.x) / editor.displayScale;
1893
+ const dy = (pointer.clientY - start.y) / editor.displayScale;
1894
+ const next = resizeRect(start.rect, dir, dx, dy);
1895
+ Object.assign(placement, snapRect(next, dir, placement.id));
1896
+ Object.assign(box.style, {
1897
+ left: `${placement.x}px`, top: `${placement.y}px`, width: `${placement.width}px`, height: `${placement.height}px`,
1898
+ });
1899
+ refs.selectionStatus.textContent = `${componentById(placement.componentId)?.name || placement.componentId} · X ${Math.round(placement.x)} · Y ${Math.round(placement.y)} · ${Math.round(placement.width)} × ${Math.round(placement.height)}`;
1900
+ markChanged({ render: false, interaction: false, checkpoint: false });
1901
+ };
1902
+ const finish = (cancel = false) => {
1903
+ if (finished) return;
1904
+ finished = true;
1905
+ box.removeEventListener('pointermove', move);
1906
+ box.removeEventListener('pointerup', commit);
1907
+ box.removeEventListener('pointercancel', rollback);
1908
+ removeEventListener('keydown', cancelWithEscape);
1909
+ if (box.hasPointerCapture(event.pointerId)) box.releasePointerCapture(event.pointerId);
1910
+ if (cancel) {
1911
+ Object.assign(placement, start.rect);
1912
+ markChanged({ render: false, interaction: false, checkpoint: false });
1913
+ } else {
1914
+ checkpoint();
1915
+ }
1916
+ clearSnapLines();
1917
+ renderWorkspace();
1918
+ renderInteraction();
1919
+ };
1920
+ const commit = () => finish(false);
1921
+ const rollback = () => finish(true);
1922
+ const cancelWithEscape = (keyEvent) => {
1923
+ if (keyEvent.key !== 'Escape') return;
1924
+ keyEvent.preventDefault();
1925
+ finish(true);
1926
+ };
1927
+ box.addEventListener('pointermove', move);
1928
+ box.addEventListener('pointerup', commit, { once: true });
1929
+ box.addEventListener('pointercancel', rollback, { once: true });
1930
+ addEventListener('keydown', cancelWithEscape);
1931
+ }
1932
+
1933
+ function resizeRect(start, dir, dx, dy) {
1934
+ const rect = { x: start.x, y: start.y, width: start.width, height: start.height };
1935
+ if (dir === 'move') { rect.x += dx; rect.y += dy; return rect; }
1936
+ if (dir.includes('e')) rect.width = start.width + dx;
1937
+ if (dir.includes('s')) rect.height = start.height + dy;
1938
+ if (dir.includes('w')) { rect.x = start.x + dx; rect.width = start.width - dx; }
1939
+ if (dir.includes('n')) { rect.y = start.y + dy; rect.height = start.height - dy; }
1940
+ if (rect.width < 20) { if (dir.includes('w')) rect.x -= 20 - rect.width; rect.width = 20; }
1941
+ if (rect.height < 20) { if (dir.includes('n')) rect.y -= 20 - rect.height; rect.height = 20; }
1942
+ return rect;
1943
+ }
1944
+
1945
+ function snapRect(rect, dir, placementId) {
1946
+ const grid = Number(refs.snapGrid.value) || 1;
1947
+ const canvas = editor.design.canvas;
1948
+ const result = { ...rect };
1949
+ clearSnapLines();
1950
+ const screenThreshold = 7;
1951
+ const threshold = screenThreshold / editor.displayScale;
1952
+ const verticalTargets = [0, canvas.width / 2, canvas.width];
1953
+ const horizontalTargets = [0, canvas.height / 2, canvas.height];
1954
+ for (const item of editor.design.placements) {
1955
+ if (item.id === placementId || !item.visible) continue;
1956
+ verticalTargets.push(item.x, item.x + item.width / 2, item.x + item.width);
1957
+ horizontalTargets.push(item.y, item.y + item.height / 2, item.y + item.height);
1958
+ }
1959
+ const xPoints = dir === 'move' ? [['x', result.x], ['cx', result.x + result.width / 2], ['r', result.x + result.width]] : [];
1960
+ const yPoints = dir === 'move' ? [['y', result.y], ['cy', result.y + result.height / 2], ['b', result.y + result.height]] : [];
1961
+ if (dir.includes('w')) xPoints.push(['x', result.x]);
1962
+ if (dir.includes('e')) xPoints.push(['r', result.x + result.width]);
1963
+ if (dir.includes('n')) yPoints.push(['y', result.y]);
1964
+ if (dir.includes('s')) yPoints.push(['b', result.y + result.height]);
1965
+ const xSnap = nearestSnap(xPoints, verticalTargets, threshold);
1966
+ const ySnap = nearestSnap(yPoints, horizontalTargets, threshold);
1967
+ if (xSnap) {
1968
+ if (dir === 'move') {
1969
+ if (xSnap.point === 'x') result.x = xSnap.target;
1970
+ if (xSnap.point === 'r') result.x = xSnap.target - result.width;
1971
+ if (xSnap.point === 'cx') result.x = xSnap.target - result.width / 2;
1972
+ } else {
1973
+ if (xSnap.point === 'x') { const right = result.x + result.width; result.x = xSnap.target; result.width = right - result.x; }
1974
+ if (xSnap.point === 'r') result.width = xSnap.target - result.x;
1975
+ }
1976
+ drawSnapLine('vertical', xSnap.target);
1977
+ }
1978
+ if (ySnap) {
1979
+ if (dir === 'move') {
1980
+ if (ySnap.point === 'y') result.y = ySnap.target;
1981
+ if (ySnap.point === 'b') result.y = ySnap.target - result.height;
1982
+ if (ySnap.point === 'cy') result.y = ySnap.target - result.height / 2;
1983
+ } else {
1984
+ if (ySnap.point === 'y') { const bottom = result.y + result.height; result.y = ySnap.target; result.height = bottom - result.y; }
1985
+ if (ySnap.point === 'b') result.height = ySnap.target - result.y;
1986
+ }
1987
+ drawSnapLine('horizontal', ySnap.target);
1988
+ }
1989
+ if (!xSnap) {
1990
+ if (dir === 'move') result.x = Math.round(result.x / grid) * grid;
1991
+ if (dir.includes('w')) {
1992
+ const right = result.x + result.width;
1993
+ result.x = Math.round(result.x / grid) * grid;
1994
+ result.width = right - result.x;
1995
+ }
1996
+ if (dir.includes('e')) result.width = Math.round((result.x + result.width) / grid) * grid - result.x;
1997
+ }
1998
+ if (!ySnap) {
1999
+ if (dir === 'move') result.y = Math.round(result.y / grid) * grid;
2000
+ if (dir.includes('n')) {
2001
+ const bottom = result.y + result.height;
2002
+ result.y = Math.round(result.y / grid) * grid;
2003
+ result.height = bottom - result.y;
2004
+ }
2005
+ if (dir.includes('s')) result.height = Math.round((result.y + result.height) / grid) * grid - result.y;
2006
+ }
2007
+ result.width = Math.max(20, result.width);
2008
+ result.height = Math.max(20, result.height);
2009
+ result.x = clamp(result.x, 0, Math.max(0, canvas.width - result.width));
2010
+ result.y = clamp(result.y, 0, Math.max(0, canvas.height - result.height));
2011
+ result.width = Math.min(result.width, canvas.width - result.x);
2012
+ result.height = Math.min(result.height, canvas.height - result.y);
2013
+ return result;
2014
+ }
2015
+
2016
+ function nearestSnap(points, targets, threshold) {
2017
+ let best = null;
2018
+ for (const [point, value] of points) {
2019
+ for (const target of targets) {
2020
+ const distance = Math.abs(value - target);
2021
+ if (distance <= threshold && (!best || distance < best.distance)) best = { point, target, distance };
2022
+ }
2023
+ }
2024
+ return best;
2025
+ }
2026
+
2027
+ function drawSnapLine(axis, position) {
2028
+ const line = h('span', `snap-line ${axis}`);
2029
+ line.style[axis === 'vertical' ? 'left' : 'top'] = `${position}px`;
2030
+ refs.layer.append(line);
2031
+ }
2032
+
2033
+ function clearSnapLines() {
2034
+ refs.layer.querySelectorAll('.snap-line').forEach((line) => line.remove());
2035
+ }
2036
+
2037
+ // ── 对话框、快捷键 ──────────────────────────────────────────────────────
2038
+
2039
+ function showModal(content) {
2040
+ refs.modalContent.replaceChildren(content);
2041
+ if (!refs.modal.open) refs.modal.showModal();
2042
+ }
2043
+
2044
+ function confirmModal(title, message, actionLabel, action) {
2045
+ const content = h('div');
2046
+ const body = h('div', 'modal-body');
2047
+ body.append(h('h2', null, title), h('p', null, message));
2048
+ const actions = h('div', 'modal-actions');
2049
+ actions.append(
2050
+ button('取消', () => refs.modal.close()),
2051
+ button(actionLabel, () => {
2052
+ refs.modal.close();
2053
+ void action();
2054
+ }, 'small-button danger'),
2055
+ );
2056
+ content.append(body, actions);
2057
+ showModal(content);
2058
+ }
2059
+
2060
+ function showHelp() {
2061
+ const content = h('div');
2062
+ const body = h('div', 'modal-body');
2063
+ body.append(
2064
+ h('h2', null, '编辑器使用说明'),
2065
+ helpLine('Ctrl / ⌘ + S', '保存并热更新 OBS 页面'),
2066
+ helpLine('Ctrl / ⌘ + Z', '撤销;Shift + Ctrl / ⌘ + Z 重做'),
2067
+ helpLine('方向键', '移动选中的布局项 1 px;按住 Shift 移动 10 px'),
2068
+ helpLine('Delete / Backspace', '删除选中的布局实例(输入框内不会触发)'),
2069
+ helpLine('Mock 测试', '只在中央预览中填充选中组件;不会改写设计或真实公告'),
2070
+ helpLine('背景 浅 / 深', '切换透明组件背后的检查背景;该偏好不进入 OBS 设计'),
2071
+ helpLine('九宫格', '源图红线定义切片像素;输出宽度定义最终边框厚度。'),
2072
+ );
2073
+ const actions = h('div', 'modal-actions');
2074
+ actions.append(button('知道了', () => refs.modal.close(), 'small-button accent'));
2075
+ content.append(body, actions);
2076
+ showModal(content);
2077
+ }
2078
+
2079
+ function helpLine(key, text) {
2080
+ const line = h('div', 'field');
2081
+ line.append(h('span', 'row-badge', key), h('span', null, text));
2082
+ return line;
2083
+ }
2084
+
2085
+ function onKeyDown(event) {
2086
+ const typing = event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement;
2087
+ const command = event.ctrlKey || event.metaKey;
2088
+ if (command && event.key.toLowerCase() === 's') {
2089
+ event.preventDefault();
2090
+ void saveDesign();
2091
+ return;
2092
+ }
2093
+ if (!typing && command && event.key.toLowerCase() === 'z') {
2094
+ event.preventDefault();
2095
+ if (event.shiftKey) redo(); else undo();
2096
+ return;
2097
+ }
2098
+ if (typing || editor.mode !== 'layout') return;
2099
+ const placement = editor.design?.placements.find((item) => item.id === editor.selectedPlacement);
2100
+ if (!placement) return;
2101
+ if ((event.key === 'Delete' || event.key === 'Backspace') && !placement.locked) {
2102
+ event.preventDefault();
2103
+ deletePlacement(placement);
2104
+ return;
2105
+ }
2106
+ const delta = event.shiftKey ? 10 : 1;
2107
+ const moves = { ArrowLeft: [-delta, 0], ArrowRight: [delta, 0], ArrowUp: [0, -delta], ArrowDown: [0, delta] };
2108
+ const move = moves[event.key];
2109
+ if (!move || placement.locked) return;
2110
+ event.preventDefault();
2111
+ mutate(() => {
2112
+ placement.x = clamp(placement.x + move[0], 0, Math.max(0, editor.design.canvas.width - placement.width));
2113
+ placement.y = clamp(placement.y + move[1], 0, Math.max(0, editor.design.canvas.height - placement.height));
2114
+ }, { render: true });
2115
+ }
2116
+ })();