@hybridlabor-api/aos 4.6.6 → 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (484) hide show
  1. package/.agents/agents.md +1 -0
  2. package/.agents/nodes.json +5 -1
  3. package/.agents/state.schema.json +2 -1
  4. package/.agents/vendor-manifest.json +50 -0
  5. package/.claude/agents/architect.md +4 -0
  6. package/.claude/agents/security-reviewer.md +1 -1
  7. package/.claude/hooks/memb-inject.mjs +257 -135
  8. package/.claude/hooks/trail-relay.mjs +18 -0
  9. package/.claude/settings.json +70 -0
  10. package/.claude/workflows/startcycle-dispatch.mjs +29 -1
  11. package/.opencode/agents/architect.md +4 -0
  12. package/.opencode/plugins/bdb-aos.js +75 -0
  13. package/THIRD_PARTY_NOTICES.md +48 -0
  14. package/bin/aos-uninstall.mjs +55 -1
  15. package/docs/handover/agenttrail-for-ao.md +58 -0
  16. package/docs/handover/deja-for-ao.md +51 -0
  17. package/installer.js +141 -37
  18. package/mcp_config.json +4 -0
  19. package/mcps/mcsc/README.md +24 -0
  20. package/mcps/mcsc/package-lock.json +1234 -0
  21. package/mcps/mcsc/package.json +14 -0
  22. package/mcps/mcsc/packages/core/bin/mcsc.js +162 -0
  23. package/mcps/mcsc/packages/core/rulebook.default.yaml +49 -0
  24. package/mcps/mcsc/packages/core/schema/rulebook.schema.json +56 -0
  25. package/mcps/mcsc/packages/core/src/adapters/agy.js +107 -0
  26. package/mcps/mcsc/packages/core/src/adapters/codex.js +56 -0
  27. package/mcps/mcsc/packages/core/src/adapters/native.js +14 -0
  28. package/mcps/mcsc/packages/core/src/adapters/opencode.js +183 -0
  29. package/mcps/mcsc/packages/core/src/adapters/types.js +21 -0
  30. package/mcps/mcsc/packages/core/src/capabilities/buildOfferTable.js +34 -0
  31. package/mcps/mcsc/packages/core/src/capabilities/registry.js +99 -0
  32. package/mcps/mcsc/packages/core/src/capabilities/types.js +24 -0
  33. package/mcps/mcsc/packages/core/src/detect/cache.js +47 -0
  34. package/mcps/mcsc/packages/core/src/detect/inventory.js +73 -0
  35. package/mcps/mcsc/packages/core/src/detect/probes.js +158 -0
  36. package/mcps/mcsc/packages/core/src/detect/types.js +26 -0
  37. package/mcps/mcsc/packages/core/src/rulebook/load.js +53 -0
  38. package/mcps/mcsc/packages/core/src/rulebook/resolve.js +227 -0
  39. package/mcps/mcsc/packages/core/src/rulebook/rulebook.types.js +89 -0
  40. package/mcps/mcsc/packages/core/src/trail.js +17 -0
  41. package/mcps/mcsc/packages/mcp/server.js +302 -0
  42. package/mcps/mcsc/server.js +2 -0
  43. package/package.json +3 -1
  44. package/skills/basic/bdbmediastorm/SKILL.md +1 -1
  45. package/skills/basic/godmode-ui-ux/SKILL.md +2 -2
  46. package/skills/basic/godmode-ui-ux/references/anti-slop-rules.md +44 -40
  47. package/skills/basic/godmode-ui-ux/references/taste-archetypes.md +49 -0
  48. package/skills/basic/startcycle/SKILL.md +7 -0
  49. package/skills/basic/startcycle-graph/SKILL.md +12 -0
  50. package/skills/basic/startcycle-graph-user/SKILL.md +3 -0
  51. package/skills/basic/teamwork-preview/SKILL.md +2 -0
  52. package/skills/bdbrainstorm/SKILL.md +1 -0
  53. package/skills/global_config/agenttrail/LICENSE +21 -0
  54. package/skills/global_config/agenttrail/SKILL.md +48 -0
  55. package/skills/global_config/agenttrail/bin/agenttrail.mjs +1002 -0
  56. package/skills/global_config/agenttrail/public/index.html +661 -0
  57. package/skills/global_config/aos-project-init/SKILL.md +25 -5
  58. package/skills/global_config/aos-setup/SKILL.md +34 -1
  59. package/skills/global_config/aos-setup/scripts/aos-doctor.mjs +115 -1
  60. package/skills/global_config/archify/LICENSE +22 -0
  61. package/skills/global_config/archify/SKILL.md +140 -0
  62. package/skills/global_config/archify/THIRD_PARTY_NOTICES.md +69 -0
  63. package/skills/global_config/archify/assets/JetBrainsMono-OFL.txt +93 -0
  64. package/skills/global_config/archify/assets/template.html +14937 -0
  65. package/skills/global_config/archify/bin/archify.mjs +2139 -0
  66. package/skills/global_config/archify/bin/open-artifact.mjs +86 -0
  67. package/skills/global_config/archify/bin/preview.mjs +653 -0
  68. package/skills/global_config/archify/bin/visual-check.mjs +829 -0
  69. package/skills/global_config/archify/brand-marks/README.md +31 -0
  70. package/skills/global_config/archify/brand-marks/catalog.json +131 -0
  71. package/skills/global_config/archify/delta/architecture-delta.mjs +1232 -0
  72. package/skills/global_config/archify/examples/agent-run.lifecycle.json +60 -0
  73. package/skills/global_config/archify/examples/agent-tool-call.workflow.json +94 -0
  74. package/skills/global_config/archify/examples/async-job-roundtrip.sequence.json +61 -0
  75. package/skills/global_config/archify/examples/brand-aware-delivery.architecture.json +47 -0
  76. package/skills/global_config/archify/examples/cache-miss-request.sequence.json +82 -0
  77. package/skills/global_config/archify/examples/checkout-platform.base.architecture.json +31 -0
  78. package/skills/global_config/archify/examples/checkout-platform.head.architecture.json +31 -0
  79. package/skills/global_config/archify/examples/deployment-release.lifecycle.json +49 -0
  80. package/skills/global_config/archify/examples/event-stream.dataflow.json +57 -0
  81. package/skills/global_config/archify/examples/incident-response.workflow.json +64 -0
  82. package/skills/global_config/archify/examples/product-analytics.dataflow.json +76 -0
  83. package/skills/global_config/archify/examples/production-deployment.architecture.json +71 -0
  84. package/skills/global_config/archify/examples/release-delivery.workflow.json +62 -0
  85. package/skills/global_config/archify/examples/web-app.architecture.json +46 -0
  86. package/skills/global_config/archify/migrations/workflow-v2.mjs +279 -0
  87. package/skills/global_config/archify/package.json +39 -0
  88. package/skills/global_config/archify/recipes/scenarios.mjs +391 -0
  89. package/skills/global_config/archify/references/authoring-contract.md +247 -0
  90. package/skills/global_config/archify/references/brand-marks.md +65 -0
  91. package/skills/global_config/archify/references/delivery-contract.md +138 -0
  92. package/skills/global_config/archify/references/viewer-runtime.md +45 -0
  93. package/skills/global_config/archify/renderers/architecture/grid.mjs +62 -0
  94. package/skills/global_config/archify/renderers/architecture/render-architecture.mjs +1078 -0
  95. package/skills/global_config/archify/renderers/dataflow/README.md +104 -0
  96. package/skills/global_config/archify/renderers/dataflow/render-dataflow.mjs +483 -0
  97. package/skills/global_config/archify/renderers/lifecycle/README.md +115 -0
  98. package/skills/global_config/archify/renderers/lifecycle/render-lifecycle.mjs +561 -0
  99. package/skills/global_config/archify/renderers/sequence/README.md +114 -0
  100. package/skills/global_config/archify/renderers/sequence/render-sequence.mjs +464 -0
  101. package/skills/global_config/archify/renderers/shared/brand-marks.mjs +563 -0
  102. package/skills/global_config/archify/renderers/shared/cli.mjs +218 -0
  103. package/skills/global_config/archify/renderers/shared/desktop-readability.mjs +26 -0
  104. package/skills/global_config/archify/renderers/shared/diagnostics.mjs +160 -0
  105. package/skills/global_config/archify/renderers/shared/engineering-profiles.mjs +157 -0
  106. package/skills/global_config/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  107. package/skills/global_config/archify/renderers/shared/generated-validators.mjs +13 -0
  108. package/skills/global_config/archify/renderers/shared/geometry.mjs +1423 -0
  109. package/skills/global_config/archify/renderers/shared/i18n.mjs +595 -0
  110. package/skills/global_config/archify/renderers/shared/layout-report.mjs +40 -0
  111. package/skills/global_config/archify/renderers/shared/legend.mjs +217 -0
  112. package/skills/global_config/archify/renderers/shared/output-path.mjs +340 -0
  113. package/skills/global_config/archify/renderers/shared/repository-evidence.mjs +239 -0
  114. package/skills/global_config/archify/renderers/shared/repository-location.mjs +58 -0
  115. package/skills/global_config/archify/renderers/shared/text-fit.mjs +49 -0
  116. package/skills/global_config/archify/renderers/shared/utils.mjs +232 -0
  117. package/skills/global_config/archify/renderers/shared/validator.mjs +86 -0
  118. package/skills/global_config/archify/renderers/workflow/README.md +223 -0
  119. package/skills/global_config/archify/renderers/workflow/render-workflow.mjs +35 -0
  120. package/skills/global_config/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
  121. package/skills/global_config/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
  122. package/skills/global_config/archify/schemas/README.md +211 -0
  123. package/skills/global_config/archify/schemas/architecture.schema.json +178 -0
  124. package/skills/global_config/archify/schemas/common.schema.json +115 -0
  125. package/skills/global_config/archify/schemas/dataflow.schema.json +243 -0
  126. package/skills/global_config/archify/schemas/lifecycle.schema.json +266 -0
  127. package/skills/global_config/archify/schemas/sequence.schema.json +223 -0
  128. package/skills/global_config/archify/schemas/workflow.schema.json +428 -0
  129. package/skills/global_config/archify/scripts/check-render-output.mjs +916 -0
  130. package/skills/global_config/archify/scripts/generate-brand-marks.mjs +141 -0
  131. package/skills/global_config/archify/scripts/generate-validators.mjs +66 -0
  132. package/skills/global_config/archify/scripts/render-examples.mjs +26 -0
  133. package/skills/global_config/archify/skill-release.json +10 -0
  134. package/skills/global_config/bdb-security-audit/SKILL.md +109 -0
  135. package/skills/global_config/bdb-security-audit/references/audit-checklists.md +172 -0
  136. package/skills/global_config/bdb-security-audit/references/skylos-guide.md +139 -0
  137. package/skills/global_config/bdb-security-audit/references/xalgorix-bridge.md +110 -0
  138. package/skills/global_config/bdbdesignpro/SKILL.md +4 -4
  139. package/skills/global_config/bdbdesignpro/references/bdb-motion-tokens.md +52 -31
  140. package/skills/global_config/bdbresilience/SKILL.md +10 -0
  141. package/skills/global_config/brag/SKILL.md +179 -0
  142. package/skills/global_config/brag/assets/music/README.md +20 -0
  143. package/skills/global_config/brag/assets/music/cues/happy-beats-business-moves-vol-1-by-ende-dot-app.music-cues.json +3846 -0
  144. package/skills/global_config/brag/assets/music/cues/happy-beats-business-moves-vol-1-by-ende-dot-app.music-cues.md +35 -0
  145. package/skills/global_config/brag/assets/music/cues/happy-beats-business-moves-vol-10-by-ende-dot-app.music-cues.json +1826 -0
  146. package/skills/global_config/brag/assets/music/cues/happy-beats-business-moves-vol-10-by-ende-dot-app.music-cues.md +35 -0
  147. package/skills/global_config/brag/assets/music/cues/happy-beats-business-moves-vol-11-by-ende-dot-app.music-cues.json +2266 -0
  148. package/skills/global_config/brag/assets/music/cues/happy-beats-business-moves-vol-11-by-ende-dot-app.music-cues.md +35 -0
  149. package/skills/global_config/brag/assets/music/cues/happy-beats-business-moves-vol-12-by-ende-dot-app.music-cues.json +2806 -0
  150. package/skills/global_config/brag/assets/music/cues/happy-beats-business-moves-vol-12-by-ende-dot-app.music-cues.md +35 -0
  151. package/skills/global_config/brag/assets/music/cues/happy-beats-business-moves-vol-9-by-ende-dot-app.music-cues.json +2796 -0
  152. package/skills/global_config/brag/assets/music/cues/happy-beats-business-moves-vol-9-by-ende-dot-app.music-cues.md +35 -0
  153. package/skills/global_config/brag/assets/music/happy-beats-business-moves-vol-1-by-ende-dot-app.mp3 +0 -0
  154. package/skills/global_config/brag/assets/music/happy-beats-business-moves-vol-10-by-ende-dot-app.mp3 +0 -0
  155. package/skills/global_config/brag/assets/music/happy-beats-business-moves-vol-11-by-ende-dot-app.mp3 +0 -0
  156. package/skills/global_config/brag/assets/music/happy-beats-business-moves-vol-12-by-ende-dot-app.mp3 +0 -0
  157. package/skills/global_config/brag/assets/music/happy-beats-business-moves-vol-9-by-ende-dot-app.mp3 +0 -0
  158. package/skills/global_config/brag/assets/sfx/casino/card-fan-1.ogg +0 -0
  159. package/skills/global_config/brag/assets/sfx/casino/card-fan-2.ogg +0 -0
  160. package/skills/global_config/brag/assets/sfx/casino/card-place-1.ogg +0 -0
  161. package/skills/global_config/brag/assets/sfx/casino/card-place-2.ogg +0 -0
  162. package/skills/global_config/brag/assets/sfx/casino/card-place-3.ogg +0 -0
  163. package/skills/global_config/brag/assets/sfx/casino/card-place-4.ogg +0 -0
  164. package/skills/global_config/brag/assets/sfx/casino/card-shove-1.ogg +0 -0
  165. package/skills/global_config/brag/assets/sfx/casino/card-shove-2.ogg +0 -0
  166. package/skills/global_config/brag/assets/sfx/casino/card-shove-3.ogg +0 -0
  167. package/skills/global_config/brag/assets/sfx/casino/card-shove-4.ogg +0 -0
  168. package/skills/global_config/brag/assets/sfx/casino/card-shuffle.ogg +0 -0
  169. package/skills/global_config/brag/assets/sfx/casino/card-slide-1.ogg +0 -0
  170. package/skills/global_config/brag/assets/sfx/casino/card-slide-2.ogg +0 -0
  171. package/skills/global_config/brag/assets/sfx/casino/card-slide-3.ogg +0 -0
  172. package/skills/global_config/brag/assets/sfx/casino/card-slide-4.ogg +0 -0
  173. package/skills/global_config/brag/assets/sfx/casino/card-slide-5.ogg +0 -0
  174. package/skills/global_config/brag/assets/sfx/casino/card-slide-6.ogg +0 -0
  175. package/skills/global_config/brag/assets/sfx/casino/card-slide-7.ogg +0 -0
  176. package/skills/global_config/brag/assets/sfx/casino/card-slide-8.ogg +0 -0
  177. package/skills/global_config/brag/assets/sfx/casino/cards-pack-open-1.ogg +0 -0
  178. package/skills/global_config/brag/assets/sfx/casino/cards-pack-open-2.ogg +0 -0
  179. package/skills/global_config/brag/assets/sfx/casino/chip-lay-1.ogg +0 -0
  180. package/skills/global_config/brag/assets/sfx/casino/chip-lay-2.ogg +0 -0
  181. package/skills/global_config/brag/assets/sfx/casino/chip-lay-3.ogg +0 -0
  182. package/skills/global_config/brag/assets/sfx/casino/chips-collide-1.ogg +0 -0
  183. package/skills/global_config/brag/assets/sfx/casino/chips-collide-2.ogg +0 -0
  184. package/skills/global_config/brag/assets/sfx/casino/chips-collide-3.ogg +0 -0
  185. package/skills/global_config/brag/assets/sfx/casino/chips-collide-4.ogg +0 -0
  186. package/skills/global_config/brag/assets/sfx/casino/chips-handle-1.ogg +0 -0
  187. package/skills/global_config/brag/assets/sfx/casino/chips-handle-2.ogg +0 -0
  188. package/skills/global_config/brag/assets/sfx/casino/chips-handle-3.ogg +0 -0
  189. package/skills/global_config/brag/assets/sfx/casino/chips-handle-4.ogg +0 -0
  190. package/skills/global_config/brag/assets/sfx/casino/chips-handle-6.ogg +0 -0
  191. package/skills/global_config/brag/assets/sfx/casino/chips-stack-1.ogg +0 -0
  192. package/skills/global_config/brag/assets/sfx/casino/chips-stack-2.ogg +0 -0
  193. package/skills/global_config/brag/assets/sfx/casino/chips-stack-3.ogg +0 -0
  194. package/skills/global_config/brag/assets/sfx/casino/chips-stack-4.ogg +0 -0
  195. package/skills/global_config/brag/assets/sfx/casino/chips-stack-5.ogg +0 -0
  196. package/skills/global_config/brag/assets/sfx/casino/chips-stack-6.ogg +0 -0
  197. package/skills/global_config/brag/assets/sfx/casino/dice-grab-1.ogg +0 -0
  198. package/skills/global_config/brag/assets/sfx/casino/dice-grab-2.ogg +0 -0
  199. package/skills/global_config/brag/assets/sfx/casino/dice-shake-1.ogg +0 -0
  200. package/skills/global_config/brag/assets/sfx/casino/dice-shake-2.ogg +0 -0
  201. package/skills/global_config/brag/assets/sfx/casino/dice-shake-3.ogg +0 -0
  202. package/skills/global_config/brag/assets/sfx/casino/dice-throw-1.ogg +0 -0
  203. package/skills/global_config/brag/assets/sfx/casino/dice-throw-2.ogg +0 -0
  204. package/skills/global_config/brag/assets/sfx/casino/dice-throw-3.ogg +0 -0
  205. package/skills/global_config/brag/assets/sfx/casino/die-throw-1.ogg +0 -0
  206. package/skills/global_config/brag/assets/sfx/casino/die-throw-2.ogg +0 -0
  207. package/skills/global_config/brag/assets/sfx/casino/die-throw-3.ogg +0 -0
  208. package/skills/global_config/brag/assets/sfx/casino/die-throw-4.ogg +0 -0
  209. package/skills/global_config/brag/assets/sfx/impact/footstep_carpet_000.ogg +0 -0
  210. package/skills/global_config/brag/assets/sfx/impact/footstep_carpet_003.ogg +0 -0
  211. package/skills/global_config/brag/assets/sfx/impact/footstep_carpet_004.ogg +0 -0
  212. package/skills/global_config/brag/assets/sfx/impact/footstep_concrete_000.ogg +0 -0
  213. package/skills/global_config/brag/assets/sfx/impact/footstep_concrete_001.ogg +0 -0
  214. package/skills/global_config/brag/assets/sfx/impact/footstep_concrete_002.ogg +0 -0
  215. package/skills/global_config/brag/assets/sfx/impact/footstep_concrete_003.ogg +0 -0
  216. package/skills/global_config/brag/assets/sfx/impact/footstep_concrete_004.ogg +0 -0
  217. package/skills/global_config/brag/assets/sfx/impact/footstep_grass_000.ogg +0 -0
  218. package/skills/global_config/brag/assets/sfx/impact/footstep_grass_001.ogg +0 -0
  219. package/skills/global_config/brag/assets/sfx/impact/footstep_grass_002.ogg +0 -0
  220. package/skills/global_config/brag/assets/sfx/impact/footstep_grass_003.ogg +0 -0
  221. package/skills/global_config/brag/assets/sfx/impact/footstep_grass_004.ogg +0 -0
  222. package/skills/global_config/brag/assets/sfx/impact/footstep_snow_000.ogg +0 -0
  223. package/skills/global_config/brag/assets/sfx/impact/footstep_snow_001.ogg +0 -0
  224. package/skills/global_config/brag/assets/sfx/impact/footstep_snow_002.ogg +0 -0
  225. package/skills/global_config/brag/assets/sfx/impact/footstep_snow_003.ogg +0 -0
  226. package/skills/global_config/brag/assets/sfx/impact/footstep_snow_004.ogg +0 -0
  227. package/skills/global_config/brag/assets/sfx/impact/footstep_wood_000.ogg +0 -0
  228. package/skills/global_config/brag/assets/sfx/impact/footstep_wood_001.ogg +0 -0
  229. package/skills/global_config/brag/assets/sfx/impact/footstep_wood_002.ogg +0 -0
  230. package/skills/global_config/brag/assets/sfx/impact/footstep_wood_003.ogg +0 -0
  231. package/skills/global_config/brag/assets/sfx/impact/footstep_wood_004.ogg +0 -0
  232. package/skills/global_config/brag/assets/sfx/impact/impactBell_heavy_000.ogg +0 -0
  233. package/skills/global_config/brag/assets/sfx/impact/impactBell_heavy_003.ogg +0 -0
  234. package/skills/global_config/brag/assets/sfx/impact/impactBell_heavy_004.ogg +0 -0
  235. package/skills/global_config/brag/assets/sfx/impact/impactGeneric_light_000.ogg +0 -0
  236. package/skills/global_config/brag/assets/sfx/impact/impactGeneric_light_001.ogg +0 -0
  237. package/skills/global_config/brag/assets/sfx/impact/impactGeneric_light_002.ogg +0 -0
  238. package/skills/global_config/brag/assets/sfx/impact/impactGeneric_light_003.ogg +0 -0
  239. package/skills/global_config/brag/assets/sfx/impact/impactGeneric_light_004.ogg +0 -0
  240. package/skills/global_config/brag/assets/sfx/impact/impactGlass_heavy_002.ogg +0 -0
  241. package/skills/global_config/brag/assets/sfx/impact/impactGlass_light_001.ogg +0 -0
  242. package/skills/global_config/brag/assets/sfx/impact/impactGlass_light_002.ogg +0 -0
  243. package/skills/global_config/brag/assets/sfx/impact/impactGlass_light_003.ogg +0 -0
  244. package/skills/global_config/brag/assets/sfx/impact/impactGlass_medium_000.ogg +0 -0
  245. package/skills/global_config/brag/assets/sfx/impact/impactGlass_medium_002.ogg +0 -0
  246. package/skills/global_config/brag/assets/sfx/impact/impactGlass_medium_004.ogg +0 -0
  247. package/skills/global_config/brag/assets/sfx/impact/impactMetal_heavy_000.ogg +0 -0
  248. package/skills/global_config/brag/assets/sfx/impact/impactMetal_heavy_002.ogg +0 -0
  249. package/skills/global_config/brag/assets/sfx/impact/impactMetal_heavy_004.ogg +0 -0
  250. package/skills/global_config/brag/assets/sfx/impact/impactMetal_light_002.ogg +0 -0
  251. package/skills/global_config/brag/assets/sfx/impact/impactMetal_light_003.ogg +0 -0
  252. package/skills/global_config/brag/assets/sfx/impact/impactMetal_medium_000.ogg +0 -0
  253. package/skills/global_config/brag/assets/sfx/impact/impactMetal_medium_001.ogg +0 -0
  254. package/skills/global_config/brag/assets/sfx/impact/impactMetal_medium_002.ogg +0 -0
  255. package/skills/global_config/brag/assets/sfx/impact/impactMetal_medium_003.ogg +0 -0
  256. package/skills/global_config/brag/assets/sfx/impact/impactMetal_medium_004.ogg +0 -0
  257. package/skills/global_config/brag/assets/sfx/impact/impactMining_001.ogg +0 -0
  258. package/skills/global_config/brag/assets/sfx/impact/impactPlank_medium_000.ogg +0 -0
  259. package/skills/global_config/brag/assets/sfx/impact/impactPlank_medium_001.ogg +0 -0
  260. package/skills/global_config/brag/assets/sfx/impact/impactPlank_medium_002.ogg +0 -0
  261. package/skills/global_config/brag/assets/sfx/impact/impactPlank_medium_003.ogg +0 -0
  262. package/skills/global_config/brag/assets/sfx/impact/impactPlank_medium_004.ogg +0 -0
  263. package/skills/global_config/brag/assets/sfx/impact/impactPlate_heavy_000.ogg +0 -0
  264. package/skills/global_config/brag/assets/sfx/impact/impactPlate_heavy_001.ogg +0 -0
  265. package/skills/global_config/brag/assets/sfx/impact/impactPlate_heavy_002.ogg +0 -0
  266. package/skills/global_config/brag/assets/sfx/impact/impactPlate_heavy_003.ogg +0 -0
  267. package/skills/global_config/brag/assets/sfx/impact/impactPlate_heavy_004.ogg +0 -0
  268. package/skills/global_config/brag/assets/sfx/impact/impactPlate_light_000.ogg +0 -0
  269. package/skills/global_config/brag/assets/sfx/impact/impactPlate_light_001.ogg +0 -0
  270. package/skills/global_config/brag/assets/sfx/impact/impactPlate_light_002.ogg +0 -0
  271. package/skills/global_config/brag/assets/sfx/impact/impactPlate_light_003.ogg +0 -0
  272. package/skills/global_config/brag/assets/sfx/impact/impactPlate_light_004.ogg +0 -0
  273. package/skills/global_config/brag/assets/sfx/impact/impactPlate_medium_000.ogg +0 -0
  274. package/skills/global_config/brag/assets/sfx/impact/impactPlate_medium_001.ogg +0 -0
  275. package/skills/global_config/brag/assets/sfx/impact/impactPlate_medium_002.ogg +0 -0
  276. package/skills/global_config/brag/assets/sfx/impact/impactPlate_medium_003.ogg +0 -0
  277. package/skills/global_config/brag/assets/sfx/impact/impactPlate_medium_004.ogg +0 -0
  278. package/skills/global_config/brag/assets/sfx/impact/impactPunch_heavy_000.ogg +0 -0
  279. package/skills/global_config/brag/assets/sfx/impact/impactPunch_heavy_001.ogg +0 -0
  280. package/skills/global_config/brag/assets/sfx/impact/impactPunch_heavy_002.ogg +0 -0
  281. package/skills/global_config/brag/assets/sfx/impact/impactPunch_heavy_003.ogg +0 -0
  282. package/skills/global_config/brag/assets/sfx/impact/impactPunch_heavy_004.ogg +0 -0
  283. package/skills/global_config/brag/assets/sfx/impact/impactPunch_medium_000.ogg +0 -0
  284. package/skills/global_config/brag/assets/sfx/impact/impactPunch_medium_001.ogg +0 -0
  285. package/skills/global_config/brag/assets/sfx/impact/impactPunch_medium_002.ogg +0 -0
  286. package/skills/global_config/brag/assets/sfx/impact/impactPunch_medium_003.ogg +0 -0
  287. package/skills/global_config/brag/assets/sfx/impact/impactPunch_medium_004.ogg +0 -0
  288. package/skills/global_config/brag/assets/sfx/impact/impactSoft_heavy_000.ogg +0 -0
  289. package/skills/global_config/brag/assets/sfx/impact/impactSoft_heavy_001.ogg +0 -0
  290. package/skills/global_config/brag/assets/sfx/impact/impactSoft_heavy_002.ogg +0 -0
  291. package/skills/global_config/brag/assets/sfx/impact/impactSoft_heavy_003.ogg +0 -0
  292. package/skills/global_config/brag/assets/sfx/impact/impactSoft_heavy_004.ogg +0 -0
  293. package/skills/global_config/brag/assets/sfx/impact/impactSoft_medium_000.ogg +0 -0
  294. package/skills/global_config/brag/assets/sfx/impact/impactSoft_medium_001.ogg +0 -0
  295. package/skills/global_config/brag/assets/sfx/impact/impactSoft_medium_002.ogg +0 -0
  296. package/skills/global_config/brag/assets/sfx/impact/impactSoft_medium_003.ogg +0 -0
  297. package/skills/global_config/brag/assets/sfx/impact/impactSoft_medium_004.ogg +0 -0
  298. package/skills/global_config/brag/assets/sfx/impact/impactTin_medium_000.ogg +0 -0
  299. package/skills/global_config/brag/assets/sfx/impact/impactTin_medium_001.ogg +0 -0
  300. package/skills/global_config/brag/assets/sfx/impact/impactTin_medium_002.ogg +0 -0
  301. package/skills/global_config/brag/assets/sfx/impact/impactTin_medium_003.ogg +0 -0
  302. package/skills/global_config/brag/assets/sfx/impact/impactTin_medium_004.ogg +0 -0
  303. package/skills/global_config/brag/assets/sfx/impact/impactWood_heavy_000.ogg +0 -0
  304. package/skills/global_config/brag/assets/sfx/impact/impactWood_heavy_001.ogg +0 -0
  305. package/skills/global_config/brag/assets/sfx/impact/impactWood_heavy_002.ogg +0 -0
  306. package/skills/global_config/brag/assets/sfx/impact/impactWood_heavy_003.ogg +0 -0
  307. package/skills/global_config/brag/assets/sfx/impact/impactWood_heavy_004.ogg +0 -0
  308. package/skills/global_config/brag/assets/sfx/impact/impactWood_light_000.ogg +0 -0
  309. package/skills/global_config/brag/assets/sfx/impact/impactWood_light_001.ogg +0 -0
  310. package/skills/global_config/brag/assets/sfx/impact/impactWood_light_002.ogg +0 -0
  311. package/skills/global_config/brag/assets/sfx/impact/impactWood_light_003.ogg +0 -0
  312. package/skills/global_config/brag/assets/sfx/impact/impactWood_light_004.ogg +0 -0
  313. package/skills/global_config/brag/assets/sfx/impact/impactWood_medium_000.ogg +0 -0
  314. package/skills/global_config/brag/assets/sfx/impact/impactWood_medium_001.ogg +0 -0
  315. package/skills/global_config/brag/assets/sfx/impact/impactWood_medium_002.ogg +0 -0
  316. package/skills/global_config/brag/assets/sfx/impact/impactWood_medium_003.ogg +0 -0
  317. package/skills/global_config/brag/assets/sfx/impact/impactWood_medium_004.ogg +0 -0
  318. package/skills/global_config/brag/assets/sfx/interface/bong_001.ogg +0 -0
  319. package/skills/global_config/brag/assets/sfx/interface/click_001.ogg +0 -0
  320. package/skills/global_config/brag/assets/sfx/interface/click_002.ogg +0 -0
  321. package/skills/global_config/brag/assets/sfx/interface/click_003.ogg +0 -0
  322. package/skills/global_config/brag/assets/sfx/interface/click_004.ogg +0 -0
  323. package/skills/global_config/brag/assets/sfx/interface/click_005.ogg +0 -0
  324. package/skills/global_config/brag/assets/sfx/interface/drop_001.ogg +0 -0
  325. package/skills/global_config/brag/assets/sfx/interface/drop_002.ogg +0 -0
  326. package/skills/global_config/brag/assets/sfx/interface/drop_003.ogg +0 -0
  327. package/skills/global_config/brag/assets/sfx/interface/error_005.ogg +0 -0
  328. package/skills/global_config/brag/assets/sfx/interface/error_006.ogg +0 -0
  329. package/skills/global_config/brag/assets/sfx/interface/glitch_002.ogg +0 -0
  330. package/skills/global_config/brag/assets/sfx/interface/glitch_004.ogg +0 -0
  331. package/skills/global_config/brag/assets/sfx/interface/select_008.ogg +0 -0
  332. package/skills/global_config/brag/assets/sfx/interface/switch_001.ogg +0 -0
  333. package/skills/global_config/brag/assets/sfx/interface/switch_002.ogg +0 -0
  334. package/skills/global_config/brag/assets/sfx/interface/switch_004.ogg +0 -0
  335. package/skills/global_config/brag/assets/sfx/interface/switch_005.ogg +0 -0
  336. package/skills/global_config/brag/assets/sfx/interface/switch_006.ogg +0 -0
  337. package/skills/global_config/brag/assets/sfx/interface/switch_007.ogg +0 -0
  338. package/skills/global_config/brag/assets/sfx/keyboard/keypress-001.wav +0 -0
  339. package/skills/global_config/brag/assets/sfx/keyboard/keypress-002.wav +0 -0
  340. package/skills/global_config/brag/assets/sfx/keyboard/keypress-003.wav +0 -0
  341. package/skills/global_config/brag/assets/sfx/keyboard/keypress-004.wav +0 -0
  342. package/skills/global_config/brag/assets/sfx/keyboard/keypress-005.wav +0 -0
  343. package/skills/global_config/brag/assets/sfx/keyboard/keypress-006.wav +0 -0
  344. package/skills/global_config/brag/assets/sfx/keyboard/keypress-007.wav +0 -0
  345. package/skills/global_config/brag/assets/sfx/keyboard/keypress-008.wav +0 -0
  346. package/skills/global_config/brag/assets/sfx/keyboard/keypress-009.wav +0 -0
  347. package/skills/global_config/brag/assets/sfx/keyboard/keypress-010.wav +0 -0
  348. package/skills/global_config/brag/assets/sfx/keyboard/keypress-011.wav +0 -0
  349. package/skills/global_config/brag/assets/sfx/keyboard/keypress-012.wav +0 -0
  350. package/skills/global_config/brag/assets/sfx/keyboard/keypress-013.wav +0 -0
  351. package/skills/global_config/brag/assets/sfx/keyboard/keypress-014.wav +0 -0
  352. package/skills/global_config/brag/assets/sfx/keyboard/keypress-015.wav +0 -0
  353. package/skills/global_config/brag/assets/sfx/keyboard/keypress-016.wav +0 -0
  354. package/skills/global_config/brag/assets/sfx/keyboard/keypress-017.wav +0 -0
  355. package/skills/global_config/brag/assets/sfx/keyboard/keypress-018.wav +0 -0
  356. package/skills/global_config/brag/assets/sfx/keyboard/keypress-019.wav +0 -0
  357. package/skills/global_config/brag/assets/sfx/keyboard/keypress-020.wav +0 -0
  358. package/skills/global_config/brag/assets/sfx/keyboard/keypress-021.wav +0 -0
  359. package/skills/global_config/brag/assets/sfx/keyboard/keypress-022.wav +0 -0
  360. package/skills/global_config/brag/assets/sfx/keyboard/keypress-023.wav +0 -0
  361. package/skills/global_config/brag/assets/sfx/keyboard/keypress-024.wav +0 -0
  362. package/skills/global_config/brag/assets/sfx/keyboard/keypress-025.wav +0 -0
  363. package/skills/global_config/brag/assets/sfx/keyboard/keypress-026.wav +0 -0
  364. package/skills/global_config/brag/assets/sfx/keyboard/keypress-027.wav +0 -0
  365. package/skills/global_config/brag/assets/sfx/keyboard/keypress-028.wav +0 -0
  366. package/skills/global_config/brag/assets/sfx/keyboard/keypress-029.wav +0 -0
  367. package/skills/global_config/brag/assets/sfx/keyboard/keypress-030.wav +0 -0
  368. package/skills/global_config/brag/assets/sfx/keyboard/keypress-031.wav +0 -0
  369. package/skills/global_config/brag/assets/sfx/keyboard/keypress-032.wav +0 -0
  370. package/skills/global_config/brag/assets/sfx/sfx-analysis.json +15584 -0
  371. package/skills/global_config/brag/assets/sfx/sfx-analysis.md +75 -0
  372. package/skills/global_config/brag/assets/sfx/ui/click1.ogg +0 -0
  373. package/skills/global_config/brag/assets/sfx/ui/click2.ogg +0 -0
  374. package/skills/global_config/brag/assets/sfx/ui/click3.ogg +0 -0
  375. package/skills/global_config/brag/assets/sfx/ui/click4.ogg +0 -0
  376. package/skills/global_config/brag/assets/sfx/ui/click5.ogg +0 -0
  377. package/skills/global_config/brag/assets/sfx/ui/mouseclick1.ogg +0 -0
  378. package/skills/global_config/brag/assets/sfx/ui/rollover1.ogg +0 -0
  379. package/skills/global_config/brag/assets/sfx/ui/rollover2.ogg +0 -0
  380. package/skills/global_config/brag/assets/sfx/ui/rollover4.ogg +0 -0
  381. package/skills/global_config/brag/assets/sfx/ui/rollover5.ogg +0 -0
  382. package/skills/global_config/brag/assets/sfx/ui/switch1.ogg +0 -0
  383. package/skills/global_config/brag/assets/sfx/ui/switch10.ogg +0 -0
  384. package/skills/global_config/brag/assets/sfx/ui/switch11.ogg +0 -0
  385. package/skills/global_config/brag/assets/sfx/ui/switch12.ogg +0 -0
  386. package/skills/global_config/brag/assets/sfx/ui/switch13.ogg +0 -0
  387. package/skills/global_config/brag/assets/sfx/ui/switch14.ogg +0 -0
  388. package/skills/global_config/brag/assets/sfx/ui/switch15.ogg +0 -0
  389. package/skills/global_config/brag/assets/sfx/ui/switch16.ogg +0 -0
  390. package/skills/global_config/brag/assets/sfx/ui/switch17.ogg +0 -0
  391. package/skills/global_config/brag/assets/sfx/ui/switch18.ogg +0 -0
  392. package/skills/global_config/brag/assets/sfx/ui/switch19.ogg +0 -0
  393. package/skills/global_config/brag/assets/sfx/ui/switch2.ogg +0 -0
  394. package/skills/global_config/brag/assets/sfx/ui/switch20.ogg +0 -0
  395. package/skills/global_config/brag/assets/sfx/ui/switch21.ogg +0 -0
  396. package/skills/global_config/brag/assets/sfx/ui/switch22.ogg +0 -0
  397. package/skills/global_config/brag/assets/sfx/ui/switch23.ogg +0 -0
  398. package/skills/global_config/brag/assets/sfx/ui/switch24.ogg +0 -0
  399. package/skills/global_config/brag/assets/sfx/ui/switch25.ogg +0 -0
  400. package/skills/global_config/brag/assets/sfx/ui/switch26.ogg +0 -0
  401. package/skills/global_config/brag/assets/sfx/ui/switch27.ogg +0 -0
  402. package/skills/global_config/brag/assets/sfx/ui/switch28.ogg +0 -0
  403. package/skills/global_config/brag/assets/sfx/ui/switch29.ogg +0 -0
  404. package/skills/global_config/brag/assets/sfx/ui/switch3.ogg +0 -0
  405. package/skills/global_config/brag/assets/sfx/ui/switch30.ogg +0 -0
  406. package/skills/global_config/brag/assets/sfx/ui/switch31.ogg +0 -0
  407. package/skills/global_config/brag/assets/sfx/ui/switch32.ogg +0 -0
  408. package/skills/global_config/brag/assets/sfx/ui/switch33.ogg +0 -0
  409. package/skills/global_config/brag/assets/sfx/ui/switch34.ogg +0 -0
  410. package/skills/global_config/brag/assets/sfx/ui/switch35.ogg +0 -0
  411. package/skills/global_config/brag/assets/sfx/ui/switch36.ogg +0 -0
  412. package/skills/global_config/brag/assets/sfx/ui/switch37.ogg +0 -0
  413. package/skills/global_config/brag/assets/sfx/ui/switch38.ogg +0 -0
  414. package/skills/global_config/brag/assets/sfx/ui/switch4.ogg +0 -0
  415. package/skills/global_config/brag/assets/sfx/ui/switch5.ogg +0 -0
  416. package/skills/global_config/brag/assets/sfx/ui/switch6.ogg +0 -0
  417. package/skills/global_config/brag/assets/sfx/ui/switch7.ogg +0 -0
  418. package/skills/global_config/brag/assets/sfx/ui/switch8.ogg +0 -0
  419. package/skills/global_config/brag/assets/sfx/ui/switch9.ogg +0 -0
  420. package/skills/global_config/brag/references/audio.md +307 -0
  421. package/skills/global_config/brag/references/step-1-inspect.md +133 -0
  422. package/skills/global_config/brag/references/step-2-plan.md +234 -0
  423. package/skills/global_config/brag/references/step-3-compose.md +210 -0
  424. package/skills/global_config/brag/references/step-4-deliver.md +168 -0
  425. package/skills/global_config/brag/references/tones.md +231 -0
  426. package/skills/global_config/brag/scripts/analyze_music_cues.py +309 -0
  427. package/skills/global_config/brag/scripts/pyproject.toml +14 -0
  428. package/skills/global_config/brag/scripts/uv.lock +819 -0
  429. package/skills/global_config/deja-memory/SKILL.md +42 -0
  430. package/skills/global_config/editable-design/LICENSE +202 -0
  431. package/skills/global_config/editable-design/README.md +34 -0
  432. package/skills/global_config/editable-design/SKILL.md +630 -0
  433. package/skills/global_config/editable-design/THIRD_PARTY_NOTICES.md +10 -0
  434. package/skills/global_config/editable-design/agents/openai.yaml +4 -0
  435. package/skills/global_config/editable-design/assets/editor/layer-editor.css +262 -0
  436. package/skills/global_config/editable-design/assets/editor/layer-editor.js +1929 -0
  437. package/skills/global_config/editable-design/assets/poster-starter/README.md +92 -0
  438. package/skills/global_config/editable-design/assets/poster-starter/assets/.gitkeep +0 -0
  439. package/skills/global_config/editable-design/assets/poster-starter/index.html +91 -0
  440. package/skills/global_config/editable-design/assets/poster-starter/poster.json +6 -0
  441. package/skills/global_config/editable-design/assets/poster-starter/reference/.gitkeep +1 -0
  442. package/skills/global_config/editable-design/assets/replay/icons/assets.png +0 -0
  443. package/skills/global_config/editable-design/assets/replay/icons/html.png +0 -0
  444. package/skills/global_config/editable-design/assets/replay/icons/input.png +0 -0
  445. package/skills/global_config/editable-design/assets/replay/icons/layers.png +0 -0
  446. package/skills/global_config/editable-design/assets/replay/icons/plan.png +0 -0
  447. package/skills/global_config/editable-design/assets/replay/icons/reference.png +0 -0
  448. package/skills/global_config/editable-design/assets/replay/icons/review.png +0 -0
  449. package/skills/global_config/editable-design/assets/replay/index.html +59 -0
  450. package/skills/global_config/editable-design/assets/replay/replay-motion.js +410 -0
  451. package/skills/global_config/editable-design/assets/replay/replay.css +380 -0
  452. package/skills/global_config/editable-design/assets/replay/replay.js +374 -0
  453. package/skills/global_config/editable-design/font-kit/package-lock.json +115 -0
  454. package/skills/global_config/editable-design/font-kit/package.json +19 -0
  455. package/skills/global_config/editable-design/pack.sh +20 -0
  456. package/skills/global_config/editable-design/references/asset-architecture.md +72 -0
  457. package/skills/global_config/editable-design/references/editor-pitfalls.md +70 -0
  458. package/skills/global_config/editable-design/references/editor-runtime.md +131 -0
  459. package/skills/global_config/editable-design/references/font-system.md +89 -0
  460. package/skills/global_config/editable-design/references/imagery.md +145 -0
  461. package/skills/global_config/editable-design/references/layout-typography.md +116 -0
  462. package/skills/global_config/editable-design/references/replay-contract.md +169 -0
  463. package/skills/global_config/editable-design/scripts/_browser.mjs +247 -0
  464. package/skills/global_config/editable-design/scripts/bake.mjs +87 -0
  465. package/skills/global_config/editable-design/scripts/build-replay.mjs +316 -0
  466. package/skills/global_config/editable-design/scripts/check-contract.mjs +293 -0
  467. package/skills/global_config/editable-design/scripts/check-fonts.sh +252 -0
  468. package/skills/global_config/editable-design/scripts/check-poster.sh +70 -0
  469. package/skills/global_config/editable-design/scripts/doctor.sh +78 -0
  470. package/skills/global_config/editable-design/scripts/explode.mjs +115 -0
  471. package/skills/global_config/editable-design/scripts/font-kit.mjs +187 -0
  472. package/skills/global_config/editable-design/scripts/import-asset.sh +23 -0
  473. package/skills/global_config/editable-design/scripts/init-poster.sh +64 -0
  474. package/skills/global_config/editable-design/scripts/install-font-kit.sh +13 -0
  475. package/skills/global_config/editable-design/scripts/package-lock.json +334 -0
  476. package/skills/global_config/editable-design/scripts/package.json +14 -0
  477. package/skills/global_config/editable-design/scripts/remove-chroma-key.py +201 -0
  478. package/skills/global_config/editable-design/scripts/render-poster.sh +202 -0
  479. package/skills/global_config/editable-design/scripts/trace.sh +159 -0
  480. package/skills/global_config/editable-design/scripts/verify-replay.mjs +97 -0
  481. package/skills/global_config/editable-design/scripts/verify.mjs +782 -0
  482. package/skills/global_config/editable-design/scripts/wire-editor.mjs +104 -0
  483. package/skills/global_config/editable-design/tests/fixtures/small-explode/index.html +86 -0
  484. package/skills/global_config/plan-canvas/SKILL.md +8 -0
