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.
- package/LICENSE +21 -0
- package/README.md +47 -0
- package/package.json +43 -0
- package/src/boot.ts +46 -0
- package/src/bot.ts +1342 -0
- package/src/config-file.ts +41 -0
- package/src/core/README.md +92 -0
- package/src/core/billing.ts +26 -0
- package/src/core/blobs.ts +106 -0
- package/src/core/bus.ts +311 -0
- package/src/core/config-schema.ts +201 -0
- package/src/core/config.ts +262 -0
- package/src/core/core.ts +699 -0
- package/src/core/cost.ts +244 -0
- package/src/core/event-store.ts +304 -0
- package/src/core/fork.ts +113 -0
- package/src/core/generation.ts +118 -0
- package/src/core/instance-lock.ts +215 -0
- package/src/core/ipc-logger.ts +105 -0
- package/src/core/language.ts +62 -0
- package/src/core/log-context.ts +38 -0
- package/src/core/loop.ts +1858 -0
- package/src/core/markers.ts +48 -0
- package/src/core/prefix.ts +121 -0
- package/src/core/run.ts +95 -0
- package/src/core/secrets.ts +18 -0
- package/src/core/session.ts +56 -0
- package/src/core/sessions.ts +261 -0
- package/src/core/state.ts +104 -0
- package/src/core/template.ts +72 -0
- package/src/core/timers.ts +129 -0
- package/src/core/tool-log.ts +135 -0
- package/src/core/transcript.ts +92 -0
- package/src/core/truncate.ts +121 -0
- package/src/core/types.ts +1140 -0
- package/src/core/usage-log.ts +79 -0
- package/src/core/util.ts +386 -0
- package/src/deploy-listing.ts +160 -0
- package/src/deploy.ts +162 -0
- package/src/extensions/README.md +72 -0
- package/src/extensions/dry-mount.ts +390 -0
- package/src/extensions/manifest.ts +153 -0
- package/src/extensions/runtime.ts +42 -0
- package/src/extensions.ts +519 -0
- package/src/launcher.ts +285 -0
- package/src/paths.ts +155 -0
- package/src/protocol/open-responses/LICENSE +201 -0
- package/src/protocol/open-responses/README.md +14 -0
- package/src/protocol/open-responses/context-helpers.ts +27 -0
- package/src/protocol/open-responses/context-log.ts +59 -0
- package/src/protocol/open-responses/context.ts +89 -0
- package/src/protocol/open-responses/generated.ts +115 -0
- package/src/protocol/open-responses/index.ts +33 -0
- package/src/protocol/open-responses/openapi.json +4230 -0
- package/src/protocol/open-responses/stream.ts +146 -0
- package/src/protocol/open-responses/tokens.ts +31 -0
- package/src/providers/README.md +86 -0
- package/src/providers/base.ts +111 -0
- package/src/providers/configuration.ts +97 -0
- package/src/providers/console/config.ts +21 -0
- package/src/providers/console/settings.ts +443 -0
- package/src/providers/console/strings.ts +74 -0
- package/src/providers/console/types.ts +11 -0
- package/src/providers/llamacpp/archive.ts +148 -0
- package/src/providers/llamacpp/catalog.ts +247 -0
- package/src/providers/llamacpp/config.ts +36 -0
- package/src/providers/llamacpp/console/client.ts +6 -0
- package/src/providers/llamacpp/console/models-panel.ts +120 -0
- package/src/providers/llamacpp/console/runtime-panel.ts +173 -0
- package/src/providers/llamacpp/console/server.ts +153 -0
- package/src/providers/llamacpp/index.ts +95 -0
- package/src/providers/llamacpp/native.ts +51 -0
- package/src/providers/llamacpp/options.ts +117 -0
- package/src/providers/llamacpp/runtime-store.ts +139 -0
- package/src/providers/llamacpp/runtime.ts +214 -0
- package/src/providers/llamacpp/server.ts +286 -0
- package/src/providers/llamacpp/strings.ts +230 -0
- package/src/providers/openai-responses-compat/config.ts +20 -0
- package/src/providers/openai-responses-compat/index.ts +89 -0
- package/src/providers/openai-responses-compat/native.ts +127 -0
- package/src/providers/openai-responses-compat/strings.ts +19 -0
- package/src/providers/pricebook.ts +75 -0
- package/src/providers/registry.ts +137 -0
- package/src/providers/strings.ts +71 -0
- package/src/providers/transport/chat.ts +156 -0
- package/src/providers/transport/errors.ts +48 -0
- package/src/providers/transport/history.ts +68 -0
- package/src/providers/transport/native-input.ts +83 -0
- package/src/providers/transport/native-types.ts +13 -0
- package/src/providers/transport/response-assembly.ts +191 -0
- package/src/providers/transport/response-http.ts +196 -0
- package/src/providers/transport/response-meters.ts +27 -0
- package/src/providers/transport/responses-input.ts +56 -0
- package/src/web/README.md +84 -0
- package/src/web/client/console-pages/builtins/llm-settings/panel.ts +625 -0
- package/src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts +168 -0
- package/src/web/client/console-pages/builtins/llm-settings/strings.ts +213 -0
- package/src/web/client/console-pages/builtins.ts +10 -0
- package/src/web/client/console-pages/context.ts +135 -0
- package/src/web/client/console-pages/host.ts +633 -0
- package/src/web/client/console-pages/loader.ts +132 -0
- package/src/web/client/console-pages/strings.ts +90 -0
- package/src/web/client/console-pages/tools/strings.ts +49 -0
- package/src/web/client/console-pages/tools/view.ts +215 -0
- package/src/web/client/core/api.ts +200 -0
- package/src/web/client/core/language.ts +56 -0
- package/src/web/client/core/lifecycle.ts +132 -0
- package/src/web/client/core/router.ts +199 -0
- package/src/web/client/core/stream.ts +157 -0
- package/src/web/client/core/websocket.ts +82 -0
- package/src/web/client/features/appearance/index.ts +333 -0
- package/src/web/client/features/appearance/strings.ts +87 -0
- package/src/web/client/features/config/index.ts +11 -0
- package/src/web/client/features/config/strings.ts +41 -0
- package/src/web/client/features/config/view.ts +428 -0
- package/src/web/client/features/core/events.ts +197 -0
- package/src/web/client/features/core/index.ts +254 -0
- package/src/web/client/features/core/run.ts +56 -0
- package/src/web/client/features/core/runlog.ts +214 -0
- package/src/web/client/features/core/sessions.ts +53 -0
- package/src/web/client/features/core/strings.ts +157 -0
- package/src/web/client/features/extensions/index.ts +422 -0
- package/src/web/client/features/extensions/strings.ts +164 -0
- package/src/web/client/features/feature.ts +93 -0
- package/src/web/client/features/live/context.ts +280 -0
- package/src/web/client/features/live/fork.ts +156 -0
- package/src/web/client/features/live/index.ts +392 -0
- package/src/web/client/features/live/onboarding.ts +113 -0
- package/src/web/client/features/live/protocol.ts +122 -0
- package/src/web/client/features/live/sessions.ts +71 -0
- package/src/web/client/features/live/status.ts +68 -0
- package/src/web/client/features/live/strings.ts +252 -0
- package/src/web/client/features/live/timeline.ts +590 -0
- package/src/web/client/features/prompts/editor.ts +292 -0
- package/src/web/client/features/prompts/index.ts +346 -0
- package/src/web/client/features/prompts/strings.ts +136 -0
- package/src/web/client/features/prompts/view.ts +256 -0
- package/src/web/client/features/providers/index.ts +100 -0
- package/src/web/client/features/providers/strings.ts +23 -0
- package/src/web/client/features/settings/general.ts +28 -0
- package/src/web/client/features/settings/index.ts +82 -0
- package/src/web/client/features/settings/strings.ts +33 -0
- package/src/web/client/features/storage/index.ts +1 -0
- package/src/web/client/features/storage/strings.ts +45 -0
- package/src/web/client/features/storage/view.ts +154 -0
- package/src/web/client/features/usage/chart.ts +486 -0
- package/src/web/client/features/usage/index.ts +407 -0
- package/src/web/client/features/usage/labels.ts +46 -0
- package/src/web/client/features/usage/range.ts +36 -0
- package/src/web/client/features/usage/state.ts +37 -0
- package/src/web/client/features/usage/strings.ts +191 -0
- package/src/web/client/features/usage/tooltip.ts +86 -0
- package/src/web/client/features/usage/types.ts +96 -0
- package/src/web/client/features/worlds/index.ts +424 -0
- package/src/web/client/features/worlds/strings.ts +121 -0
- package/src/web/client/main.ts +273 -0
- package/src/web/client/shell/avatar.ts +233 -0
- package/src/web/client/shell/index.ts +549 -0
- package/src/web/client/shell/strings.ts +89 -0
- package/src/web/client/theme/handoff.ts +43 -0
- package/src/web/client/theme/palette.ts +109 -0
- package/src/web/client/theme/registry.ts +130 -0
- package/src/web/client/theme/storage.ts +72 -0
- package/src/web/client/theme/strings.ts +123 -0
- package/src/web/client/theme/studio.ts +366 -0
- package/src/web/client/ui/actions.ts +109 -0
- package/src/web/client/ui/data.ts +185 -0
- package/src/web/client/ui/dom.ts +23 -0
- package/src/web/client/ui/fields.ts +191 -0
- package/src/web/client/ui/format.ts +75 -0
- package/src/web/client/ui/icons.ts +183 -0
- package/src/web/client/ui/images.ts +91 -0
- package/src/web/client/ui/index.ts +89 -0
- package/src/web/client/ui/lamp.ts +125 -0
- package/src/web/client/ui/log.ts +98 -0
- package/src/web/client/ui/overlay.ts +184 -0
- package/src/web/client/ui/page.ts +8 -0
- package/src/web/client/ui/prompt-input.tsx +311 -0
- package/src/web/client/ui/sheet.ts +131 -0
- package/src/web/client/ui/strings.ts +71 -0
- package/src/web/console-pages.ts +367 -0
- package/src/web/files.ts +107 -0
- package/src/web/path-picker.ts +296 -0
- package/src/web/public/index.html +17 -0
- package/src/web/public/styles.css +1715 -0
- package/src/web/server.ts +1883 -0
- package/src/web/shared/client-panel.ts +603 -0
- package/src/web/shared/console-protocol.ts +560 -0
- package/src/web/shared/css.d.ts +1 -0
- package/src/web/shared/path-picker.ts +23 -0
- package/src/web/shared/theme.ts +256 -0
- package/src/web/theme-store.ts +37 -0
- package/src/world.ts +449 -0
- package/src/worlds/bilibili/ENV_PROMPT.md +5 -0
- package/src/worlds/bilibili/README.md +176 -0
- package/src/worlds/bilibili/audience-admission.ts +995 -0
- package/src/worlds/bilibili/client.ts +528 -0
- package/src/worlds/bilibili/coalescing-buffer.ts +125 -0
- package/src/worlds/bilibili/config.ts +185 -0
- package/src/worlds/bilibili/console/client.ts +202 -0
- package/src/worlds/bilibili/definition.ts +35 -0
- package/src/worlds/bilibili/gift-frame.ts +131 -0
- package/src/worlds/bilibili/normalize.ts +452 -0
- package/src/worlds/bilibili/overlay/announcement.ts +76 -0
- package/src/worlds/bilibili/overlay/assets.ts +82 -0
- package/src/worlds/bilibili/overlay/model.ts +494 -0
- package/src/worlds/bilibili/overlay/project.ts +202 -0
- package/src/worlds/bilibili/overlay/server.ts +286 -0
- package/src/worlds/bilibili/overlay/types.ts +217 -0
- package/src/worlds/bilibili/overlay/web/app.js +690 -0
- package/src/worlds/bilibili/overlay/web/editor.css +297 -0
- package/src/worlds/bilibili/overlay/web/editor.html +102 -0
- package/src/worlds/bilibili/overlay/web/editor.js +2116 -0
- package/src/worlds/bilibili/overlay/web/overlay.html +13 -0
- package/src/worlds/bilibili/overlay/web/styles.css +258 -0
- package/src/worlds/bilibili/protobuf.ts +123 -0
- package/src/worlds/bilibili/wire.ts +94 -0
- package/src/worlds/bilibili/world.ts +1389 -0
- package/src/worlds/console-fixture/ENV_PROMPT.md +1 -0
- package/src/worlds/console-fixture/console/client.ts +72 -0
- package/src/worlds/console-fixture/world.ts +73 -0
- package/src/worlds/index.ts +15 -0
- package/src/worlds/minecraft/ENV_PROMPT.md +32 -0
- package/src/worlds/minecraft/ENV_PROMPT_CAMERA.md +2 -0
- package/src/worlds/minecraft/LICENSE-mineflayer-pathfinder.txt +21 -0
- package/src/worlds/minecraft/README.md +917 -0
- package/src/worlds/minecraft/blueprint-plan.ts +915 -0
- package/src/worlds/minecraft/blueprint-registry.ts +566 -0
- package/src/worlds/minecraft/blueprint-repair.ts +236 -0
- package/src/worlds/minecraft/blueprint-resource.ts +272 -0
- package/src/worlds/minecraft/blueprint.ts +492 -0
- package/src/worlds/minecraft/body-lease.ts +340 -0
- package/src/worlds/minecraft/bridge.ts +749 -0
- package/src/worlds/minecraft/check.ts +651 -0
- package/src/worlds/minecraft/chests.ts +292 -0
- package/src/worlds/minecraft/client-launch.ts +272 -0
- package/src/worlds/minecraft/client-options.ts +85 -0
- package/src/worlds/minecraft/client-skins.ts +164 -0
- package/src/worlds/minecraft/client-window.ps1 +91 -0
- package/src/worlds/minecraft/client.ts +423 -0
- package/src/worlds/minecraft/combat-context.ts +109 -0
- package/src/worlds/minecraft/combat.ts +1552 -0
- package/src/worlds/minecraft/config.ts +499 -0
- package/src/worlds/minecraft/console/access.ts +218 -0
- package/src/worlds/minecraft/console/client.ts +244 -0
- package/src/worlds/minecraft/console/log.ts +110 -0
- package/src/worlds/minecraft/console/mount.ts +420 -0
- package/src/worlds/minecraft/console/skin.ts +257 -0
- package/src/worlds/minecraft/console/style.css +154 -0
- package/src/worlds/minecraft/console/world.ts +449 -0
- package/src/worlds/minecraft/deaths.ts +135 -0
- package/src/worlds/minecraft/definition.ts +18 -0
- package/src/worlds/minecraft/engine-child.ts +274 -0
- package/src/worlds/minecraft/engine-ipc.ts +98 -0
- package/src/worlds/minecraft/entity-facts.ts +132 -0
- package/src/worlds/minecraft/escape.ts +407 -0
- package/src/worlds/minecraft/executor.ts +14494 -0
- package/src/worlds/minecraft/explored.ts +156 -0
- package/src/worlds/minecraft/geometry.ts +205 -0
- package/src/worlds/minecraft/goal-plan.ts +647 -0
- package/src/worlds/minecraft/item-break.ts +126 -0
- package/src/worlds/minecraft/item-facts.ts +146 -0
- package/src/worlds/minecraft/item-pick.ts +67 -0
- package/src/worlds/minecraft/level-dat.ts +176 -0
- package/src/worlds/minecraft/log.ts +132 -0
- package/src/worlds/minecraft/mineflayer-fixes.ts +1180 -0
- package/src/worlds/minecraft/names.ts +377 -0
- package/src/worlds/minecraft/pathfinder-lib.d.ts +13 -0
- package/src/worlds/minecraft/pathfinder-perf.ts +796 -0
- package/src/worlds/minecraft/piglin.ts +49 -0
- package/src/worlds/minecraft/policy.ts +547 -0
- package/src/worlds/minecraft/precheck.ts +655 -0
- package/src/worlds/minecraft/proxy.ts +445 -0
- package/src/worlds/minecraft/ranged.ts +635 -0
- package/src/worlds/minecraft/readouts.ts +102 -0
- package/src/worlds/minecraft/round.ts +26 -0
- package/src/worlds/minecraft/search-observation.ts +92 -0
- package/src/worlds/minecraft/server-config.ts +467 -0
- package/src/worlds/minecraft/server.ts +658 -0
- package/src/worlds/minecraft/show.ts +84 -0
- package/src/worlds/minecraft/skills.ts +1763 -0
- package/src/worlds/minecraft/terrain.ts +1735 -0
- package/src/worlds/minecraft/window.ts +96 -0
- package/src/worlds/minecraft/works.ts +179 -0
- package/src/worlds/minecraft/world.ts +6438 -0
- package/src/worlds/qq/ENV_PROMPT.md +5 -0
- package/src/worlds/qq/config.ts +108 -0
- package/src/worlds/qq/console/client.ts +126 -0
- package/src/worlds/qq/console/events.ts +155 -0
- package/src/worlds/qq/console/gate.ts +232 -0
- package/src/worlds/qq/console/roster.ts +203 -0
- package/src/worlds/qq/console/style.css +65 -0
- package/src/worlds/qq/conversation.ts +28 -0
- package/src/worlds/qq/definition.ts +62 -0
- package/src/worlds/qq/driver.ts +375 -0
- package/src/worlds/qq/history-tools.ts +209 -0
- package/src/worlds/qq/normalize.ts +323 -0
- package/src/worlds/qq/qface-map.ts +282 -0
- package/src/worlds/qq/vision-prompt.ts +27 -0
- package/src/worlds/qq/vision.ts +642 -0
- package/src/worlds/qq/vlm.ts +142 -0
- package/src/worlds/qq/world.ts +1503 -0
- package/src/worlds/terminal/ENV_PROMPT.md +11 -0
- package/src/worlds/terminal/config.ts +9 -0
- package/src/worlds/terminal/definition.ts +14 -0
- package/src/worlds/terminal/world.ts +700 -0
- package/src/worlds/websearch/ENV_PROMPT.md +1 -0
- package/src/worlds/websearch/brave-client.ts +261 -0
- package/src/worlds/websearch/config.ts +75 -0
- package/src/worlds/websearch/definition.ts +20 -0
- 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
|
+
})();
|