@@ -0,0 +1,1929 @@
1
+ /* ============================================================
2
+ layer-editor —— 海报图层微调编辑器
3
+ 与 layer-editor.css 配套。接入方式:在海报 HTML 的 </head> 前加两行
4
+ <link rel="stylesheet" href="layer-editor.css">
5
+ <script src="layer-editor.js" defer></script>
6
+ 海报本体无需任何改动,移除这两行即还原为纯海报。
7
+
8
+ 定位:微调工具,不是设计工具。只做「改位置 / 改尺寸 / 改文字 / 改字号字体 / 删图层」。
9
+ 前置契约:画布为固定 px 尺寸并声明 data-canvas-width/height;
10
+ 可编辑单元带 data-layer-id。详见 SKILL.md。
11
+ ============================================================ */
12
+ (() => {
13
+ 'use strict';
14
+
15
+ const canvas = document.querySelector('[data-canvas-width]') || document.querySelector('.poster-canvas');
16
+ if (!canvas) {
17
+ console.error('[layer-editor] 找不到画布:需要一个带 data-canvas-width 的根节点');
18
+ return;
19
+ }
20
+ canvas.classList.add('hf-canvas');
21
+
22
+ const CW = +canvas.dataset.canvasWidth || canvas.offsetWidth;
23
+ const CH = +canvas.dataset.canvasHeight || canvas.offsetHeight;
24
+ const SNAP = 8; // 吸附阈值,画布坐标系下的像素
25
+ const FS_STEP = 1.08; // 字号每次缩放比例
26
+ const MIN_W = 24; // 手动缩放的最小图层宽度
27
+ const MIN_H = 16; // 手动缩放的最小图层高度
28
+ const sourceSignature = (() => {
29
+ let hash = 2166136261;
30
+ const source = canvas.outerHTML;
31
+ for (let i = 0; i < source.length; i++) {
32
+ hash ^= source.charCodeAt(i);
33
+ hash = Math.imul(hash, 16777619);
34
+ }
35
+ return (hash >>> 0).toString(36);
36
+ })();
37
+ const sourceRevision = document.documentElement.dataset.hfSourceRevision || sourceSignature;
38
+ const runtimeRevision = document.documentElement.dataset.hfRuntimeRevision || 'runtime-v1';
39
+ const DRAFT_KEY = `hf-draft:${location.pathname}:${sourceRevision}:${runtimeRevision}`;
40
+ const MOTION = {
41
+ scan: 760, scanOverlap: 0.78,
42
+ duration: 780, reverse: 620, stagger: 58,
43
+ ease: 'cubic-bezier(.22,1,.36,1)',
44
+ };
45
+ const motionOff = () => matchMedia('(prefers-reduced-motion: reduce)').matches
46
+ || new URLSearchParams(location.search).get('motion') === '0';
47
+
48
+ const state = {
49
+ scale: 1, sel: null, layers: [], undo: [],
50
+ initial: new Map(), removed: [], ui: {},
51
+ exploded: false, spread: null, spreadGeometry: new Map(),
52
+ groups: new Map(),
53
+ sourceCanvas: null,
54
+ motion: [], motionToken: null, transitioning: false,
55
+ };
56
+
57
+ const round = (n) => Math.round(n * 10) / 10;
58
+ const px = (n) => round(n) + 'px';
59
+ const h = (html) => {
60
+ const t = document.createElement('template');
61
+ t.innerHTML = html.trim();
62
+ return t.content.firstElementChild;
63
+ };
64
+
65
+ const CONTEXT_PROPS = [
66
+ 'display', 'box-sizing', 'overflow', 'overflow-x', 'overflow-y', 'opacity',
67
+ 'color', 'background-color', 'background-image', 'background-size',
68
+ 'background-position', 'background-repeat', 'background-clip',
69
+ 'border-top-width', 'border-top-style', 'border-top-color',
70
+ 'border-right-width', 'border-right-style', 'border-right-color',
71
+ 'border-bottom-width', 'border-bottom-style', 'border-bottom-color',
72
+ 'border-left-width', 'border-left-style', 'border-left-color',
73
+ 'border-top-left-radius', 'border-top-right-radius',
74
+ 'border-bottom-right-radius', 'border-bottom-left-radius',
75
+ 'box-shadow', 'filter', 'clip-path', 'mix-blend-mode',
76
+ 'font-family', 'font-size', 'font-style', 'font-weight', 'font-stretch',
77
+ 'font-variant', 'line-height', 'letter-spacing', 'word-spacing',
78
+ 'text-align', 'text-transform', 'text-decoration-line',
79
+ 'text-decoration-color', 'text-decoration-style', 'text-shadow',
80
+ 'white-space', 'writing-mode',
81
+ 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
82
+ 'align-items', 'align-content', 'justify-items', 'justify-content',
83
+ 'row-gap', 'column-gap', 'grid-template-columns', 'grid-template-rows',
84
+ 'grid-auto-flow', 'flex-direction', 'flex-wrap', 'flex-grow', 'flex-shrink',
85
+ 'object-fit', 'object-position', 'fill', 'stroke', 'stroke-width',
86
+ ];
87
+ const PSEUDO_PROPS = [
88
+ 'content', 'display', 'position', 'left', 'top', 'right', 'bottom',
89
+ 'width', 'height', 'box-sizing', 'opacity', 'color',
90
+ 'background-color', 'background-image', 'background-size', 'background-position',
91
+ 'border-top-width', 'border-top-style', 'border-top-color',
92
+ 'border-right-width', 'border-right-style', 'border-right-color',
93
+ 'border-bottom-width', 'border-bottom-style', 'border-bottom-color',
94
+ 'border-left-width', 'border-left-style', 'border-left-color',
95
+ 'border-top-left-radius', 'border-top-right-radius',
96
+ 'border-bottom-right-radius', 'border-bottom-left-radius',
97
+ 'transform', 'transform-origin', 'box-shadow', 'filter',
98
+ ];
99
+
100
+ const styleSnapshot = (el, pseudo, props) => {
101
+ const cs = getComputedStyle(el, pseudo);
102
+ return Object.fromEntries(props.map((prop) => [prop, cs.getPropertyValue(prop)]));
103
+ };
104
+ const changedDeclarations = (el, pseudo, before, props) => {
105
+ const after = getComputedStyle(el, pseudo);
106
+ return props.filter((prop) => after.getPropertyValue(prop) !== before[prop])
107
+ .map((prop) => `${prop}:${before[prop]};`).join('');
108
+ };
109
+
110
+ /* ---------------------------------------------------------------
111
+ * 1. 布局固化
112
+ *
113
+ * 海报里通常只有少数图层是绝对定位的,其余活在文档流(margin 流、
114
+ * flex)里 —— 没有 left/top 就无从拖起。这里把每个图层此刻的实际
115
+ * 位置量出来,统一改写成相对画布的绝对坐标。
116
+ *
117
+ * 必须严格分成「先全部量完,再全部改写」两趟:一旦边量边改,前面的
118
+ * 元素脱离文档流会让后面的元素往上跳,量到的就全是错的。
119
+ * ------------------------------------------------------------- */
120
+ function bake() {
121
+ state.sourceCanvas = canvas.cloneNode(true);
122
+ const layers = [...canvas.querySelectorAll('[data-layer-id]')];
123
+ const cRect = canvas.getBoundingClientRect();
124
+ const rel = (el) => {
125
+ const r = el.getBoundingClientRect();
126
+ return { left: r.left - cRect.left, top: r.top - cRect.top, width: r.width, height: r.height };
127
+ };
128
+
129
+ // 可选的一级内容模块契约:未标组时保持扁平内容拆解;标组时把完整
130
+ // 模块作为一个画廊单元,不再继续钻取内部叶子。叶子提升到画布前
131
+ // 仍要固化归属,确保下载后的 baked HTML 再接线时能恢复模块组合,
132
+ // 而不依赖原来的 DOM 嵌套关系。
133
+ const groupNodes = [...canvas.querySelectorAll('[data-explode-group]')];
134
+ const groupDefs = groupNodes.map((el) => {
135
+ const id = (el.dataset.explodeGroup || '').trim();
136
+ const nested = layers.filter((layer) => layer.closest('[data-explode-group]') === el);
137
+ const explicit = layers.filter((layer) => (layer.dataset.explodeParent || '').trim() === id);
138
+ const members = [...new Set([...nested, ...explicit])];
139
+ return {
140
+ id, el,
141
+ label: (el.dataset.explodeLabel || id).trim() || id,
142
+ members,
143
+ memberIds: members.map((layer) => layer.dataset.layerId),
144
+ box: rel(el),
145
+ };
146
+ }).filter((group) => group.id && group.members.length >= 2);
147
+ state.groups = new Map(groupDefs.map((group) => [group.id, group]));
148
+ for (const group of groupDefs) {
149
+ for (const member of group.members) member.dataset.explodeParent = group.id;
150
+ }
151
+
152
+ // 提升叶子后仍要复刻原来的组内绘制顺序:surface/image 在底层,
153
+ // 留在组容器里的分隔线、遮罩和轨道居中,copy/icon/decoration 在上层。
154
+ // 每个顶层模块占用独立的 z 段,模块之间继续遵循原 DOM 顺序。
155
+ const topChildren = [...canvas.children];
156
+ const topOf = (el) => {
157
+ let top = el;
158
+ while (top.parentElement && top.parentElement !== canvas) top = top.parentElement;
159
+ return top;
160
+ };
161
+ // 顶层绘制顺序不能只看 DOM 顺序:显式 z-index 会让较早出现的装饰
162
+ // 盖到较晚元素之上。先按浏览器的粗粒度 stacking 顺序排一次,
163
+ // 再给每个顶层模块分配独立 z 段。
164
+ const topPaintOrder = [...topChildren].sort((a, b) => {
165
+ const za = getComputedStyle(a).zIndex;
166
+ const zb = getComputedStyle(b).zIndex;
167
+ const na = za === 'auto' || !Number.isFinite(+za) ? 0 : +za;
168
+ const nb = zb === 'auto' || !Number.isFinite(+zb) ? 0 : +zb;
169
+ return na - nb || topChildren.indexOf(a) - topChildren.indexOf(b);
170
+ });
171
+ const topRank = new Map(topPaintOrder.map((el, index) => [el, (index + 1) * 100]));
172
+ const stackBase = (el) => topRank.get(topOf(el)) || 100;
173
+ for (const top of topChildren) {
174
+ // 未被标成叶子图层的顶层视觉(背景图、scrim、路线装饰)也要进入
175
+ // 同一套绘制顺序,否则提升后的图层会无条件盖过它们。
176
+ top.style.zIndex = String(stackBase(top) + 30);
177
+ }
178
+ const memberGroup = new Map();
179
+ for (const group of groupDefs) {
180
+ group.stackBase = stackBase(group.el);
181
+ // 有独立 surface 叶子的模块通常把容器留给分隔线/遮罩,容器应位于
182
+ // surface/image 与 copy 之间;若没有 surface 叶子,容器自身往往
183
+ // 就是卡片底色,必须放在 image 之下,不能把照片盖住。
184
+ const hasSurface = group.members.some((member) => member.dataset.explodeRole === 'surface');
185
+ group.el.style.zIndex = String(group.stackBase + (hasSurface ? 30 : 10));
186
+ for (const member of group.members) memberGroup.set(member, group);
187
+ }
188
+ const stackZ = (el) => {
189
+ const group = memberGroup.get(el);
190
+ if (!group) return String(stackBase(el) + 50);
191
+ const role = (el.dataset.explodeRole || 'copy').trim();
192
+ const rank = { surface: 10, image: 20, copy: 40, icon: 45, decoration: 46 }[role] || 40;
193
+ return String(group.stackBase + rank);
194
+ };
195
+
196
+ // 图层被抽走后,原先靠内容撑开的容器会塌陷;用 bottom 定位的容器
197
+ // 一塌陷,容器内未被抽走的元素(如分隔线)就会整体位移。先钉死尺寸。
198
+ const holders = new Set();
199
+ for (const el of layers) {
200
+ for (let p = el.parentElement; p && p !== canvas; p = p.parentElement) holders.add(p);
201
+ }
202
+
203
+ const layerBox = layers.map((el) => {
204
+ const computed = getComputedStyle(el);
205
+ return {
206
+ el, id: el.dataset.layerId, box: rel(el), z: stackZ(el),
207
+ // getBoundingClientRect() 是 transform 之后的可见包围盒,不能把它
208
+ // 直接写回 width/height 再重新施加 transform。保存布局尺寸与矩阵,
209
+ // 提升到画布后再用包围盒差值校准 left/top。
210
+ layoutWidth: parseFloat(computed.width) || el.offsetWidth,
211
+ layoutHeight: parseFloat(computed.height) || el.offsetHeight,
212
+ transform: computed.transform,
213
+ transformOrigin: computed.transformOrigin,
214
+ scrollWidth: el.scrollWidth, scrollHeight: el.scrollHeight,
215
+ context: styleSnapshot(el, null, CONTEXT_PROPS),
216
+ before: styleSnapshot(el, '::before', PSEUDO_PROPS),
217
+ after: styleSnapshot(el, '::after', PSEUDO_PROPS),
218
+ };
219
+ });
220
+ const holderBox = [...holders].map((el) => ({
221
+ el, box: rel(el),
222
+ local: { left: el.offsetLeft, top: el.offsetTop },
223
+ }));
224
+
225
+ for (const { el, box, local } of holderBox) {
226
+ if (getComputedStyle(el).position === 'absolute') {
227
+ // holder 仍留在原父级,left/top 必须使用父级坐标。旧实现写入
228
+ // 画布坐标,会让嵌套容器把父级偏移再加一次,产生大面积漂移。
229
+ Object.assign(el.style, { left: px(local.left), top: px(local.top), right: 'auto', bottom: 'auto' });
230
+ }
231
+ Object.assign(el.style, { width: px(box.width), height: px(box.height), pointerEvents: 'none' });
232
+ }
233
+
234
+ // 提升到画布直属子节点,让所有图层共用同一个坐标系。
235
+ // 插入位置紧跟原来的顶层祖先,以维持原有的绘制顺序。
236
+ const anchors = new Map();
237
+ for (const { el, box, z, layoutWidth, layoutHeight, transform, transformOrigin } of layerBox) {
238
+ let top = el;
239
+ while (top.parentElement !== canvas) top = top.parentElement;
240
+ if (top !== el) {
241
+ (anchors.get(top) || top).insertAdjacentElement('afterend', el);
242
+ anchors.set(top, el);
243
+ }
244
+ Object.assign(el.style, {
245
+ position: 'absolute',
246
+ left: px(box.left), top: px(box.top), width: px(layoutWidth), height: px(layoutHeight),
247
+ right: 'auto', bottom: 'auto',
248
+ margin: '0',
249
+ transform, transformOrigin,
250
+ pointerEvents: 'auto',
251
+ });
252
+ if (z) el.style.zIndex = z;
253
+ el.classList.add('hf-draggable');
254
+ }
255
+
256
+ // 旋转/位移后的 bounding box 左上角通常不等于 CSS left/top;第一次
257
+ // 写入只是临时锚点。按原始可见包围盒反算一次锚点,保持旋转的回形针、
258
+ // 邮戳、手写贴纸等逐像素落在原处。
259
+ for (const rec of layerBox) {
260
+ const visible = rel(rec.el);
261
+ rec.el.style.left = px(parseFloat(rec.el.style.left) + rec.box.left - visible.left);
262
+ rec.el.style.top = px(parseFloat(rec.el.style.top) + rec.box.top - visible.top);
263
+ }
264
+
265
+ // 图层离开语义容器后,`.dark-card .label` 这类上下文选择器不再
266
+ // 命中。只把确实发生变化的视觉属性固化为内联值;同时为伪元素
267
+ // 生成最小覆盖规则。这样既保留原稿,又不把整份 computed style
268
+ // 粗暴复制进导出物。
269
+ const pseudoRules = [];
270
+ for (const rec of layerBox) {
271
+ const current = getComputedStyle(rec.el);
272
+ for (const prop of CONTEXT_PROPS) {
273
+ if (current.getPropertyValue(prop) !== rec.context[prop]) {
274
+ rec.el.style.setProperty(prop, rec.context[prop]);
275
+ }
276
+ }
277
+ // shrink-to-fit 文本的测量值带小数;写回到 0.1px 后偶尔会少掉
278
+ // 几百分之一像素,恰好触发多一行换行。只在真实 scroll 尺寸
279
+ // 超过原稿时逐像素补宽,最多 8px,不碰正常固定宽度图层。
280
+ for (let extra = 0; extra < 8 && (
281
+ rec.el.scrollHeight > rec.scrollHeight + .5
282
+ || rec.el.scrollWidth > rec.scrollWidth + .5
283
+ ); extra++) {
284
+ rec.el.style.width = px(parseFloat(rec.el.style.width) + 1);
285
+ }
286
+ const escaped = rec.id.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
287
+ const beforeDecl = changedDeclarations(rec.el, '::before', rec.before, PSEUDO_PROPS);
288
+ const afterDecl = changedDeclarations(rec.el, '::after', rec.after, PSEUDO_PROPS);
289
+ if (beforeDecl) pseudoRules.push(`[data-layer-id="${escaped}"]::before{${beforeDecl}}`);
290
+ if (afterDecl) pseudoRules.push(`[data-layer-id="${escaped}"]::after{${afterDecl}}`);
291
+ }
292
+ if (pseudoRules.length) {
293
+ const style = document.createElement('style');
294
+ style.dataset.hfRuntimeContext = '1';
295
+ style.textContent = pseudoRules.join('\n');
296
+ document.head.appendChild(style);
297
+ }
298
+
299
+ state.layers = layerBox.map(({ el, id }) => ({ el, id }));
300
+ for (const { el, id } of state.layers) {
301
+ state.initial.set(id, {
302
+ left: parseFloat(el.style.left), top: parseFloat(el.style.top),
303
+ width: parseFloat(el.style.width), height: parseFloat(el.style.height),
304
+ });
305
+ }
306
+ }
307
+
308
+ // 元素移出原容器后会丢掉继承来的层级,先把最近一个显式 z-index 记下来
309
+ function inheritedZ(el) {
310
+ for (let n = el; n && n !== canvas; n = n.parentElement) {
311
+ const z = getComputedStyle(n).zIndex;
312
+ if (z !== 'auto') return z;
313
+ }
314
+ return '';
315
+ }
316
+
317
+ /* ---------------------------------------------------------------
318
+ * 2. 文本槽
319
+ *
320
+ * 可编辑单元是「文本槽」而非整个图层:价格这类图层内部靠多个不同
321
+ * 字号的 span 拼出排版,整块设成 contenteditable 的话,一个退格就
322
+ * 能删掉 span,样式随之崩掉。所以只把承载文字的叶子节点标为可编辑。
323
+ * ------------------------------------------------------------- */
324
+ function markSlots() {
325
+ for (const { el } of state.layers) {
326
+ for (const slot of findSlots(el)) slot.classList.add('hf-slot');
327
+ }
328
+ }
329
+
330
+ function findSlots(root) {
331
+ const out = [];
332
+ (function walk(n) {
333
+ if (n instanceof SVGElement) return; // 二维码、装饰图形不参与文本编辑
334
+ // 叶子节点,或「加 <b>3</b> 元升级」这种图文混排,都作为一个整体编辑
335
+ const ownText = [...n.childNodes].some((c) => c.nodeType === 3 && c.textContent.trim());
336
+ if (!n.children.length || ownText) {
337
+ if (n.textContent.trim()) out.push(n);
338
+ return;
339
+ }
340
+ for (const c of n.children) walk(c);
341
+ })(root);
342
+ return out;
343
+ }
344
+
345
+ // 图层自身可能就是一个槽(此时它内部不会再有子槽)
346
+ const slotsOf = (layer) => {
347
+ const out = layer.classList.contains('hf-slot') ? [layer] : [];
348
+ out.push(...layer.querySelectorAll('.hf-slot'));
349
+ return out;
350
+ };
351
+
352
+ /* ---------------------------------------------------------------
353
+ * 3. 编辑器 UI(全部动态创建,海报 HTML 里不留痕迹)
354
+ * ------------------------------------------------------------- */
355
+ function buildUI() {
356
+ const title = (document.title || '海报').slice(0, 40);
357
+ const bar = h(`
358
+ <div class="hf-bar">
359
+ <span class="hf-title">${title}</span>
360
+ <span class="hf-sep"></span>
361
+ <button class="hf-btn" id="hf-undo" disabled>撤销</button>
362
+ <button class="hf-btn" id="hf-reset">还原初始布局</button>
363
+ <button class="hf-btn" id="hf-explode">拆解视图</button>
364
+ <span class="hf-sep"></span>
365
+ <button class="hf-btn primary" id="hf-save">下载 HTML</button>
366
+ <button class="hf-btn" id="hf-explode-save">下载拆解稿</button>
367
+ <button class="hf-btn" id="hf-css">复制定位 CSS</button>
368
+ <label class="hf-zoom">缩放
369
+ <input type="range" id="hf-scale" min="10" max="100" step="1">
370
+ <output id="hf-scale-out">—</output>
371
+ </label>
372
+ </div>`);
373
+
374
+ const panel = h(`
375
+ <div class="hf-panel">
376
+ <div class="hf-draft" id="hf-draft" hidden>
377
+ <span id="hf-draft-msg"></span>
378
+ <button class="hf-btn" id="hf-draft-drop">丢弃草稿</button>
379
+ </div>
380
+ <h4>图层</h4>
381
+ <div class="hf-layers" id="hf-layers"></div>
382
+ <div class="hf-props" id="hf-props" hidden>
383
+ <h4 style="padding-left:0">属性</h4>
384
+ <div class="hf-field">
385
+ <label>字号</label>
386
+ <div class="hf-step">
387
+ <button class="hf-btn" id="hf-fs-down">A−</button>
388
+ <span class="val" id="hf-fs-val">—</span>
389
+ <button class="hf-btn" id="hf-fs-up">A+</button>
390
+ </div>
391
+ </div>
392
+ <div class="hf-field">
393
+ <label>尺寸</label>
394
+ <span class="hf-size-val" id="hf-size-val">—</span>
395
+ </div>
396
+ <div class="hf-field" id="hf-ff-field">
397
+ <label>字体</label>
398
+ <select id="hf-ff"></select>
399
+ </div>
400
+ <button class="hf-btn danger" id="hf-del">删除此图层</button>
401
+ </div>
402
+ <div class="hf-hint">
403
+ <b>单击</b>选中并拖动 <b>绿色手柄</b>改宽高<br>
404
+ <b>双击</b>改文字 <b>角点</b>同时改宽高<br>
405
+ <kbd>方向键</kbd> 微调 1px <kbd>Shift</kbd>+<kbd>方向键</kbd> 10px<br>
406
+ <kbd>Alt</kbd> 临时关闭吸附 <kbd>Delete</kbd> 删除图层<br>
407
+ <kbd>Enter</kbd> 提交文字 <kbd>Esc</kbd> 放弃本次修改<br>
408
+ <kbd>⌘Z</kbd> 撤销 <kbd>⌘S</kbd> 下载
409
+ </div>
410
+ </div>`);
411
+
412
+ const modal = h(`
413
+ <div class="hf-modal" id="hf-modal" hidden>
414
+ <div class="box">
415
+ <header>
416
+ <span id="hf-modal-title">导出</span>
417
+ <button class="hf-btn hf-spacer" id="hf-modal-copy">复制</button>
418
+ <button class="hf-btn" id="hf-modal-close">关闭</button>
419
+ </header>
420
+ <textarea id="hf-modal-text" spellcheck="false" readonly></textarea>
421
+ </div>
422
+ </div>`);
423
+
424
+ const toast = h('<div class="hf-toast" id="hf-toast" hidden></div>');
425
+ document.body.append(bar, panel, modal, toast);
426
+ for (const id of ['undo', 'reset', 'explode', 'save', 'explode-save', 'css', 'scale', 'scale-out', 'layers', 'props',
427
+ 'fs-down', 'fs-up', 'fs-val', 'size-val', 'ff', 'ff-field', 'del', 'modal', 'modal-title',
428
+ 'modal-text', 'modal-copy', 'modal-close', 'toast', 'draft', 'draft-msg', 'draft-drop']) {
429
+ state.ui[id] = document.getElementById('hf-' + id);
430
+ }
431
+ }
432
+
433
+ const $ = (id) => state.ui[id];
434
+
435
+ /* ---------------------------------------------------------------
436
+ * 4. 舞台与缩放:画布通常放不进屏幕,整体 scale 显示
437
+ * ------------------------------------------------------------- */
438
+ const guides = Object.assign(document.createElement('div'), { className: 'hf-guides' });
439
+ const resizeOverlay = h(`
440
+ <div class="hf-resize-overlay" hidden>
441
+ <i class="hf-resize-handle nw" data-hf-resize-dir="nw"></i>
442
+ <i class="hf-resize-handle n" data-hf-resize-dir="n"></i>
443
+ <i class="hf-resize-handle ne" data-hf-resize-dir="ne"></i>
444
+ <i class="hf-resize-handle e" data-hf-resize-dir="e"></i>
445
+ <i class="hf-resize-handle se" data-hf-resize-dir="se"></i>
446
+ <i class="hf-resize-handle s" data-hf-resize-dir="s"></i>
447
+ <i class="hf-resize-handle sw" data-hf-resize-dir="sw"></i>
448
+ <i class="hf-resize-handle w" data-hf-resize-dir="w"></i>
449
+ </div>`);
450
+ let stage;
451
+
452
+ function buildStage() {
453
+ document.body.classList.add('hf-on');
454
+ const wrap = Object.assign(document.createElement('div'), { className: 'hf-stage-wrap' });
455
+ stage = Object.assign(document.createElement('div'), { className: 'hf-stage' });
456
+ document.body.insertBefore(wrap, document.body.firstChild);
457
+ wrap.appendChild(stage);
458
+ stage.appendChild(canvas);
459
+ stage.appendChild(guides);
460
+ stage.appendChild(resizeOverlay);
461
+ setScale(fitScale());
462
+ }
463
+
464
+ const fitScale = () => {
465
+ const w = state.exploded && state.spread ? state.spread.width : CW;
466
+ const h = state.exploded && state.spread ? state.spread.height : CH;
467
+ return Math.min((window.innerWidth - 300 - 96) / w, (window.innerHeight - 52 - 72) / h);
468
+ };
469
+
470
+ function setScale(s) {
471
+ state.scale = Math.max(0.1, Math.min(1, s));
472
+ if (state.exploded && state.spread) {
473
+ state.spread.board.style.transform = `scale(${state.scale})`;
474
+ stage.style.width = state.spread.width * state.scale + 'px';
475
+ stage.style.height = state.spread.height * state.scale + 'px';
476
+ } else {
477
+ canvas.style.transform = `scale(${state.scale})`;
478
+ stage.style.width = CW * state.scale + 'px';
479
+ stage.style.height = CH * state.scale + 'px';
480
+ }
481
+ $('scale').value = Math.round(state.scale * 100);
482
+ $('scale-out').textContent = Math.round(state.scale * 100) + '%';
483
+ updateResizeOverlay();
484
+ }
485
+
486
+ /* ---------------------------------------------------------------
487
+ * 5. 拆解视图
488
+ *
489
+ * 把当前已经固化的成稿拆成完整总览、背景基底与一级内容模块,
490
+ * 再铺成横向组件画廊;内容仍保留原 DOM / CSS,不改编辑状态。
491
+ * ------------------------------------------------------------- */
492
+ const cleanClone = (root) => {
493
+ for (const n of [root, ...root.querySelectorAll('*')]) {
494
+ if (n.classList) {
495
+ for (const c of [...n.classList]) if (c.startsWith('hf-')) n.classList.remove(c);
496
+ if (!n.getAttribute('class')) n.removeAttribute('class');
497
+ }
498
+ n.removeAttribute('contenteditable');
499
+ for (const a of [...n.attributes]) if (a.name.startsWith('data-hf-')) n.removeAttribute(a.name);
500
+ }
501
+ root.style.transform = '';
502
+ return root;
503
+ };
504
+
505
+ const finishExplodedSource = (copy, transparent = false, marker = 'layer') => {
506
+ for (const n of copy.querySelectorAll('[data-layer-id]')) {
507
+ if (marker === 'layer') {
508
+ n.dataset.explodedLayer = n.dataset.layerId;
509
+ const live = state.layers.find((layer) => layer.id === n.dataset.layerId)?.el;
510
+ const opacity = live ? Number.parseFloat(getComputedStyle(live).opacity) : 1;
511
+ if (Number.isFinite(opacity) && opacity < .55) {
512
+ n.style.opacity = String(Math.max(.46, Math.min(.68, opacity * 1.8)));
513
+ n.dataset.explodedVisibilityBoosted = '1';
514
+ }
515
+ }
516
+ if (marker === 'reference') n.dataset.explodedReferenceLayer = n.dataset.layerId;
517
+ n.removeAttribute('data-layer-id');
518
+ }
519
+ copy.classList.add('hf-exploded-source');
520
+ Object.assign(copy.style, {
521
+ position: 'absolute', left: '0', top: '0',
522
+ transform: 'none', transformOrigin: 'top left',
523
+ });
524
+ if (transparent) Object.assign(copy.style, { background: 'transparent', boxShadow: 'none' });
525
+ return copy;
526
+ };
527
+
528
+ const parseColor = (value) => {
529
+ const raw = String(value);
530
+ const rgb = raw.match(/rgba?\((\d+(?:\.\d+)?)[, ]+(\d+(?:\.\d+)?)[, ]+(\d+(?:\.\d+)?)(?:\s*[,/]\s*(\d*(?:\.\d+)?))?/i);
531
+ if (rgb) return { rgb: rgb.slice(1, 4).map(Number), alpha: rgb[4] === undefined ? 1 : Number(rgb[4]) };
532
+ const hex = raw.match(/#([0-9a-f]{6}|[0-9a-f]{3})(?![0-9a-f])/i);
533
+ if (!hex) return null;
534
+ const value6 = hex[1].length === 3 ? [...hex[1]].map((c) => c + c).join('') : hex[1];
535
+ return {
536
+ rgb: [0, 2, 4].map((i) => parseInt(value6.slice(i, i + 2), 16)), alpha: 1,
537
+ };
538
+ };
539
+
540
+ function posterBackgroundRGB() {
541
+ const cs = getComputedStyle(canvas);
542
+ const solid = parseColor(cs.backgroundColor);
543
+ if (solid && solid.alpha > .25) return solid.rgb;
544
+ // 许多纸张海报用纯渐变覆盖整块画布,background-color 本身是
545
+ // transparent。此时从 background-image 中选第一个近乎不透明的颜色;
546
+ // 低透明度网点/纹理不应把暖白纸张误判成黑色舞台。
547
+ const tokens = String(cs.backgroundImage).match(/#[0-9a-f]{3,8}|rgba?\([^)]*\)/ig) || [];
548
+ for (const token of tokens) {
549
+ const parsed = parseColor(token);
550
+ if (parsed && parsed.alpha > .65) return parsed.rgb;
551
+ }
552
+ // 最后退到页面底色;仍透明时使用中性暖白。
553
+ for (const el of [canvas.parentElement, document.body, document.documentElement]) {
554
+ if (!el) continue;
555
+ const parsed = parseColor(getComputedStyle(el).backgroundColor);
556
+ if (parsed && parsed.alpha > .25) return parsed.rgb;
557
+ }
558
+ return [244, 241, 232];
559
+ }
560
+
561
+ function explodedTheme() {
562
+ const rgb = posterBackgroundRGB();
563
+ const linear = rgb.map((n) => {
564
+ const c = n / 255;
565
+ return c <= .03928 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4;
566
+ });
567
+ const light = linear[0] * .2126 + linear[1] * .7152 + linear[2] * .0722 > .52;
568
+ const bg = `rgb(${rgb.map((n) => Math.round(n)).join(',')})`;
569
+ const labelRGB = rgb.map((n) => Math.max(0, Math.min(255, Math.round(n * .42))));
570
+ return light ? {
571
+ kind: 'light', bg, fg: '#26313a',
572
+ border: 'rgba(38,49,58,.28)', labelBg: 'rgba(255,255,255,.92)',
573
+ labelFg: '#26313a', shadow: 'rgba(31,42,48,.18)',
574
+ } : {
575
+ kind: 'dark', bg, fg: '#f7fbff',
576
+ border: 'rgba(247,251,255,.40)', labelBg: `rgba(${labelRGB.join(',')},.88)`,
577
+ labelFg: '#f7fbff', shadow: 'rgba(0,0,0,.34)',
578
+ };
579
+ }
580
+
581
+ function applyExplodedTheme(board) {
582
+ const theme = explodedTheme();
583
+ board.dataset.explodedTheme = theme.kind;
584
+ board.style.setProperty('--hf-exploded-bg', theme.bg);
585
+ board.style.setProperty('--hf-exploded-fg', theme.fg);
586
+ board.style.setProperty('--hf-exploded-border', theme.border);
587
+ board.style.setProperty('--hf-exploded-label-bg', theme.labelBg);
588
+ board.style.setProperty('--hf-exploded-label-fg', theme.labelFg);
589
+ board.style.setProperty('--hf-exploded-shadow', theme.shadow);
590
+ return theme;
591
+ }
592
+
593
+ function isolatedCanvas(layerId = null) {
594
+ const copy = cleanClone(canvas.cloneNode(true));
595
+ copy.querySelectorAll('script').forEach((n) => n.remove());
596
+ const layerNodes = [...copy.querySelectorAll('[data-layer-id]')];
597
+ if (layerId) {
598
+ const keep = layerNodes.find((n) => n.dataset.layerId === layerId);
599
+ for (const child of [...copy.children]) if (child !== keep) child.remove();
600
+ } else {
601
+ for (const n of layerNodes) n.remove();
602
+ // 背景组件不重复呈现已被抽成一级内容模块的表面。
603
+ for (const n of [...copy.querySelectorAll('[data-explode-group]')]) {
604
+ if (state.groups.has(n.dataset.explodeGroup)) n.remove();
605
+ }
606
+ }
607
+ return finishExplodedSource(copy, !!layerId, 'layer');
608
+ }
609
+
610
+ function isolatedOverviewCanvas() {
611
+ const copy = cleanClone(canvas.cloneNode(true));
612
+ copy.querySelectorAll('script').forEach((n) => n.remove());
613
+ return finishExplodedSource(copy, false, 'none');
614
+ }
615
+
616
+ const topChildOf = (root, node) => {
617
+ let top = node;
618
+ while (top && top.parentElement !== root) top = top.parentElement;
619
+ return top;
620
+ };
621
+
622
+ function isolatedGroupCanvas(groupId) {
623
+ const group = state.groups.get(groupId);
624
+ const copy = cleanClone(canvas.cloneNode(true));
625
+ copy.querySelectorAll('script').forEach((n) => n.remove());
626
+ if (!group) return finishExplodedSource(copy, true, 'layer');
627
+
628
+ const memberIds = new Set(group.memberIds);
629
+ const target = [...copy.querySelectorAll('[data-explode-group]')]
630
+ .find((n) => n.dataset.explodeGroup === groupId);
631
+ const keepTop = target ? topChildOf(copy, target) : null;
632
+ for (const child of [...copy.children]) {
633
+ const member = child.dataset.layerId && memberIds.has(child.dataset.layerId);
634
+ if (child !== keepTop && !member) child.remove();
635
+ }
636
+ if (keepTop) {
637
+ for (const n of [...keepTop.querySelectorAll('[data-explode-group]')]) {
638
+ if (n.dataset.explodeGroup !== groupId) {
639
+ // Grid/Flex 模块不能直接删兄弟:目标卡会自动补到第一个槽位,
640
+ // 原坐标裁切时便只剩提升后的文字,卡片底色已经跑走。隐藏但保留
641
+ // 占位,既维持布局轨道,又不会把其他模块画进当前切片。
642
+ n.style.visibility = 'hidden';
643
+ n.setAttribute('aria-hidden', 'true');
644
+ }
645
+ }
646
+ }
647
+ for (const n of [...copy.querySelectorAll('[data-layer-id]')]) {
648
+ if (!memberIds.has(n.dataset.layerId)) n.remove();
649
+ }
650
+ return finishExplodedSource(copy, true, 'layer');
651
+ }
652
+
653
+ const cropOf = (g) => {
654
+ const pad = Math.min(84, Math.max(16, Math.max(CW, CH) * 0.035));
655
+ const left = Math.max(0, g.left - pad);
656
+ const top = Math.max(0, g.top - pad);
657
+ const right = Math.min(CW, g.left + g.width + pad);
658
+ const bottom = Math.min(CH, g.top + g.height + pad);
659
+ return { left, top, width: Math.max(1, right - left), height: Math.max(1, bottom - top) };
660
+ };
661
+
662
+ function makeExplodedItem(rec, scale, interactive) {
663
+ const tile = Object.assign(document.createElement('div'), { className: 'hf-exploded-item' });
664
+ tile.dataset.layerName = rec.id;
665
+ tile.dataset.explodedUnit = rec.kind;
666
+ if (rec.supplementalText) tile.dataset.explodedTextSupplement = '1';
667
+ if (rec.kind === 'group') tile.dataset.explodedGroup = rec.group.id;
668
+ if (rec.kind === 'background') tile.dataset.explodedBackground = '1';
669
+ if (rec.kind === 'overview') tile.dataset.explodedOverview = '1';
670
+ tile.title = rec.label || rec.id;
671
+ tile.style.width = px(rec.crop.width * scale);
672
+ tile.style.height = px(rec.crop.height * scale);
673
+ tile.style.zIndex = rec.kind === 'overview' ? '30' : rec.kind === 'background' ? '1' : '10';
674
+
675
+ const crop = Object.assign(document.createElement('div'), { className: 'hf-exploded-crop' });
676
+ Object.assign(crop.style, {
677
+ width: px(rec.crop.width), height: px(rec.crop.height),
678
+ transform: `scale(${scale})`, transformOrigin: 'top left',
679
+ });
680
+ const source = rec.kind === 'group' ? isolatedGroupCanvas(rec.group.id)
681
+ : rec.kind === 'overview' ? isolatedOverviewCanvas()
682
+ : rec.kind === 'background' ? isolatedCanvas()
683
+ : isolatedCanvas(rec.id);
684
+ source.style.left = px(-rec.crop.left);
685
+ source.style.top = px(-rec.crop.top);
686
+ crop.appendChild(source);
687
+ tile.appendChild(crop);
688
+ if (rec.kind !== 'layer') {
689
+ const label = Object.assign(document.createElement('div'), {
690
+ className: 'hf-exploded-label', textContent: rec.label,
691
+ });
692
+ label.setAttribute('aria-hidden', 'true');
693
+ tile.appendChild(label);
694
+ }
695
+ if (interactive && rec.kind !== 'group') tile.addEventListener('click', async () => {
696
+ await setExploded(false);
697
+ const live = state.layers.find((l) => l.id === rec.id);
698
+ if (live) select(live.el);
699
+ });
700
+ return tile;
701
+ }
702
+
703
+ function makeExplodedBoard(interactive = true) {
704
+ // 舞台保持与海报高度相同,并给横向组件画廊足够宽度。终态没有
705
+ // 永久中央基底:完整成稿、背景和内容都使用同一种 tile 排布。
706
+ const width = Math.round(Math.max(CW * 1.7, CH * 1.275));
707
+ const height = CH;
708
+ const margin = height * 0.045;
709
+ const board = Object.assign(document.createElement('div'), { className: 'hf-exploded-board' });
710
+ const theme = applyExplodedTheme(board);
711
+ board.dataset.explodedLevel = '1';
712
+ board.dataset.width = width;
713
+ board.dataset.height = height;
714
+ Object.assign(board.style, { width: px(width), height: px(height) });
715
+
716
+ const layerRecords = state.layers.map(({ el, id }, index) => {
717
+ const g = state.exploded && state.spreadGeometry.has(id)
718
+ ? state.spreadGeometry.get(id) : geom(el);
719
+ return { kind: 'layer', el, id, index, g, crop: cropOf(g), area: g.width * g.height };
720
+ }).filter((r) => r.g.width > 0 && r.g.height > 0);
721
+ const grouped = new Set([...state.groups.values()].flatMap((group) => group.memberIds));
722
+ const unionBox = (records) => {
723
+ if (!records.length) return null;
724
+ const left = Math.min(...records.map((rec) => rec.g.left));
725
+ const top = Math.min(...records.map((rec) => rec.g.top));
726
+ const right = Math.max(...records.map((rec) => rec.g.left + rec.g.width));
727
+ const bottom = Math.max(...records.map((rec) => rec.g.top + rec.g.height));
728
+ return { left, top, width: right - left, height: bottom - top };
729
+ };
730
+ const moduleRecords = [...state.groups.values()].map((group) => {
731
+ const members = group.memberIds.map((id) => layerRecords.find((rec) => rec.id === id)).filter(Boolean);
732
+ const index = Math.min(...group.memberIds.map((id) =>
733
+ layerRecords.find((rec) => rec.id === id)?.index ?? Number.MAX_SAFE_INTEGER));
734
+ const memberBox = unionBox(members);
735
+ const declared = group.box;
736
+ const declaredArea = Math.max(0, declared.width) * Math.max(0, declared.height);
737
+ const memberArea = memberBox ? memberBox.width * memberBox.height : 0;
738
+ const g = declared.width > 1 && declared.height > 1 && declaredArea >= memberArea * .2
739
+ ? declared : memberBox;
740
+ if (!g) return null;
741
+ return {
742
+ kind: 'group', id: `group:${group.id}`, label: group.label, group,
743
+ index, g, crop: cropOf(g), area: g.width * g.height,
744
+ };
745
+ }).filter(Boolean);
746
+ const primaryContentRecords = [...layerRecords.filter((rec) => !grouped.has(rec.id)), ...moduleRecords];
747
+ const isTextLayer = (rec) => {
748
+ const role = (rec.el.dataset.explodeRole || 'copy').trim();
749
+ if (['surface', 'image', 'icon', 'decoration'].includes(role)) return false;
750
+ return Boolean((rec.el.innerText || rec.el.textContent || '').replace(/\s+/g, ' ').trim());
751
+ };
752
+ const supplementalTextRecords = primaryContentRecords.length < 5
753
+ ? layerRecords.filter((rec) => grouped.has(rec.id) && isTextLayer(rec))
754
+ .map((rec) => ({ ...rec, supplementalText: true }))
755
+ : [];
756
+ const contentRecords = [...primaryContentRecords, ...supplementalTextRecords]
757
+ .sort((a, b) => a.index - b.index)
758
+ .map((rec) => ({ ...rec, motionOrder: Math.min(12, rec.index + 1) }));
759
+ const full = { left: 0, top: 0, width: CW, height: CH };
760
+ const overview = {
761
+ kind: 'overview', id: '__overview__', label: '完整成稿总览', index: -2,
762
+ motionOrder: 0, g: full, crop: full, area: CW * CH,
763
+ };
764
+ const background = {
765
+ kind: 'background', id: '__background__', label: '背景基底', index: Number.MAX_SAFE_INTEGER,
766
+ motionOrder: 13, g: full, crop: full, area: CW * CH,
767
+ };
768
+ const records = [overview, ...contentRecords, background];
769
+ board.dataset.explodedPrimaryCount = primaryContentRecords.length;
770
+ board.dataset.explodedTextSupplements = supplementalTextRecords.length;
771
+
772
+ // 动画原点仍是一张可读的完整成稿。所有 tile 先叠回这个矩形,
773
+ // 扫描结束后再散开;overview 在最上层保证起始画面像素完整。
774
+ const originScale = Math.min((width * 0.52) / CW, (height * 0.86) / CH, 1);
775
+ const originW = CW * originScale, originH = CH * originScale;
776
+ const originLeft = (width - originW) / 2, originTop = (height - originH) / 2;
777
+
778
+ // 扫描层覆盖“尚未拆开”的中央成稿。它只负责视觉提示,不参与
779
+ // 几何和导出图层计数;扫描结束后保持透明,由同一 WAAPI 时间线驱动。
780
+ const scan = Object.assign(document.createElement('div'), {
781
+ className: 'hf-exploded-scan', ariaHidden: 'true',
782
+ });
783
+ Object.assign(scan.style, {
784
+ left: px(originLeft), top: px(originTop), width: px(originW), height: px(originH),
785
+ });
786
+ scan.append(
787
+ Object.assign(document.createElement('div'), { className: 'hf-exploded-scan-grid' }),
788
+ Object.assign(document.createElement('div'), { className: 'hf-exploded-scan-beam' }),
789
+ );
790
+ board.appendChild(scan);
791
+
792
+ // 组件数少时两列、常规三列、极多时最多五列。列数只由组件数决定,
793
+ // 不依赖海报题材;原始横向位置仅作为轻量偏好,避免完全打乱拓扑。
794
+ const laneCount = Math.max(2, Math.min(5, Math.ceil(records.length / 5)));
795
+ const laneGap = width * 0.022;
796
+ const laneWidth = (width - margin * 2 - laneGap * (laneCount - 1)) / laneCount;
797
+ const lanes = Array.from({ length: laneCount }, () => []);
798
+ const weights = Array(laneCount).fill(0);
799
+ const addToLane = (rec, lane, weight = 1) => {
800
+ lanes[lane].push(rec);
801
+ weights[lane] += weight;
802
+ };
803
+ addToLane(overview, 0, .95);
804
+ addToLane(background, laneCount - 1, .95);
805
+ for (const rec of [...contentRecords].sort((a, b) => b.area - a.area)) {
806
+ const desired = Math.round(((rec.g.left + rec.g.width / 2) / CW) * (laneCount - 1));
807
+ let lane = 0, best = Infinity;
808
+ for (let i = 0; i < laneCount; i++) {
809
+ const score = weights[i] + Math.abs(i - desired) * .16;
810
+ if (score < best) { best = score; lane = i; }
811
+ }
812
+ addToLane(rec, lane, Math.min(1.35, rec.g.height / CH * .72 + rec.g.width / CW * .28));
813
+ }
814
+
815
+ const place = (items, laneIndex) => {
816
+ items.sort((a, b) => {
817
+ const ar = a.kind === 'overview' || a.kind === 'background' ? -1 : a.g.top;
818
+ const br = b.kind === 'overview' || b.kind === 'background' ? -1 : b.g.top;
819
+ return ar - br || a.index - b.index;
820
+ });
821
+ if (!items.length) return;
822
+ const usableH = height - margin * 2;
823
+ const minGap = height * 0.022;
824
+ const prepared = items.map((rec) => ({
825
+ rec,
826
+ scale: rec.kind === 'overview' || rec.kind === 'background'
827
+ ? Math.min(.42, laneWidth / rec.crop.width, height * .34 / rec.crop.height)
828
+ : Math.min(.86, laneWidth / rec.crop.width,
829
+ height * (items.length <= 3 ? .30 : .24) / rec.crop.height),
830
+ }));
831
+ const rawH = prepared.reduce((sum, p) => sum + p.rec.crop.height * p.scale, 0);
832
+ const roomForItems = Math.max(1, usableH - minGap * Math.max(0, items.length - 1));
833
+ const shrink = rawH > roomForItems ? roomForItems / rawH : 1;
834
+ for (const p of prepared) p.scale *= shrink;
835
+ const usedH = prepared.reduce((sum, p) => sum + p.rec.crop.height * p.scale, 0);
836
+ const gap = prepared.length > 1
837
+ ? Math.max(minGap, Math.min(height * .055, (usableH - usedH) / (prepared.length - 1))) : 0;
838
+ const blockH = usedH + gap * Math.max(0, prepared.length - 1);
839
+ let y = margin + Math.max(0, (usableH - blockH) / 2);
840
+ const laneX = margin + laneIndex * (laneWidth + laneGap);
841
+ for (const p of prepared) {
842
+ const tile = makeExplodedItem(p.rec, p.scale, interactive);
843
+ const tw = p.rec.crop.width * p.scale;
844
+ const left = laneX + (laneWidth - tw) / 2;
845
+ Object.assign(tile.style, { left: px(left), top: px(y) });
846
+ tile.dataset.motionFromLeft = round(originLeft + p.rec.crop.left * originScale);
847
+ tile.dataset.motionFromTop = round(originTop + p.rec.crop.top * originScale);
848
+ tile.dataset.motionFromScale = round(originScale / p.scale);
849
+ tile.dataset.motionOrder = p.rec.motionOrder;
850
+ const tiltIndex = Number.isFinite(p.rec.index) ? Math.abs(p.rec.index) : laneIndex;
851
+ tile.dataset.motionTilt = round((tiltIndex % 2 ? 1 : -1) * (0.7 + tiltIndex % 3 * 0.35));
852
+ y += p.rec.crop.height * p.scale + gap;
853
+ board.appendChild(tile);
854
+ }
855
+ };
856
+ lanes.forEach(place);
857
+ board.addEventListener('pointermove', () => {
858
+ if (board.classList.contains('hf-exploded-settled')) {
859
+ board.classList.add('hf-exploded-interacted');
860
+ }
861
+ }, { passive: true });
862
+ return {
863
+ board, width, height, layerCount: layerRecords.length, level: 1,
864
+ laneCount, laneWidth, originScale, theme,
865
+ primaryContentCount: primaryContentRecords.length,
866
+ supplementalTextCount: supplementalTextRecords.length,
867
+ };
868
+ }
869
+
870
+ function stopExplodedMotion() {
871
+ for (const a of state.motion) try { a.cancel(); } catch {}
872
+ state.motion = [];
873
+ state.motionToken = null;
874
+ }
875
+
876
+ async function animateExploded(board, reverse = false) {
877
+ stopExplodedMotion();
878
+ if (!board || motionOff() || !Element.prototype.animate) {
879
+ board?.classList.toggle('hf-exploded-settled', !reverse);
880
+ return;
881
+ }
882
+ const token = Symbol('explode-motion');
883
+ state.motionToken = token;
884
+ board.classList.add('hf-exploded-animating');
885
+ board.classList.remove('hf-exploded-settled', 'hf-exploded-interacted');
886
+ const items = [...board.querySelectorAll('.hf-exploded-item')];
887
+ const maxOrder = Math.max(0, ...items.map((n) => +n.dataset.motionOrder || 0));
888
+ const animations = [];
889
+
890
+ if (!reverse) {
891
+ const scan = board.querySelector('.hf-exploded-scan');
892
+ const grid = board.querySelector('.hf-exploded-scan-grid');
893
+ const beam = board.querySelector('.hf-exploded-scan-beam');
894
+ const scanH = parseFloat(scan?.style.height) || 0;
895
+ if (scan) animations.push(scan.animate([
896
+ { opacity: 0 },
897
+ { offset: .08, opacity: 1 },
898
+ { offset: .86, opacity: 1 },
899
+ { opacity: 0 },
900
+ ], { duration: MOTION.scan, easing: 'linear', fill: 'both' }));
901
+ if (grid) animations.push(grid.animate([
902
+ { opacity: 0 },
903
+ { offset: .16, opacity: .56 },
904
+ { offset: .78, opacity: .28 },
905
+ { opacity: 0 },
906
+ ], { duration: MOTION.scan, easing: MOTION.ease, fill: 'both' }));
907
+ if (beam) animations.push(beam.animate([
908
+ { transform: `translateY(${round(-scanH * .2)}px)`, opacity: 0 },
909
+ { offset: .08, opacity: 1 },
910
+ { offset: .92, opacity: 1 },
911
+ { transform: `translateY(${round(scanH * 1.02)}px)`, opacity: 0 },
912
+ ], { duration: MOTION.scan, easing: 'cubic-bezier(.34,0,.25,1)', fill: 'both' }));
913
+ }
914
+
915
+ for (const item of items) {
916
+ const left = parseFloat(item.style.left) || 0;
917
+ const top = parseFloat(item.style.top) || 0;
918
+ const dx = (+item.dataset.motionFromLeft || left) - left;
919
+ const dy = (+item.dataset.motionFromTop || top) - top;
920
+ const fromScale = +item.dataset.motionFromScale || 1;
921
+ const order = Math.min(12, +item.dataset.motionOrder || 0);
922
+ const tilt = +item.dataset.motionTilt || 0;
923
+ const origin = `translate(${round(dx)}px,${round(dy)}px) scale(${round(fromScale)}) rotate(0deg)`;
924
+ const overshoot = `translate(0,0) scale(1.018) rotate(${tilt}deg)`;
925
+ const frames = reverse ? [
926
+ { transform: 'translate(0,0) scale(1) rotate(0deg)', opacity: 1, filter: 'blur(0)' },
927
+ { offset: .2, transform: overshoot, opacity: 1, filter: 'blur(0)' },
928
+ { transform: origin, opacity: .56, filter: 'blur(1.5px)' },
929
+ ] : [
930
+ { transform: origin, opacity: 1, filter: 'blur(0)' },
931
+ { offset: .12, transform: origin, opacity: 1, filter: 'blur(0)' },
932
+ { offset: .74, transform: overshoot, opacity: 1, filter: 'blur(0)' },
933
+ { transform: 'translate(0,0) scale(1) rotate(0deg)', opacity: 1, filter: 'blur(0)' },
934
+ ];
935
+ const delay = reverse ? (maxOrder - order) * 34
936
+ : MOTION.scan * MOTION.scanOverlap + 70 + order * MOTION.stagger;
937
+ animations.push(item.animate(frames, {
938
+ duration: reverse ? MOTION.reverse : MOTION.duration,
939
+ delay, easing: MOTION.ease, fill: 'both',
940
+ }));
941
+ }
942
+
943
+ state.motion = animations;
944
+ await Promise.allSettled(animations.map((a) => a.finished));
945
+ if (state.motionToken !== token) return;
946
+ for (const a of animations) a.cancel();
947
+ state.motion = [];
948
+ state.motionToken = null;
949
+ board.classList.remove('hf-exploded-animating');
950
+ board.classList.toggle('hf-exploded-settled', !reverse);
951
+ }
952
+
953
+ function syncExplodedUI(on) {
954
+ document.body.classList.toggle('hf-exploded-on', on);
955
+ $('explode').classList.toggle('active', on);
956
+ $('explode').textContent = on ? '返回编辑' : '拆解视图';
957
+ $('undo').disabled = on || !state.undo.length;
958
+ $('reset').disabled = on;
959
+ $('css').disabled = on;
960
+ }
961
+
962
+ async function setExploded(on = !state.exploded) {
963
+ if (state.transitioning || on === state.exploded) return;
964
+ state.transitioning = true;
965
+ $('explode').disabled = true;
966
+ endEdit(true);
967
+ try {
968
+ if (on) {
969
+ state.spreadGeometry = new Map(state.layers.map(({ el, id }) => [id, geom(el)]));
970
+ if (state.spread?.board) state.spread.board.remove();
971
+ state.spread = makeExplodedBoard(true);
972
+ state.exploded = true;
973
+ syncExplodedUI(true);
974
+ select(null);
975
+ stage.appendChild(state.spread.board);
976
+ stage.parentElement.style.setProperty('--hf-exploded-bg', state.spread.theme.bg);
977
+ setScale(fitScale());
978
+ await animateExploded(state.spread.board, false);
979
+ } else {
980
+ await animateExploded(state.spread?.board, true);
981
+ state.spread?.board.remove();
982
+ state.spread = null;
983
+ state.exploded = false;
984
+ syncExplodedUI(false);
985
+ stage.parentElement.style.removeProperty('--hf-exploded-bg');
986
+ setScale(fitScale());
987
+ }
988
+ } finally {
989
+ state.transitioning = false;
990
+ $('explode').disabled = false;
991
+ }
992
+ }
993
+
994
+ /* ---------------------------------------------------------------
995
+ * 5. 拖拽
996
+ * ------------------------------------------------------------- */
997
+ const geom = (el) => ({
998
+ left: parseFloat(el.style.left) || 0,
999
+ top: parseFloat(el.style.top) || 0,
1000
+ // 拆解视图会暂时 display:none 原画布;此时 offsetWidth/Height 为 0,
1001
+ // 仍要能从固化后的内联尺寸重建并下载拆解稿。
1002
+ width: el.offsetWidth || parseFloat(el.style.width) || parseFloat(getComputedStyle(el).width) || 0,
1003
+ height: el.offsetHeight || parseFloat(el.style.height) || parseFloat(getComputedStyle(el).height) || 0,
1004
+ });
1005
+
1006
+ let drag = null;
1007
+
1008
+ const releaseCapture = (el, pointerId) => {
1009
+ try {
1010
+ if (el?.hasPointerCapture?.(pointerId)) el.releasePointerCapture(pointerId);
1011
+ } catch { /* 指针可能已经被浏览器释放 */ }
1012
+ };
1013
+
1014
+ const discardGestureUndo = (type, el) => {
1015
+ const last = state.undo[state.undo.length - 1];
1016
+ if (last?.type === type && last.el === el) state.undo.pop();
1017
+ $('undo').disabled = !state.undo.length;
1018
+ };
1019
+
1020
+ canvas.addEventListener('pointerdown', (e) => {
1021
+ if (edit && edit.slot.contains(e.target)) return; // 编辑框内,光标定位交给浏览器
1022
+ const el = e.target.closest('[data-layer-id]');
1023
+ if (!el) { select(null); return; }
1024
+ // 这里刻意不调 preventDefault:在 pointerdown 上阻止默认行为会连带抑制
1025
+ // click / dblclick 兼容事件,双击进入文本编辑就失效了。防止拖拽误选
1026
+ // 文字改由下面「真正拖起来之后」再禁用 user-select。
1027
+ select(el);
1028
+ const g = geom(el);
1029
+ drag = { el, id: e.pointerId, sx: e.clientX, sy: e.clientY, ox: g.left, oy: g.top, w: g.width, h: g.height, moved: false };
1030
+ // 指针捕获推迟到确认拖拽之后:一旦在 pointerdown 阶段就捕获,后续
1031
+ // click / dblclick 的 target 会被重定向到捕获元素,双击时拿到的就是
1032
+ // 整个图层而不是被点的那个文本槽。
1033
+ });
1034
+
1035
+ canvas.addEventListener('pointermove', (e) => {
1036
+ if (!drag) return;
1037
+ if (!(e.buttons & 1)) return finishDrag(true);
1038
+ // 画布被 scale 过,屏幕位移要换算回画布坐标才不会跟手偏移
1039
+ const dx = (e.clientX - drag.sx) / state.scale;
1040
+ const dy = (e.clientY - drag.sy) / state.scale;
1041
+ if (!drag.moved && Math.hypot(dx, dy) > 2) {
1042
+ drag.moved = true;
1043
+ drag.el.setPointerCapture(drag.id); // 捕获后指针滑出元素范围也不会「掉手」
1044
+ document.body.style.userSelect = 'none';
1045
+ getSelection().removeAllRanges();
1046
+ pushUndo({ type: 'move', el: drag.el, left: drag.ox, top: drag.oy });
1047
+ }
1048
+ const snapped = e.altKey
1049
+ ? { left: drag.ox + dx, top: drag.oy + dy, lines: [] }
1050
+ : snap(drag.el, drag.ox + dx, drag.oy + dy, drag.w, drag.h);
1051
+ move(drag.el, snapped.left, snapped.top);
1052
+ drawGuides(snapped.lines);
1053
+ });
1054
+
1055
+ function finishDrag(commit = true) {
1056
+ if (!drag) return;
1057
+ const current = drag;
1058
+ drag = null;
1059
+ if (!commit && current.moved) {
1060
+ Object.assign(current.el.style, { left: px(current.ox), top: px(current.oy) });
1061
+ discardGestureUndo('move', current.el);
1062
+ syncPanel();
1063
+ } else if (current.moved) touch();
1064
+ releaseCapture(current.el, current.id);
1065
+ drawGuides([]);
1066
+ document.body.style.userSelect = '';
1067
+ }
1068
+ const endDrag = () => finishDrag(true);
1069
+ const cancelDrag = () => finishDrag(false);
1070
+ canvas.addEventListener('pointerup', endDrag);
1071
+ canvas.addEventListener('pointercancel', endDrag);
1072
+ canvas.addEventListener('lostpointercapture', endDrag);
1073
+ window.addEventListener('pointerup', endDrag, true);
1074
+ window.addEventListener('pointercancel', endDrag, true);
1075
+
1076
+ // 图片图层不拦的话会走浏览器原生的「拖动图片」
1077
+ canvas.addEventListener('dragstart', (e) => e.preventDefault());
1078
+
1079
+ function move(el, left, top) {
1080
+ el.style.left = px(left);
1081
+ el.style.top = px(top);
1082
+ syncPanel();
1083
+ }
1084
+
1085
+ // 选框放在未缩放的舞台坐标系中,手柄始终保持可点击的屏幕尺寸;
1086
+ // 图层几何与指针位移则在画布坐标系内计算。
1087
+ function updateResizeOverlay() {
1088
+ if (!stage || !state.sel || state.exploded || edit) {
1089
+ resizeOverlay.hidden = true;
1090
+ return;
1091
+ }
1092
+ const g = geom(state.sel);
1093
+ Object.assign(resizeOverlay.style, {
1094
+ left: px(g.left * state.scale),
1095
+ top: px(g.top * state.scale),
1096
+ width: px(g.width * state.scale),
1097
+ height: px(g.height * state.scale),
1098
+ });
1099
+ resizeOverlay.hidden = false;
1100
+ }
1101
+
1102
+ let resize = null;
1103
+
1104
+ resizeOverlay.addEventListener('pointerdown', (e) => {
1105
+ const handle = e.target.closest('[data-hf-resize-dir]');
1106
+ if (!handle || !state.sel || edit) return;
1107
+ e.preventDefault();
1108
+ e.stopPropagation();
1109
+ const g = geom(state.sel);
1110
+ resize = {
1111
+ el: state.sel, handle, id: e.pointerId, dir: handle.dataset.hfResizeDir,
1112
+ sx: e.clientX, sy: e.clientY,
1113
+ left: g.left, top: g.top, width: g.width, height: g.height,
1114
+ widthLocked: state.sel.dataset.hfWidthLocked === '1',
1115
+ heightLocked: state.sel.dataset.hfHeightLocked === '1',
1116
+ moved: false,
1117
+ };
1118
+ handle.setPointerCapture(e.pointerId);
1119
+ handle.classList.add('active');
1120
+ });
1121
+
1122
+ resizeOverlay.addEventListener('pointermove', (e) => {
1123
+ if (!resize || e.pointerId !== resize.id) return;
1124
+ if (!(e.buttons & 1)) return finishResize(true, e);
1125
+ const dx = (e.clientX - resize.sx) / state.scale;
1126
+ const dy = (e.clientY - resize.sy) / state.scale;
1127
+ if (!resize.moved && Math.hypot(dx, dy) > 1) {
1128
+ resize.moved = true;
1129
+ document.body.style.userSelect = 'none';
1130
+ getSelection().removeAllRanges();
1131
+ pushUndo({
1132
+ type: 'resize', el: resize.el,
1133
+ left: resize.left, top: resize.top,
1134
+ width: resize.width, height: resize.height,
1135
+ widthLocked: resize.widthLocked, heightLocked: resize.heightLocked,
1136
+ });
1137
+ }
1138
+ if (!resize.moved) return;
1139
+
1140
+ const right = resize.left + resize.width;
1141
+ const bottom = resize.top + resize.height;
1142
+ let left = resize.left, top = resize.top;
1143
+ let width = resize.width, height = resize.height;
1144
+
1145
+ if (resize.dir.includes('w')) {
1146
+ left = Math.max(0, Math.min(resize.left + dx, right - MIN_W));
1147
+ width = right - left;
1148
+ } else if (resize.dir.includes('e')) {
1149
+ width = Math.max(MIN_W, Math.min(CW - resize.left, resize.width + dx));
1150
+ }
1151
+ if (resize.dir.includes('n')) {
1152
+ top = Math.max(0, Math.min(resize.top + dy, bottom - MIN_H));
1153
+ height = bottom - top;
1154
+ } else if (resize.dir.includes('s')) {
1155
+ height = Math.max(MIN_H, Math.min(CH - resize.top, resize.height + dy));
1156
+ }
1157
+
1158
+ Object.assign(resize.el.style, {
1159
+ left: px(left), top: px(top), width: px(width), height: px(height),
1160
+ });
1161
+ if (resize.dir.includes('w') || resize.dir.includes('e')) resize.el.dataset.hfWidthLocked = '1';
1162
+ if (resize.dir.includes('n') || resize.dir.includes('s')) resize.el.dataset.hfHeightLocked = '1';
1163
+ syncPanel();
1164
+ });
1165
+
1166
+ function finishResize(commit = true, e) {
1167
+ if (!resize || (e && e.pointerId !== resize.id)) return;
1168
+ const current = resize;
1169
+ resize = null;
1170
+ current.handle.classList.remove('active');
1171
+ if (!commit && current.moved) {
1172
+ Object.assign(current.el.style, {
1173
+ left: px(current.left), top: px(current.top),
1174
+ width: px(current.width), height: px(current.height),
1175
+ });
1176
+ if (current.widthLocked) current.el.dataset.hfWidthLocked = '1';
1177
+ else delete current.el.dataset.hfWidthLocked;
1178
+ if (current.heightLocked) current.el.dataset.hfHeightLocked = '1';
1179
+ else delete current.el.dataset.hfHeightLocked;
1180
+ discardGestureUndo('resize', current.el);
1181
+ syncPanel();
1182
+ } else if (current.moved) touch();
1183
+ releaseCapture(current.handle, current.id);
1184
+ document.body.style.userSelect = '';
1185
+ updateResizeOverlay();
1186
+ }
1187
+ const endResize = (e) => finishResize(true, e);
1188
+ const cancelResize = () => finishResize(false);
1189
+ resizeOverlay.addEventListener('pointerup', endResize);
1190
+ resizeOverlay.addEventListener('pointercancel', endResize);
1191
+ resizeOverlay.addEventListener('lostpointercapture', endResize);
1192
+ window.addEventListener('pointerup', endResize, true);
1193
+ window.addEventListener('pointercancel', endResize, true);
1194
+
1195
+ const finishPointerGestures = () => {
1196
+ finishDrag(true);
1197
+ finishResize(true);
1198
+ };
1199
+ document.addEventListener('pointerdown', finishPointerGestures, true);
1200
+ window.addEventListener('blur', finishPointerGestures);
1201
+ document.addEventListener('visibilitychange', () => {
1202
+ if (document.hidden) finishPointerGestures();
1203
+ });
1204
+
1205
+ // 很矮或很窄的文字层中,边中点手柄可能覆盖文字的视觉中心。
1206
+ // 双击手柄时临时移开选框重新命中,让原本的双击编辑仍可用。
1207
+ resizeOverlay.addEventListener('dblclick', (e) => {
1208
+ if (!state.sel || resize?.moved) return;
1209
+ e.preventDefault();
1210
+ e.stopPropagation();
1211
+ resizeOverlay.hidden = true;
1212
+ const slot = document.elementFromPoint(e.clientX, e.clientY)?.closest('.hf-slot');
1213
+ resizeOverlay.hidden = false;
1214
+ if (slot && state.sel.contains(slot)) beginEdit(slot, state.sel);
1215
+ });
1216
+
1217
+ /* ---------------------------------------------------------------
1218
+ * 6. 吸附:对齐画布中线/边缘,以及其它图层的边和中心
1219
+ * ------------------------------------------------------------- */
1220
+ function snap(el, left, top, w, h) {
1221
+ const vt = [0, CW / 2, CW], ht = [0, CH / 2, CH];
1222
+ for (const { el: o } of state.layers) {
1223
+ if (o === el) continue;
1224
+ const g = geom(o);
1225
+ vt.push(g.left, g.left + g.width / 2, g.left + g.width);
1226
+ ht.push(g.top, g.top + g.height / 2, g.top + g.height);
1227
+ }
1228
+ const axis = (edges, targets) => {
1229
+ let best = null;
1230
+ for (const edge of edges) {
1231
+ for (const t of targets) {
1232
+ const d = Math.abs(edge - t);
1233
+ if (d <= SNAP && (!best || d < best.d)) best = { d, delta: t - edge, at: t };
1234
+ }
1235
+ }
1236
+ return best;
1237
+ };
1238
+ const v = axis([left, left + w / 2, left + w], vt);
1239
+ const hh = axis([top, top + h / 2, top + h], ht);
1240
+ const lines = [];
1241
+ if (v) lines.push({ dir: 'v', at: v.at });
1242
+ if (hh) lines.push({ dir: 'h', at: hh.at });
1243
+ return { left: left + (v ? v.delta : 0), top: top + (hh ? hh.delta : 0), lines };
1244
+ }
1245
+
1246
+ function drawGuides(lines) {
1247
+ guides.textContent = '';
1248
+ for (const l of lines) {
1249
+ const d = document.createElement('div');
1250
+ d.className = 'hf-guide ' + l.dir;
1251
+ d.style[l.dir === 'v' ? 'left' : 'top'] = l.at * state.scale + 'px';
1252
+ guides.appendChild(d);
1253
+ }
1254
+ }
1255
+
1256
+ /* ---------------------------------------------------------------
1257
+ * 7. 选中、撤销、面板
1258
+ * ------------------------------------------------------------- */
1259
+ function select(el) {
1260
+ if (state.sel) state.sel.classList.remove('hf-sel');
1261
+ state.sel = el;
1262
+ if (el) el.classList.add('hf-sel');
1263
+ syncPanel();
1264
+ }
1265
+
1266
+ // 位置 / 文字 / 样式 / 删除共用一个栈,保证 ⌘Z 的时间顺序符合直觉
1267
+ function pushUndo(entry) {
1268
+ state.undo.push(entry);
1269
+ $('undo').disabled = false;
1270
+ }
1271
+
1272
+ function undo() {
1273
+ const last = state.undo.pop();
1274
+ if (last) {
1275
+ if (last.type === 'text') {
1276
+ last.slot.innerHTML = last.html;
1277
+ refit(last.layer);
1278
+ } else if (last.type === 'style') {
1279
+ last.slots.forEach((s, i) => { s.style[last.prop] = last.before[i]; });
1280
+ refit(last.layer);
1281
+ } else if (last.type === 'remove') {
1282
+ last.parent.insertBefore(last.el, last.next);
1283
+ state.layers.splice(last.idx, 0, { el: last.el, id: last.id });
1284
+ state.removed = state.removed.filter((id) => id !== last.id);
1285
+ rebuildPanel();
1286
+ } else if (last.type === 'resize') {
1287
+ Object.assign(last.el.style, {
1288
+ left: px(last.left), top: px(last.top),
1289
+ width: px(last.width), height: px(last.height),
1290
+ });
1291
+ if (last.widthLocked) last.el.dataset.hfWidthLocked = '1';
1292
+ else delete last.el.dataset.hfWidthLocked;
1293
+ if (last.heightLocked) last.el.dataset.hfHeightLocked = '1';
1294
+ else delete last.el.dataset.hfHeightLocked;
1295
+ syncPanel();
1296
+ } else {
1297
+ move(last.el, last.left, last.top);
1298
+ }
1299
+ }
1300
+ $('undo').disabled = !state.undo.length;
1301
+ touch();
1302
+ }
1303
+
1304
+ function rebuildPanel() {
1305
+ const list = $('layers');
1306
+ list.textContent = '';
1307
+ for (const { el, id } of state.layers) {
1308
+ const row = h('<div class="hf-layer"><span class="dot"></span><span class="nm"></span><span class="pos"></span></div>');
1309
+ row.querySelector('.nm').textContent = id;
1310
+ row.addEventListener('click', async () => {
1311
+ if (state.exploded) await setExploded(false);
1312
+ select(el);
1313
+ });
1314
+ list.appendChild(row);
1315
+ }
1316
+ syncPanel();
1317
+ }
1318
+
1319
+ function syncPanel() {
1320
+ const list = $('layers');
1321
+ if (list.children.length !== state.layers.length) return rebuildPanel();
1322
+ state.layers.forEach(({ el }, i) => {
1323
+ const row = list.children[i];
1324
+ const g = geom(el);
1325
+ row.classList.toggle('sel', el === state.sel);
1326
+ row.querySelector('.pos').textContent = `${Math.round(g.left)}, ${Math.round(g.top)} · ${Math.round(g.width)}×${Math.round(g.height)}`;
1327
+ });
1328
+ updateResizeOverlay();
1329
+ syncProps();
1330
+ }
1331
+
1332
+ function syncProps() {
1333
+ const box = $('props');
1334
+ if (!state.sel) { box.hidden = true; return; }
1335
+ box.hidden = false;
1336
+ const g = geom(state.sel);
1337
+ $('size-val').textContent = `${Math.round(g.width)} × ${Math.round(g.height)} px`;
1338
+ const slots = slotsOf(state.sel);
1339
+ if (slots.length) {
1340
+ $('fs-val').textContent = Math.round(parseFloat(getComputedStyle(slots[0]).fontSize)) + 'px';
1341
+ $('fs-up').disabled = $('fs-down').disabled = false;
1342
+ } else {
1343
+ $('fs-val').textContent = '无文字';
1344
+ $('fs-up').disabled = $('fs-down').disabled = true;
1345
+ }
1346
+ $('ff').disabled = !slots.length;
1347
+ }
1348
+
1349
+ /* ---------------------------------------------------------------
1350
+ * 8. 文本编辑
1351
+ * ------------------------------------------------------------- */
1352
+ let edit = null;
1353
+
1354
+ function beginEdit(slot, layer) {
1355
+ if (edit) endEdit(true);
1356
+ edit = { slot, layer, html: slot.innerHTML };
1357
+ slot.setAttribute('contenteditable', 'plaintext-only');
1358
+ slot.classList.add('hf-editing');
1359
+ updateResizeOverlay();
1360
+ slot.focus();
1361
+ const range = document.createRange();
1362
+ range.selectNodeContents(slot);
1363
+ const sel = getSelection();
1364
+ sel.removeAllRanges();
1365
+ sel.addRange(range);
1366
+ }
1367
+
1368
+ function endEdit(commit = true) {
1369
+ if (!edit) return;
1370
+ const { slot, layer, html } = edit;
1371
+ edit = null;
1372
+ slot.removeAttribute('contenteditable');
1373
+ slot.classList.remove('hf-editing');
1374
+ if (!commit) {
1375
+ slot.innerHTML = html;
1376
+ } else if (slot.innerHTML !== html) {
1377
+ pushUndo({ type: 'text', slot, layer, html });
1378
+ refit(layer);
1379
+ touch();
1380
+ }
1381
+ getSelection().removeAllRanges();
1382
+ syncPanel();
1383
+ }
1384
+
1385
+ // 文字变长或字号变大后会被图层锁定的宽度挤到换行。这里按需扩宽,
1386
+ // 但不主动收窄,免得吃掉版式上刻意留白的宽度。用户用缩放手柄
1387
+ // 明确设过宽度后则保留该宽度,让文字按用户意图换行。
1388
+ function refit(layer) {
1389
+ if (layer.dataset.hfWidthLocked === '1') {
1390
+ syncPanel();
1391
+ return;
1392
+ }
1393
+ const cur = parseFloat(layer.style.width) || 0;
1394
+ const left = parseFloat(layer.style.left) || 0;
1395
+ layer.style.width = 'max-content';
1396
+ const natural = layer.getBoundingClientRect().width / state.scale;
1397
+ layer.style.width = px(Math.min(Math.max(cur, natural), CW - left));
1398
+ syncPanel();
1399
+ }
1400
+
1401
+ canvas.addEventListener('dblclick', (e) => {
1402
+ // 按坐标重新做命中测试,而不是信任 e.target:指针一旦被捕获,
1403
+ // dblclick 的 target 会被重定向到捕获元素,拿不到真正被点的文本槽。
1404
+ const slot = document.elementFromPoint(e.clientX, e.clientY)?.closest('.hf-slot');
1405
+ const layer = slot?.closest('[data-layer-id]');
1406
+ if (slot && layer) beginEdit(slot, layer);
1407
+ });
1408
+
1409
+ // 点到别处即提交。capture 阶段处理,抢在拖拽逻辑之前。
1410
+ document.addEventListener('pointerdown', (e) => {
1411
+ if (edit && !edit.slot.contains(e.target)) endEdit(true);
1412
+ }, true);
1413
+
1414
+ document.addEventListener('keydown', (e) => {
1415
+ if (!edit) return;
1416
+ // 输入法组字期间按键一律归输入法,此时的 Esc 是取消组字而不是退出编辑
1417
+ if (e.isComposing || e.keyCode === 229) return;
1418
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); endEdit(true); }
1419
+ else if (e.key === 'Escape') { e.preventDefault(); endEdit(false); }
1420
+ e.stopPropagation(); // 方向键交给光标,别拿去挪图层
1421
+ }, true);
1422
+
1423
+ /* ---------------------------------------------------------------
1424
+ * 9. 字号与字体
1425
+ *
1426
+ * 作用对象是图层内的全部文本槽,不引入「选中子元素」这一层交互。
1427
+ * 字号按比例缩放而非绝对赋值,一对 A−/A+ 即可覆盖微调场景。
1428
+ * ------------------------------------------------------------- */
1429
+ function applyStyle(layer, prop, valueOf) {
1430
+ const slots = slotsOf(layer);
1431
+ if (!slots.length) return;
1432
+ const before = slots.map((s) => s.style[prop]);
1433
+ slots.forEach((s) => { s.style[prop] = valueOf(s); });
1434
+ pushUndo({ type: 'style', slots, layer, prop, before });
1435
+ refit(layer);
1436
+ touch();
1437
+ }
1438
+
1439
+ const scaleFont = (layer, factor) => applyStyle(layer, 'fontSize',
1440
+ (s) => round(parseFloat(getComputedStyle(s).fontSize) * factor) + 'px');
1441
+
1442
+ const setFont = (layer, value) => applyStyle(layer, 'fontFamily', () => value);
1443
+
1444
+ /* 字体选项只从海报 :root 已声明的字体族变量里取。
1445
+ 不提供任意字体:那需要动态注入 @font-face,中文字体动辄数 MB,
1446
+ 且导出后若引用丢失会静默回落成系统字体。 */
1447
+ function fontOptions() {
1448
+ const out = [], seen = new Set();
1449
+ for (const sheet of document.styleSheets) {
1450
+ let rules;
1451
+ try { rules = sheet.cssRules; } catch { continue; } // 跨域样式表(如 Google Fonts)不可读
1452
+ for (const rule of rules) {
1453
+ if (!rule.style || !rule.selectorText) continue;
1454
+ if (!/(^|,)\s*(:root|html)\s*(,|$)/.test(rule.selectorText)) continue;
1455
+ for (const prop of rule.style) {
1456
+ if (!prop.startsWith('--') || seen.has(prop)) continue;
1457
+ const val = rule.style.getPropertyValue(prop).trim();
1458
+ if (!/serif|sans|mono|cursive|fantasy/i.test(val)) continue;
1459
+ seen.add(prop);
1460
+ out.push({ label: prop.slice(2), value: `var(${prop})` });
1461
+ }
1462
+ }
1463
+ }
1464
+ return out;
1465
+ }
1466
+
1467
+ function buildFontSelect() {
1468
+ const opts = fontOptions();
1469
+ if (!opts.length) { $('ff-field').hidden = true; return; }
1470
+ const sel = $('ff');
1471
+ sel.append(h('<option value="">(保持原样)</option>'));
1472
+ for (const o of opts) {
1473
+ const el = h('<option></option>');
1474
+ el.value = o.value;
1475
+ el.textContent = o.label;
1476
+ sel.append(el);
1477
+ }
1478
+ sel.addEventListener('change', () => {
1479
+ if (state.sel && sel.value) setFont(state.sel, sel.value);
1480
+ sel.value = '';
1481
+ });
1482
+ }
1483
+
1484
+ /* ---------------------------------------------------------------
1485
+ * 10. 删除图层
1486
+ * ------------------------------------------------------------- */
1487
+ function removeLayer(layer) {
1488
+ const idx = state.layers.findIndex((l) => l.el === layer);
1489
+ if (idx < 0) return;
1490
+ const { id } = state.layers[idx];
1491
+ pushUndo({ type: 'remove', el: layer, id, idx, parent: layer.parentNode, next: layer.nextSibling });
1492
+ layer.remove();
1493
+ state.layers.splice(idx, 1);
1494
+ state.removed.push(id);
1495
+ state.sel = null;
1496
+ rebuildPanel();
1497
+ touch();
1498
+ toast(`已删除 ${id}`);
1499
+ }
1500
+
1501
+ /* ---------------------------------------------------------------
1502
+ * 11. 草稿:localStorage 自动保存,刷新与误关不丢
1503
+ * ------------------------------------------------------------- */
1504
+ let draftTimer;
1505
+ function touch() {
1506
+ clearTimeout(draftTimer);
1507
+ draftTimer = setTimeout(saveDraft, 500);
1508
+ }
1509
+
1510
+ function snapshot() {
1511
+ return {
1512
+ at: Date.now(),
1513
+ removed: state.removed,
1514
+ layers: state.layers.map(({ el, id }) => ({
1515
+ id,
1516
+ left: parseFloat(el.style.left) || 0,
1517
+ top: parseFloat(el.style.top) || 0,
1518
+ width: el.style.width || '',
1519
+ height: el.style.height || '',
1520
+ manualWidth: el.dataset.hfWidthLocked === '1',
1521
+ manualHeight: el.dataset.hfHeightLocked === '1',
1522
+ slots: slotsOf(el).map((s) => ({
1523
+ html: s.innerHTML, fs: s.style.fontSize || '', ff: s.style.fontFamily || '',
1524
+ })),
1525
+ })),
1526
+ };
1527
+ }
1528
+
1529
+ function saveDraft() {
1530
+ try {
1531
+ localStorage.setItem(DRAFT_KEY, JSON.stringify(snapshot()));
1532
+ } catch (err) {
1533
+ console.warn('[layer-editor] 草稿保存失败', err);
1534
+ }
1535
+ }
1536
+
1537
+ function loadDraft() {
1538
+ try {
1539
+ const raw = localStorage.getItem(DRAFT_KEY);
1540
+ return raw ? JSON.parse(raw) : null;
1541
+ } catch { return null; }
1542
+ }
1543
+
1544
+ // 自动恢复,同时给出「丢弃草稿」入口 —— 默认不丢失用户的改动
1545
+ function applyDraft(d) {
1546
+ const byId = new Map(state.layers.map((l) => [l.id, l.el]));
1547
+ for (const rec of d.layers || []) {
1548
+ const el = byId.get(rec.id);
1549
+ if (!el) continue;
1550
+ el.style.left = px(rec.left);
1551
+ el.style.top = px(rec.top);
1552
+ if (rec.width) el.style.width = rec.width;
1553
+ if (rec.height) el.style.height = rec.height;
1554
+ if (rec.manualWidth) el.dataset.hfWidthLocked = '1';
1555
+ if (rec.manualHeight) el.dataset.hfHeightLocked = '1';
1556
+ const slots = slotsOf(el);
1557
+ (rec.slots || []).forEach((s, i) => {
1558
+ if (!slots[i]) return;
1559
+ if (s.html !== undefined) slots[i].innerHTML = s.html;
1560
+ if (s.fs) slots[i].style.fontSize = s.fs;
1561
+ if (s.ff) slots[i].style.fontFamily = s.ff;
1562
+ });
1563
+ }
1564
+ for (const id of d.removed || []) {
1565
+ const idx = state.layers.findIndex((l) => l.id === id);
1566
+ if (idx < 0) continue;
1567
+ state.layers[idx].el.remove();
1568
+ state.layers.splice(idx, 1);
1569
+ state.removed.push(id);
1570
+ }
1571
+ }
1572
+
1573
+ function showDraftBar(d) {
1574
+ const t = new Date(d.at);
1575
+ const pad = (n) => String(n).padStart(2, '0');
1576
+ $('draft-msg').textContent =
1577
+ `已恢复草稿(${pad(t.getMonth() + 1)}-${pad(t.getDate())} ${pad(t.getHours())}:${pad(t.getMinutes())})`;
1578
+ $('draft').hidden = false;
1579
+ }
1580
+
1581
+ function dropDraft() {
1582
+ localStorage.removeItem(DRAFT_KEY);
1583
+ location.reload();
1584
+ }
1585
+
1586
+ /* ---------------------------------------------------------------
1587
+ * 12. 导出
1588
+ * ------------------------------------------------------------- */
1589
+ function positionCSS() {
1590
+ const rules = state.layers.map(({ el, id }) => {
1591
+ const g = geom(el);
1592
+ return `[data-layer-id="${id}"]{ left:${round(g.left)}px; top:${round(g.top)}px; width:${round(g.width)}px; height:${round(g.height)}px; }`;
1593
+ });
1594
+ return '/* 图层定位(前提是各图层均为 position:absolute,父级为画布) */\n' + rules.join('\n');
1595
+ }
1596
+
1597
+ function fullHTML() {
1598
+ endEdit(true);
1599
+ const doc = document.documentElement.cloneNode(true);
1600
+ doc.querySelectorAll('.hf-bar,.hf-panel,.hf-modal,.hf-toast,.hf-guides,.hf-resize-overlay').forEach((n) => n.remove());
1601
+ doc.querySelectorAll('link[href*="layer-editor"],script[src*="layer-editor"]').forEach((n) => n.remove());
1602
+
1603
+ const cv = doc.querySelector('.hf-canvas');
1604
+ cv.style.transform = '';
1605
+ doc.querySelector('.hf-stage-wrap').replaceWith(cv);
1606
+ doc.querySelector('body').removeAttribute('class');
1607
+ doc.querySelectorAll('[contenteditable]').forEach((n) => n.removeAttribute('contenteditable'));
1608
+ doc.querySelectorAll('[class*="hf-"]').forEach((n) => {
1609
+ n.classList.remove('hf-canvas', 'hf-draggable', 'hf-sel', 'hf-slot', 'hf-editing');
1610
+ if (!n.getAttribute('class')) n.removeAttribute('class');
1611
+ });
1612
+ // data-hf-ready 挂在 <html> 上,会被 cloneNode 一起带走
1613
+ for (const n of [doc, ...doc.querySelectorAll('*')]) {
1614
+ for (const attr of [...n.attributes]) {
1615
+ if (attr.name.startsWith('data-hf-')) n.removeAttribute(attr.name);
1616
+ }
1617
+ }
1618
+ return '<!doctype html>\n' + doc.outerHTML;
1619
+ }
1620
+
1621
+ const EXPLODED_EXPORT_CSS = `
1622
+ html,body{margin:0;width:100%;height:100%;overflow:hidden;background:var(--hf-exploded-bg,#0b0c0d)}
1623
+ body.hf-exploded-export{display:flex!important;align-items:center!important;justify-content:center!important;
1624
+ padding:0!important;min-height:100%!important;background:var(--hf-exploded-bg,#0b0c0d)!important}
1625
+ .hf-exploded-shell{position:relative;flex:none;overflow:visible}
1626
+ .hf-exploded-board{position:absolute;left:0;top:0;overflow:hidden;transform-origin:top left;
1627
+ background:var(--hf-exploded-bg,#0b0c0d);color:var(--hf-exploded-fg,#f4f1e8);isolation:isolate}
1628
+ .hf-exploded-item{position:absolute;overflow:hidden;transform-origin:top left;
1629
+ will-change:transform,opacity,filter}
1630
+ .hf-exploded-scan{position:absolute;z-index:40;overflow:hidden;pointer-events:none;opacity:0;
1631
+ border-radius:18px;mix-blend-mode:screen;
1632
+ box-shadow:inset 0 0 0 1px rgba(188,255,243,.36),inset 0 0 42px rgba(113,244,222,.09)}
1633
+ .hf-exploded-scan-grid{position:absolute;inset:0;opacity:0;
1634
+ background-image:linear-gradient(rgba(151,255,237,.16) 1px,transparent 1px),
1635
+ linear-gradient(90deg,rgba(151,255,237,.16) 1px,transparent 1px);
1636
+ background-size:48px 48px;
1637
+ mask-image:linear-gradient(180deg,transparent,black 12%,black 88%,transparent)}
1638
+ .hf-exploded-scan-beam{position:absolute;left:-3%;top:0;width:106%;height:18%;opacity:0;
1639
+ background:linear-gradient(180deg,transparent 0%,rgba(118,255,231,.08) 28%,
1640
+ rgba(185,255,244,.25) 50%,rgba(255,255,255,.96) 55%,
1641
+ rgba(104,255,230,.34) 58%,transparent 100%);
1642
+ filter:drop-shadow(0 0 18px rgba(152,255,237,.9))}
1643
+ .hf-exploded-item{filter:drop-shadow(0 16px 28px var(--hf-exploded-shadow,rgba(0,0,0,.28)));
1644
+ transition:transform .22s cubic-bezier(.22,1,.36,1),filter .18s}
1645
+ .hf-exploded-item:is([data-exploded-overview],[data-exploded-background]){
1646
+ border:1px solid var(--hf-exploded-border,rgba(244,241,232,.42));border-radius:16px;box-sizing:border-box}
1647
+ .hf-exploded-label{position:absolute;left:18px;top:18px;z-index:8;max-width:calc(100% - 36px);
1648
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;padding:12px 18px;
1649
+ border:1px solid var(--hf-exploded-border,rgba(255,255,255,.22));border-radius:999px;
1650
+ background:var(--hf-exploded-label-bg,rgba(11,12,13,.78));
1651
+ color:var(--hf-exploded-label-fg,#f4f1e8);font:650 28px/1 -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;
1652
+ opacity:0;transform:translateY(6px);transition:opacity .16s ease,transform .22s cubic-bezier(.22,1,.36,1)}
1653
+ .hf-exploded-settled.hf-exploded-interacted .hf-exploded-item:is(:hover,:focus-visible) .hf-exploded-label{
1654
+ opacity:1;transform:translateY(0)}
1655
+ .hf-exploded-settled .hf-exploded-item:hover{transform:translateY(-8px) scale(1.015);
1656
+ filter:drop-shadow(0 18px 34px rgba(0,0,0,.38)) brightness(1.08)}
1657
+ .hf-exploded-animating .hf-exploded-item{pointer-events:none}
1658
+ .hf-exploded-crop{position:absolute;left:0;top:0;overflow:hidden}
1659
+ .hf-exploded-source{max-width:none!important;max-height:none!important;transform:none!important}
1660
+ .hf-exploded-replay{position:fixed;right:22px;top:20px;z-index:20;appearance:none;
1661
+ border:1px solid rgba(255,255,255,.22);border-radius:999px;padding:8px 14px;
1662
+ color:#eee;background:rgba(20,22,24,.7);backdrop-filter:blur(12px);cursor:pointer;
1663
+ font:500 12px/1 -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif}
1664
+ .hf-exploded-replay:hover{background:rgba(42,45,49,.88);border-color:rgba(255,255,255,.38)}
1665
+ .hf-exploded-replay:disabled{opacity:.45;cursor:default}
1666
+ @media(prefers-reduced-motion:reduce){.hf-exploded-item{transition:none}.hf-exploded-scan{display:none}}
1667
+ `;
1668
+
1669
+ function explodedHTML() {
1670
+ endEdit(true);
1671
+ const spread = makeExplodedBoard(false);
1672
+ const doc = document.documentElement.cloneNode(true);
1673
+ doc.querySelectorAll('script').forEach((n) => n.remove());
1674
+ doc.querySelectorAll('link[href*="layer-editor"]').forEach((n) => n.remove());
1675
+ for (const n of [doc, ...doc.querySelectorAll('*')]) {
1676
+ for (const attr of [...n.attributes]) if (attr.name.startsWith('data-hf-')) n.removeAttribute(attr.name);
1677
+ }
1678
+ const head = doc.querySelector('head');
1679
+ const style = document.createElement('style');
1680
+ style.textContent = EXPLODED_EXPORT_CSS;
1681
+ head.appendChild(style);
1682
+ const title = head.querySelector('title');
1683
+ if (title) title.textContent += ' · 分层展示';
1684
+
1685
+ const body = doc.querySelector('body');
1686
+ body.textContent = '';
1687
+ body.removeAttribute('style');
1688
+ body.className = 'hf-exploded-export';
1689
+ body.style.setProperty('--hf-exploded-bg', spread.theme.bg);
1690
+ body.style.setProperty('--hf-exploded-fg', spread.theme.fg);
1691
+ body.style.setProperty('--hf-exploded-border', spread.theme.border);
1692
+ body.style.setProperty('--hf-exploded-label-bg', spread.theme.labelBg);
1693
+ body.style.setProperty('--hf-exploded-label-fg', spread.theme.labelFg);
1694
+ body.style.setProperty('--hf-exploded-shadow', spread.theme.shadow);
1695
+ const shell = Object.assign(document.createElement('div'), { className: 'hf-exploded-shell' });
1696
+ shell.appendChild(spread.board);
1697
+ body.appendChild(shell);
1698
+ const replay = Object.assign(document.createElement('button'), {
1699
+ className: 'hf-exploded-replay', textContent: '↻ 再次播放分层动画', type: 'button',
1700
+ });
1701
+ body.appendChild(replay);
1702
+
1703
+ const fit = document.createElement('script');
1704
+ fit.textContent = `(() => {
1705
+ const board = document.querySelector('.hf-exploded-board');
1706
+ const shell = document.querySelector('.hf-exploded-shell');
1707
+ const replay = document.querySelector('.hf-exploded-replay');
1708
+ const w = ${spread.width}, h = ${spread.height};
1709
+ const resize = () => {
1710
+ const s = Math.min(innerWidth / w, innerHeight / h);
1711
+ board.style.transform = 'scale(' + s + ')';
1712
+ shell.style.width = (w * s) + 'px';
1713
+ shell.style.height = (h * s) + 'px';
1714
+ };
1715
+ const reduced = matchMedia('(prefers-reduced-motion:reduce)').matches
1716
+ || new URLSearchParams(location.search).get('motion') === '0';
1717
+ board.addEventListener('pointermove', () => {
1718
+ if (board.classList.contains('hf-exploded-settled')) {
1719
+ board.classList.add('hf-exploded-interacted');
1720
+ }
1721
+ }, {passive:true});
1722
+ const scanDuration = 760, scanRelease = scanDuration * .78;
1723
+ let running = [];
1724
+ const play = async () => {
1725
+ for (const a of running) try { a.cancel(); } catch {}
1726
+ running = [];
1727
+ board.classList.remove('hf-exploded-settled','hf-exploded-interacted');
1728
+ if (reduced || !Element.prototype.animate) {
1729
+ replay.hidden = true;
1730
+ board.classList.add('hf-exploded-settled');
1731
+ document.documentElement.dataset.hfMotion = 'reduced';
1732
+ return;
1733
+ }
1734
+ board.classList.add('hf-exploded-animating');
1735
+ replay.disabled = true;
1736
+ const items = [...board.querySelectorAll('.hf-exploded-item')];
1737
+ const scan = board.querySelector('.hf-exploded-scan');
1738
+ const grid = board.querySelector('.hf-exploded-scan-grid');
1739
+ const beam = board.querySelector('.hf-exploded-scan-beam');
1740
+ const scanH = parseFloat(scan?.style.height)||0;
1741
+ if (scan) running.push(scan.animate([
1742
+ {opacity:0},{offset:.08,opacity:1},{offset:.86,opacity:1},{opacity:0}
1743
+ ],{duration:scanDuration,easing:'linear',fill:'both'}));
1744
+ if (grid) running.push(grid.animate([
1745
+ {opacity:0},{offset:.16,opacity:.56},{offset:.78,opacity:.28},{opacity:0}
1746
+ ],{duration:scanDuration,easing:'cubic-bezier(.22,1,.36,1)',fill:'both'}));
1747
+ if (beam) running.push(beam.animate([
1748
+ {transform:'translateY('+(-scanH*.2)+'px)',opacity:0},
1749
+ {offset:.08,opacity:1},{offset:.92,opacity:1},
1750
+ {transform:'translateY('+(scanH*1.02)+'px)',opacity:0}
1751
+ ],{duration:scanDuration,easing:'cubic-bezier(.34,0,.25,1)',fill:'both'}));
1752
+ for (const item of items) {
1753
+ const left = parseFloat(item.style.left)||0, top = parseFloat(item.style.top)||0;
1754
+ const dx = (+item.dataset.motionFromLeft||left)-left;
1755
+ const dy = (+item.dataset.motionFromTop||top)-top;
1756
+ const scale = +item.dataset.motionFromScale||1;
1757
+ const order = Math.min(12,+item.dataset.motionOrder||0);
1758
+ const tilt = +item.dataset.motionTilt||0;
1759
+ running.push(item.animate([
1760
+ {transform:'translate('+dx+'px,'+dy+'px) scale('+scale+') rotate(0deg)',opacity:1,filter:'blur(0)'},
1761
+ {offset:.12,transform:'translate('+dx+'px,'+dy+'px) scale('+scale+') rotate(0deg)',opacity:1,filter:'blur(0)'},
1762
+ {offset:.74,transform:'translate(0,0) scale(1.018) rotate('+tilt+'deg)',opacity:1,filter:'blur(0)'},
1763
+ {transform:'translate(0,0) scale(1) rotate(0deg)',opacity:1,filter:'blur(0)'}
1764
+ ],{duration:780,delay:scanRelease+70+order*58,easing:'cubic-bezier(.22,1,.36,1)',fill:'both'}));
1765
+ }
1766
+ await Promise.allSettled(running.map((a)=>a.finished));
1767
+ for (const a of running) a.cancel();
1768
+ running=[];
1769
+ board.classList.remove('hf-exploded-animating');
1770
+ board.classList.add('hf-exploded-settled');
1771
+ replay.disabled=false;
1772
+ document.documentElement.dataset.hfMotion='played';
1773
+ };
1774
+ replay.addEventListener('click',play);
1775
+ addEventListener('resize',resize); resize();
1776
+ play().finally(()=>{document.documentElement.dataset.hfExplodedReady='1';});
1777
+ })();`;
1778
+ body.appendChild(fit);
1779
+ return '<!doctype html>\n' + doc.outerHTML;
1780
+ }
1781
+
1782
+ function downloadBlob(html, name) {
1783
+ const url = URL.createObjectURL(new Blob([html], { type: 'text/html;charset=utf-8' }));
1784
+ const a = Object.assign(document.createElement('a'), { href: url, download: name });
1785
+ document.body.appendChild(a);
1786
+ a.click();
1787
+ a.remove();
1788
+ URL.revokeObjectURL(url);
1789
+ toast('已下载 ' + name);
1790
+ }
1791
+
1792
+ function download() {
1793
+ const html = fullHTML();
1794
+ const base = (location.pathname.split('/').pop() || 'poster').replace(/\.html?$/i, '');
1795
+ const name = (/^(editor|index)$/i.test(base) ? 'index' : base) + '.edited.html';
1796
+ downloadBlob(html, name);
1797
+ }
1798
+
1799
+ function downloadExploded() {
1800
+ const base = (location.pathname.split('/').pop() || 'poster').replace(/\.html?$/i, '');
1801
+ const name = (/^(editor|index)$/i.test(base) ? 'index' : base) + '.layers.html';
1802
+ downloadBlob(explodedHTML(), name);
1803
+ }
1804
+
1805
+ function openModal(title, text) {
1806
+ $('modal-title').textContent = title;
1807
+ $('modal-text').value = text;
1808
+ $('modal').hidden = false;
1809
+ $('modal-text').select();
1810
+ }
1811
+
1812
+ let toastTimer;
1813
+ function toast(msg) {
1814
+ const t = $('toast');
1815
+ t.textContent = msg;
1816
+ t.hidden = false;
1817
+ clearTimeout(toastTimer);
1818
+ toastTimer = setTimeout(() => { t.hidden = true; }, 1800);
1819
+ }
1820
+
1821
+ async function copy(text) {
1822
+ try {
1823
+ await navigator.clipboard.writeText(text);
1824
+ toast('已复制到剪贴板');
1825
+ } catch {
1826
+ toast('浏览器拒绝了剪贴板访问,请手动复制');
1827
+ }
1828
+ }
1829
+
1830
+ /* ---------------------------------------------------------------
1831
+ * 13. 接线
1832
+ * ------------------------------------------------------------- */
1833
+ function wire() {
1834
+ $('undo').addEventListener('click', undo);
1835
+
1836
+ $('reset').addEventListener('click', () => {
1837
+ endEdit(true);
1838
+ for (const { el, id } of state.layers) {
1839
+ const p = state.initial.get(id);
1840
+ if (p) {
1841
+ el.style.left = px(p.left); el.style.top = px(p.top);
1842
+ el.style.width = px(p.width); el.style.height = px(p.height);
1843
+ delete el.dataset.hfWidthLocked;
1844
+ delete el.dataset.hfHeightLocked;
1845
+ }
1846
+ }
1847
+ state.undo.length = 0;
1848
+ $('undo').disabled = true;
1849
+ syncPanel();
1850
+ touch();
1851
+ toast('已还原到固化后的初始布局');
1852
+ });
1853
+
1854
+ $('save').addEventListener('click', download);
1855
+ $('explode').addEventListener('click', () => setExploded());
1856
+ $('explode-save').addEventListener('click', downloadExploded);
1857
+ $('css').addEventListener('click', () => openModal('图层定位 CSS', positionCSS()));
1858
+ $('modal-copy').addEventListener('click', () => copy($('modal-text').value));
1859
+ $('modal-close').addEventListener('click', () => { $('modal').hidden = true; });
1860
+ $('modal').addEventListener('click', (e) => { if (e.target === $('modal')) $('modal').hidden = true; });
1861
+ $('scale').addEventListener('input', (e) => setScale(e.target.value / 100));
1862
+ $('fs-up').addEventListener('click', () => state.sel && scaleFont(state.sel, FS_STEP));
1863
+ $('fs-down').addEventListener('click', () => state.sel && scaleFont(state.sel, 1 / FS_STEP));
1864
+ $('del').addEventListener('click', () => state.sel && removeLayer(state.sel));
1865
+ $('draft-drop').addEventListener('click', dropDraft);
1866
+ window.addEventListener('resize', () => setScale(fitScale()));
1867
+
1868
+ document.addEventListener('keydown', (e) => {
1869
+ if (e.key === 'Escape' && (drag || resize)) {
1870
+ e.preventDefault();
1871
+ cancelDrag();
1872
+ cancelResize();
1873
+ return;
1874
+ }
1875
+ if (edit) return; // 编辑态的按键由 §8 的 capture 处理器负责
1876
+ const mod = e.metaKey || e.ctrlKey;
1877
+ if (mod && e.key.toLowerCase() === 'z') { e.preventDefault(); return undo(); }
1878
+ if (mod && e.key.toLowerCase() === 's') { e.preventDefault(); return download(); }
1879
+ if (e.key === 'Escape') return state.exploded ? setExploded(false) : select(null);
1880
+ if (!state.sel) return;
1881
+ if (e.key === 'Delete' || e.key === 'Backspace') {
1882
+ e.preventDefault();
1883
+ return removeLayer(state.sel);
1884
+ }
1885
+ if (!e.key.startsWith('Arrow')) return;
1886
+ e.preventDefault();
1887
+ const step = e.shiftKey ? 10 : 1;
1888
+ const g = geom(state.sel);
1889
+ pushUndo({ type: 'move', el: state.sel, left: g.left, top: g.top });
1890
+ const d = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] }[e.key];
1891
+ move(state.sel, g.left + d[0], g.top + d[1]);
1892
+ touch();
1893
+ });
1894
+ }
1895
+
1896
+ /* 字体决定文字块的实际尺寸,图片决定图层的实际高度 —— 两者都必须
1897
+ 落地后再测量,否则固化下来的是 fallback 字体或零高图片的坐标。
1898
+ 图片加载失败也要放行,否则一张坏图会卡死整个编辑器。 */
1899
+ function ready() {
1900
+ const imgs = [...document.images].filter((i) => !i.complete).map((i) => new Promise((r) => {
1901
+ i.addEventListener('load', r, { once: true });
1902
+ i.addEventListener('error', r, { once: true });
1903
+ }));
1904
+ return Promise.all([document.fonts.ready, ...imgs]);
1905
+ }
1906
+
1907
+ ready().then(() => {
1908
+ bake();
1909
+ markSlots();
1910
+ buildUI();
1911
+ buildFontSelect();
1912
+ buildStage();
1913
+ wire();
1914
+ const draft = loadDraft();
1915
+ if (draft) { applyDraft(draft); showDraftBar(draft); }
1916
+ rebuildPanel();
1917
+
1918
+ /* 供 scripts/ 下的离线固化与验证脚本驱动。编辑器不加载时不存在,
1919
+ 导出的 HTML 也不含此脚本,因此不会污染产物。 */
1920
+ window.__layerEditor = {
1921
+ fullHTML, explodedHTML, positionCSS, snapshot, download, downloadExploded, undo, toast,
1922
+ select, scaleFont, setFont, removeLayer, fontOptions,
1923
+ setExploded,
1924
+ layers: () => state.layers.map(({ el, id }) => ({ el, id })),
1925
+ state,
1926
+ };
1927
+ document.documentElement.dataset.hfReady = '1'; // 自动化脚本的等待锚点
1928
+ });
1929
+ })();