@amaster.ai/pi-lark 0.1.5 → 0.1.7

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 (258) hide show
  1. package/README.md +5 -1
  2. package/dist/config.d.ts +1 -1
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +2 -2
  5. package/dist/config.js.map +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +2 -1
  8. package/dist/index.js.map +1 -1
  9. package/package.json +4 -4
  10. package/skills/lark-approval/references/lark-approval-initiate.md +2 -5
  11. package/skills/lark-approval/references/lark-approval-instances-initiated.md +6 -0
  12. package/skills/lark-approval/references/lark-approval-tasks-query.md +9 -0
  13. package/skills/lark-approval/references/lark-approval-tasks-rollback.md +8 -2
  14. package/skills/lark-apps/SKILL.md +46 -16
  15. package/skills/lark-apps/creative-design/agents/assets/vision-probe.png +0 -0
  16. package/skills/lark-apps/creative-design/agents/fork-verifier-agent.md +71 -0
  17. package/skills/lark-apps/creative-design/agents/vision-probe-agent.md +41 -0
  18. package/skills/lark-apps/creative-design/assets/index.html +27 -0
  19. package/skills/lark-apps/creative-design/creative-design.md +239 -0
  20. package/skills/lark-apps/creative-design/references/aily.md +39 -0
  21. package/skills/lark-apps/creative-design/references/animated-video.md +34 -0
  22. package/skills/lark-apps/creative-design/references/charts.md +165 -0
  23. package/skills/lark-apps/creative-design/references/claude.md +36 -0
  24. package/skills/lark-apps/creative-design/references/codex.md +32 -0
  25. package/skills/lark-apps/creative-design/references/data-report.md +108 -0
  26. package/skills/lark-apps/creative-design/references/frontend-design.md +71 -0
  27. package/skills/lark-apps/creative-design/references/hi-fi-design.md +32 -0
  28. package/skills/lark-apps/creative-design/references/interactive-prototype.md +24 -0
  29. package/skills/lark-apps/creative-design/references/make-a-deck.md +133 -0
  30. package/skills/lark-apps/creative-design/references/visual-exposure.md +82 -0
  31. package/skills/lark-apps/creative-design/references/wireframe.md +14 -0
  32. package/skills/lark-apps/creative-design/starter-components/android-frame.jsx +188 -0
  33. package/skills/lark-apps/creative-design/starter-components/animations.jsx +773 -0
  34. package/skills/lark-apps/creative-design/starter-components/browser-window.jsx +122 -0
  35. package/skills/lark-apps/creative-design/starter-components/deck-stage.js +2483 -0
  36. package/skills/lark-apps/creative-design/starter-components/design-canvas.jsx +1432 -0
  37. package/skills/lark-apps/creative-design/starter-components/ios-frame.jsx +270 -0
  38. package/skills/lark-apps/creative-design/starter-components/macos-window.jsx +197 -0
  39. package/skills/lark-apps/creative-design/starter-components/tweaks-panel.jsx +752 -0
  40. package/skills/lark-apps/references/lark-apps-access-scope-set.md +1 -1
  41. package/skills/lark-apps/references/lark-apps-automation.md +242 -0
  42. package/skills/lark-apps/references/lark-apps-cache.md +61 -0
  43. package/skills/lark-apps/references/lark-apps-cloud-dev.md +0 -1
  44. package/skills/lark-apps/references/lark-apps-create.md +1 -2
  45. package/skills/lark-apps/references/lark-apps-db-execute.md +186 -2
  46. package/skills/lark-apps/references/lark-apps-db.md +4 -4
  47. package/skills/lark-apps/references/lark-apps-env-pull.md +1 -1
  48. package/skills/lark-apps/references/lark-apps-file.md +2 -2
  49. package/skills/lark-apps/references/lark-apps-get.md +43 -0
  50. package/skills/lark-apps/references/lark-apps-git-credential.md +1 -1
  51. package/skills/lark-apps/references/lark-apps-html-publish.md +5 -4
  52. package/skills/lark-apps/references/lark-apps-init.md +2 -3
  53. package/skills/lark-apps/references/lark-apps-list.md +1 -1
  54. package/skills/lark-apps/references/lark-apps-local-dev.md +54 -11
  55. package/skills/lark-apps/references/lark-apps-openapi-key.md +1 -1
  56. package/skills/lark-apps/references/lark-apps-release-create.md +5 -3
  57. package/skills/lark-apps/references/lark-apps-release-get.md +3 -3
  58. package/skills/lark-apps/references/lark-apps-role.md +133 -0
  59. package/skills/lark-base/SKILL.md +26 -15
  60. package/skills/lark-base/references/dashboard-block-data-config.md +28 -2
  61. package/skills/lark-base/references/lark-base-cell-value.md +12 -7
  62. package/skills/lark-base/references/lark-base-dashboard-block-get-data.md +7 -7
  63. package/skills/lark-base/references/lark-base-dashboard.md +11 -2
  64. package/skills/lark-base/references/lark-base-data-query.md +20 -11
  65. package/skills/lark-base/references/lark-base-field-create.md +8 -2
  66. package/skills/lark-base/references/lark-base-field-json.md +56 -19
  67. package/skills/lark-base/references/lark-base-field-update.md +21 -3
  68. package/skills/lark-base/references/lark-base-filter-condition.md +179 -0
  69. package/skills/lark-base/references/lark-base-form-questions-create.md +40 -7
  70. package/skills/lark-base/references/lark-base-form-questions-update.md +73 -20
  71. package/skills/lark-base/references/lark-base-form-submit.md +16 -7
  72. package/skills/lark-base/references/lark-base-record-batch-create.md +12 -10
  73. package/skills/lark-base/references/lark-base-record-batch-update.md +11 -9
  74. package/skills/lark-base/references/lark-base-record-upsert.md +1 -1
  75. package/skills/lark-base/references/lark-base-role-guide.md +11 -0
  76. package/skills/lark-base/references/lark-base-view-set-filter.md +14 -138
  77. package/skills/lark-base/references/role-config.md +31 -5
  78. package/skills/lark-calendar/SKILL.md +101 -37
  79. package/skills/lark-calendar/references/lark-calendar-create.md +13 -43
  80. package/skills/lark-calendar/references/lark-calendar-recurring.md +1 -0
  81. package/skills/lark-calendar/references/lark-calendar-room-find.md +7 -10
  82. package/skills/lark-calendar/references/lark-calendar-rsvp.md +1 -5
  83. package/skills/lark-calendar/references/lark-calendar-schedule-clear-time.md +60 -0
  84. package/skills/lark-calendar/references/lark-calendar-schedule-fuzzy-time.md +88 -0
  85. package/skills/lark-calendar/references/lark-calendar-schedule-meeting.md +67 -210
  86. package/skills/lark-calendar/references/lark-calendar-suggestion.md +2 -6
  87. package/skills/lark-calendar/references/lark-calendar-update.md +12 -11
  88. package/skills/lark-contact/SKILL.md +19 -3
  89. package/skills/lark-contact/references/lark-contact-search-bot.md +60 -0
  90. package/skills/lark-doc/SKILL.md +1 -1
  91. package/skills/lark-doc/references/lark-doc-fetch.md +14 -4
  92. package/skills/lark-doc/references/lark-doc-mindnote.md +17 -2
  93. package/skills/lark-doc/references/lark-doc-whiteboard.md +13 -8
  94. package/skills/lark-doc/references/lark-doc-xml-extended-blocks.md +76 -0
  95. package/skills/lark-doc/references/lark-doc-xml.md +6 -4
  96. package/skills/lark-drive/SKILL.md +35 -43
  97. package/skills/lark-drive/references/lark-drive-add-comment.md +2 -4
  98. package/skills/lark-drive/references/lark-drive-add-reply.md +47 -0
  99. package/skills/lark-drive/references/lark-drive-apply-permission.md +2 -2
  100. package/skills/lark-drive/references/lark-drive-batch-query-comments.md +46 -0
  101. package/skills/lark-drive/references/lark-drive-comment-content.md +50 -0
  102. package/skills/lark-drive/references/lark-drive-comment-location.md +18 -12
  103. package/skills/lark-drive/references/lark-drive-delete-reply.md +48 -0
  104. package/skills/lark-drive/references/lark-drive-delete.md +35 -11
  105. package/skills/lark-drive/references/lark-drive-download.md +5 -1
  106. package/skills/lark-drive/references/lark-drive-export.md +39 -10
  107. package/skills/lark-drive/references/lark-drive-files-list.md +27 -2
  108. package/skills/lark-drive/references/lark-drive-inspect.md +2 -0
  109. package/skills/lark-drive/references/lark-drive-list-comments.md +82 -0
  110. package/skills/lark-drive/references/lark-drive-list-replies.md +54 -0
  111. package/skills/lark-drive/references/lark-drive-member-add.md +3 -3
  112. package/skills/lark-drive/references/lark-drive-member-list.md +65 -0
  113. package/skills/lark-drive/references/lark-drive-move.md +5 -3
  114. package/skills/lark-drive/references/lark-drive-permission-get-setting.md +48 -0
  115. package/skills/lark-drive/references/lark-drive-permission-guide.md +12 -0
  116. package/skills/lark-drive/references/lark-drive-preview.md +11 -1
  117. package/skills/lark-drive/references/lark-drive-pull.md +3 -3
  118. package/skills/lark-drive/references/lark-drive-push.md +33 -6
  119. package/skills/lark-drive/references/lark-drive-react-reply.md +51 -0
  120. package/skills/lark-drive/references/lark-drive-reactions.md +27 -25
  121. package/skills/lark-drive/references/lark-drive-resolve-comment.md +45 -0
  122. package/skills/lark-drive/references/lark-drive-restore-comment.md +46 -0
  123. package/skills/lark-drive/references/lark-drive-search.md +7 -1
  124. package/skills/lark-drive/references/lark-drive-secure-label.md +1 -1
  125. package/skills/lark-drive/references/lark-drive-status.md +12 -14
  126. package/skills/lark-drive/references/lark-drive-task-result.md +58 -5
  127. package/skills/lark-drive/references/lark-drive-update-reply.md +46 -0
  128. package/skills/lark-drive/references/lark-drive-upload.md +1 -0
  129. package/skills/lark-drive/references/lark-drive-workflow-knowledge-organize.md +26 -20
  130. package/skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md +38 -8
  131. package/skills/lark-drive/references/lark-drive-workflow-permission-governance-outputs.md +10 -10
  132. package/skills/lark-drive/references/lark-drive-workflow-permission-governance.md +22 -20
  133. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-execute.md +273 -0
  134. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-recall.md +202 -0
  135. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-resolve-verify.md +231 -0
  136. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-review-plan.md +248 -0
  137. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-setup.md +174 -0
  138. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector.md +202 -0
  139. package/skills/lark-drive/references/lark-drive-workflow.md +5 -3
  140. package/skills/lark-event/SKILL.md +3 -1
  141. package/skills/lark-event/references/lark-event-application.md +38 -0
  142. package/skills/lark-event/references/lark-event-approval.md +170 -0
  143. package/skills/lark-im/SKILL.md +6 -5
  144. package/skills/lark-im/references/card/card-2.0-schema.md +1 -1
  145. package/skills/lark-im/references/card/lark-im-card-style.md +4 -4
  146. package/skills/lark-im/references/card/resource/icons.md +14 -0
  147. package/skills/lark-im/references/lark-im-flag-list.md +8 -7
  148. package/skills/lark-im/references/lark-im-messages-reply.md +1 -1
  149. package/skills/lark-im/references/lark-im-messages-send.md +1 -1
  150. package/skills/lark-mail/SKILL.md +12 -9
  151. package/skills/lark-mail/references/lark-mail-forward.md +1 -1
  152. package/skills/lark-mail/references/lark-mail-message-modify.md +48 -0
  153. package/skills/lark-mail/references/lark-mail-message-trash.md +41 -0
  154. package/skills/lark-mail/references/lark-mail-reply-all.md +1 -1
  155. package/skills/lark-mail/references/lark-mail-reply.md +1 -1
  156. package/skills/lark-mail/references/lark-mail-watch.md +1 -1
  157. package/skills/lark-markdown/SKILL.md +3 -2
  158. package/skills/lark-markdown/references/lark-markdown-create.md +22 -2
  159. package/skills/lark-minutes/SKILL.md +19 -4
  160. package/skills/lark-minutes/references/lark-minutes-download.md +0 -2
  161. package/skills/lark-minutes/references/lark-minutes-search.md +0 -2
  162. package/skills/lark-minutes/references/lark-minutes-speaker-replace.md +0 -2
  163. package/skills/lark-minutes/references/lark-minutes-summary.md +0 -2
  164. package/skills/lark-minutes/references/lark-minutes-todo.md +2 -4
  165. package/skills/lark-minutes/references/lark-minutes-update.md +0 -2
  166. package/skills/lark-minutes/references/lark-minutes-upload.md +10 -10
  167. package/skills/lark-okr/SKILL.md +71 -26
  168. package/skills/lark-okr/references/lark-okr-batch-create.md +19 -18
  169. package/skills/lark-okr/references/lark-okr-create.md +173 -0
  170. package/skills/lark-okr/references/lark-okr-cycle-list.md +17 -7
  171. package/skills/lark-okr/references/lark-okr-entities.md +1 -0
  172. package/skills/lark-okr/references/lark-okr-indicator-update.md +3 -1
  173. package/skills/lark-okr/references/lark-okr-indicators.md +61 -12
  174. package/skills/lark-okr/references/lark-okr-progress-list.md +21 -9
  175. package/skills/lark-shared/SKILL.md +26 -8
  176. package/skills/lark-sheets/SKILL.md +98 -29
  177. package/skills/lark-sheets/references/lark-sheets-batch-update.md +18 -9
  178. package/skills/lark-sheets/references/lark-sheets-changeset.md +105 -0
  179. package/skills/lark-sheets/references/lark-sheets-chart.md +4 -2
  180. package/skills/lark-sheets/references/lark-sheets-conditional-format.md +2 -0
  181. package/skills/lark-sheets/references/lark-sheets-filter-view.md +1 -1
  182. package/skills/lark-sheets/references/lark-sheets-float-image.md +6 -6
  183. package/skills/lark-sheets/references/lark-sheets-formula-translation.md +12 -3
  184. package/skills/lark-sheets/references/lark-sheets-formula-verify.md +77 -0
  185. package/skills/lark-sheets/references/lark-sheets-history.md +93 -0
  186. package/skills/lark-sheets/references/lark-sheets-pivot-table.md +7 -2
  187. package/skills/lark-sheets/references/lark-sheets-range-operations.md +44 -14
  188. package/skills/lark-sheets/references/lark-sheets-read-data.md +3 -3
  189. package/skills/lark-sheets/references/lark-sheets-sheet-structure.md +4 -4
  190. package/skills/lark-sheets/references/lark-sheets-visual-standards.md +4 -4
  191. package/skills/lark-sheets/references/lark-sheets-workbook.md +29 -4
  192. package/skills/lark-sheets/references/lark-sheets-write-cells.md +21 -11
  193. package/skills/lark-slides/SKILL.md +121 -63
  194. package/skills/lark-slides/references/asset-planning.md +18 -5
  195. package/skills/lark-slides/references/iconpark.md +3 -3
  196. package/skills/lark-slides/references/lark-slides-create.md +30 -3
  197. package/skills/lark-slides/references/lark-slides-history.md +132 -0
  198. package/skills/lark-slides/references/lark-slides-media-upload.md +1 -3
  199. package/skills/lark-slides/references/lark-slides-pptx-template-workflows.md +85 -0
  200. package/skills/lark-slides/references/lark-slides-replace-pages.md +1 -1
  201. package/skills/lark-slides/references/lark-slides-replace-slide.md +1 -4
  202. package/skills/lark-slides/references/lark-slides-screenshot.md +11 -8
  203. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-create.md +5 -6
  204. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-delete.md +5 -2
  205. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-get.md +5 -5
  206. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-replace.md +14 -13
  207. package/skills/lark-slides/references/lark-slides-xml-presentations-get.md +67 -31
  208. package/skills/lark-slides/references/planning-layer.md +41 -10
  209. package/skills/lark-slides/references/slides_chart_demo.xml +1416 -0
  210. package/skills/lark-slides/references/slides_xml_schema_definition.xml +499 -78
  211. package/skills/lark-slides/references/troubleshooting.md +5 -5
  212. package/skills/lark-slides/references/validation-checklist.md +65 -19
  213. package/skills/lark-slides/references/visual-planning.md +26 -22
  214. package/skills/lark-slides/references/xml-schema-quick-ref.md +285 -45
  215. package/skills/lark-slides/scripts/sxsd_validator.py +908 -0
  216. package/skills/lark-slides/scripts/xml_text_overlap_lint.py +2429 -91
  217. package/skills/lark-slides/scripts/xml_text_overlap_lint_test.py +3567 -70
  218. package/skills/lark-task/SKILL.md +8 -0
  219. package/skills/lark-task/references/lark-task-complete.md +6 -2
  220. package/skills/lark-task/references/lark-task-create.md +23 -1
  221. package/skills/lark-task/references/lark-task-update.md +6 -2
  222. package/skills/lark-vc/SKILL.md +6 -3
  223. package/skills/lark-vc/references/lark-vc-recording.md +0 -2
  224. package/skills/lark-vc/references/vc-domain-boundaries.md +9 -1
  225. package/skills/lark-vc-agent/SKILL.md +25 -15
  226. package/skills/lark-vc-agent/references/lark-vc-agent-meeting-events.md +65 -37
  227. package/skills/lark-vc-agent/references/lark-vc-agent-meeting-leave.md +1 -1
  228. package/skills/lark-vc-agent/references/lark-vc-agent-meeting-list-active.md +8 -8
  229. package/skills/lark-whiteboard/SKILL.md +13 -12
  230. package/skills/lark-whiteboard/elements/layout.md +1 -1
  231. package/skills/lark-whiteboard/elements/schema.md +2 -2
  232. package/skills/lark-whiteboard/references/{lark-whiteboard-query.md → lark-whiteboard-export.md} +15 -15
  233. package/skills/lark-whiteboard/references/lark-whiteboard-update.md +3 -3
  234. package/skills/lark-whiteboard/references/lark-whiteboard-workflow.md +12 -19
  235. package/skills/lark-whiteboard/routes/dsl.md +3 -3
  236. package/skills/lark-whiteboard/routes/mermaid.md +2 -2
  237. package/skills/lark-whiteboard/routes/svg-edit.md +4 -4
  238. package/skills/lark-whiteboard/routes/svg.md +11 -6
  239. package/skills/lark-whiteboard/scenes/bar-chart.md +1 -1
  240. package/skills/lark-whiteboard/scenes/fishbone.md +1 -1
  241. package/skills/lark-whiteboard/scenes/flywheel.md +1 -1
  242. package/skills/lark-whiteboard/scenes/line-chart.md +1 -1
  243. package/skills/lark-whiteboard/scenes/treemap.md +1 -1
  244. package/skills/lark-wiki/SKILL.md +8 -3
  245. package/skills/lark-wiki/references/lark-wiki-move-to-drive.md +122 -0
  246. package/skills/lark-wiki/references/lark-wiki-move.md +5 -3
  247. package/skills/lark-wiki/references/lark-wiki-node-get.md +1 -1
  248. package/skills/lark-wiki/references/lark-wiki-node-list.md +9 -2
  249. package/skills/lark-calendar/references/lark-calendar-agenda.md +0 -78
  250. package/skills/lark-calendar/references/lark-calendar-freebusy.md +0 -124
  251. package/skills/lark-calendar/references/lark-calendar-search-event.md +0 -29
  252. package/skills/lark-drive/references/lark-drive-comments-guide.md +0 -72
  253. package/skills/lark-sheets/references/lark-sheets-core-operations.md +0 -103
  254. package/skills/lark-slides/references/examples.md +0 -261
  255. package/skills/lark-slides/references/lark-slides-whiteboard.md +0 -330
  256. package/skills/lark-slides/references/slide-templates.md +0 -201
  257. package/skills/lark-slides/references/slides_demo.xml +0 -226
  258. package/skills/lark-slides/references/xml-format-guide.md +0 -369
@@ -0,0 +1,2483 @@
1
+ // Copyright (c) 2026 Lark Technologies Pte. Ltd.
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /* BEGIN USAGE */
5
+ /**
6
+ * <deck-stage> — reusable web component for HTML decks.
7
+ *
8
+ * Handles:
9
+ * (a) speaker notes — reads <script type="application/json" id="speaker-notes">
10
+ * (a JSON array of per-slide note strings), shows the current slide's
11
+ * note in an editable bottom dock (edit mode only; drag its grip to
12
+ * resize, height persisted to localStorage), posts miaoda:deck:slide-changed
13
+ * to the parent on nav, and posts miaoda:deck:notes-changed (the full
14
+ * array) when a note is edited so the host can persist it. The dock is
15
+ * hidden while presenting / in preview / on narrow / noscale / no-rail.
16
+ * (b) keyboard navigation — ←/→, PgUp/PgDn, Space, Home/End, number keys.
17
+ * On touch devices, tapping the left/right half of the stage goes
18
+ * prev/next (desktop / presenting mode; mobile browse mode scrolls
19
+ * instead — see (g)) — taps on links, buttons and other interactive
20
+ * slide content are left alone.
21
+ * (c) press R to reset to slide 0.
22
+ * (d) auto-scaling — inner canvas is a fixed design size (default 1920×1080)
23
+ * scaled with `transform: scale()` to fit the viewport, letterboxed.
24
+ * Set the `noscale` attribute to render at authored size (1:1) — the
25
+ * PPTX exporter sets this so its DOM capture sees unscaled geometry.
26
+ * (e) print — `@media print` lays every slide out as its own page at the
27
+ * design size, so the browser's Print → Save as PDF produces a clean
28
+ * one-page-per-slide PDF with no extra setup.
29
+ * (f) thumbnail rail — resizable left-hand column of per-slide thumbnails
30
+ * (static clones). Click to navigate; ↑/↓ with a thumbnail focused to
31
+ * step between slides; drag to reorder; right-click for
32
+ * Skip / Move up / Move down / Delete. Drag the rail's right edge
33
+ * to resize; width persists to
34
+ * localStorage. Skipped slides carry `data-deck-skip`, are dimmed in
35
+ * the rail, omitted from prev/next navigation, and hidden at print.
36
+ * The rail is suppressed in presenting mode, in the host's Preview
37
+ * mode (ViewerMode='none'), on `noscale`, on narrow viewports
38
+ * (≤640px), and via the `no-rail` attribute. Rail mutations dispatch
39
+ * a `deckchange`
40
+ * CustomEvent on the element: detail = {action, from, to, slide}.
41
+ * (g) mobile — on mobile UA (mirrors the host platform's isMobile()
42
+ * semantics: UA regex + iPad maxTouchPoints, never viewport width),
43
+ * browse mode renders slides as a vertical card stream (12px margin,
44
+ * 8px gap, 8px radius) with native scrolling; a per-card page badge
45
+ * fades in while scrolling (16|20×12, ink 20% bg, fixed 8px digits)
46
+ * and fades out 2s after scrolling stops. Presenting mode
47
+ * (miaoda:deck:presenting) rotates the stage 90° (CSS pseudo-
48
+ * landscape, no orientation.lock): tap left/right third to navigate,
49
+ * tap center to toggle the controls overlay (back button +
50
+ * filmstrip; auto-hides after 3s). The back button exits presenting
51
+ * and posts miaoda:deck:presenting-dismissed to the parent.
52
+ * Desktop behaviour is unchanged.
53
+ *
54
+ * Slides are HIDDEN, not unmounted. Non-active slides stay in the DOM with
55
+ * `visibility: hidden` + `opacity: 0`, so their state (videos, iframes,
56
+ * form inputs, React trees) is preserved across navigation.
57
+ *
58
+ * Lifecycle event — the component dispatches a `slidechange` CustomEvent on
59
+ * itself whenever the active slide changes (including the initial mount).
60
+ * The event bubbles and composes out of shadow DOM, so you can listen on
61
+ * the <deck-stage> element or on document:
62
+ *
63
+ * document.querySelector('deck-stage').addEventListener('slidechange', (e) => {
64
+ * e.detail.index // new 0-based index
65
+ * e.detail.previousIndex // previous index, or -1 on init
66
+ * e.detail.total // total slide count
67
+ * e.detail.slide // the new active slide element
68
+ * e.detail.previousSlide // the prior slide element, or null on init
69
+ * e.detail.reason // 'init' | 'keyboard' | 'click' | 'tap' | 'api'
70
+ * });
71
+ *
72
+ * Persistence: none at the deck level. The host app keeps the current slide
73
+ * in its own URL (?slide=) and re-delivers it via location.hash on load, so a
74
+ * bare load with no hash always starts at slide 1.
75
+ *
76
+ * Usage:
77
+ * <style>deck-stage:not(:defined){visibility:hidden}</style>
78
+ * <deck-stage width="1920" height="1080">
79
+ * <section data-label="Title">...</section>
80
+ * <section data-label="Agenda">...</section>
81
+ * </deck-stage>
82
+ * <script src="deck-stage.js"></script>
83
+ *
84
+ * The :not(:defined) rule prevents a flash of the first slide at its
85
+ * authored styles before this script runs and attaches the shadow root.
86
+ *
87
+ * Slides are the direct element children of <deck-stage>. Each slide is
88
+ * automatically tagged with:
89
+ * - data-screen-label="NN Label" (1-indexed, for comment flow)
90
+ * - data-miaoda-validate="no_overflowing_text,no_overlapping_text,slide_sized_text"
91
+ *
92
+ * Speaker notes stay in sync because the component posts miaoda:deck:slide-changed
93
+ * to the parent — just include the #speaker-notes script tag if asked for notes.
94
+ *
95
+ * Authoring guidance:
96
+ * - Write slide bodies as static HTML inside <deck-stage>, with sizing via
97
+ * CSS custom properties in a <style> block rather than JS constants.
98
+ * Static slide markup is what lets the user click a heading in edit mode
99
+ * and retype it directly; a slide rendered through <script type="text/babel">,
100
+ * React, or a loop over a JS array has to round-trip every tweak through a
101
+ * chat message instead. Reach for script-generated slides only when the
102
+ * content genuinely needs interactive behaviour static HTML can't express.
103
+ * - Do NOT set position/inset/width/height on the slide <section> elements —
104
+ * the component absolutely positions every slotted child for you.
105
+ * - Entrance animations: make the visible end-state the base style and
106
+ * animate *from* hidden. The thumbnail rail, print, and reduced-motion
107
+ * render each slide's static base state and never fire the animation, so
108
+ * a hidden base (opacity:0 on the rule itself) renders blank there while
109
+ * the live slide looks fine. Keep the hidden state only in the keyframes
110
+ * `from`, and gate the animation on [data-deck-active] and the motion
111
+ * query, e.g.:
112
+ * .fade-up { opacity: 1 }
113
+ * @media (prefers-reduced-motion:no-preference){ [data-deck-active] .fade-up{ animation: fadeUp .5s both } }
114
+ * @keyframes fadeUp { from{ opacity:0; transform:translateY(24px) } to{ opacity:1; transform:none } }
115
+ * Avoid infinite decorative loops on slide content.
116
+ */
117
+ /* END USAGE */
118
+
119
+ (() => {
120
+ if (typeof window === 'undefined') return;
121
+ const DESIGN_W_DEFAULT = 1920;
122
+ const DESIGN_H_DEFAULT = 1080;
123
+ // Safe margin around the scaled slide in edit/preview (non-presenting) mode,
124
+ // giving the canvas a floating "card" look with a drop-shadow.
125
+ const DECK_MARGIN = 20;
126
+ // TEMP: presenter-notes dock is hidden for now. Flip back to false to restore
127
+ // the editable speaker-notes panel (all notes logic below stays intact).
128
+ const NOTES_DISABLED = true;
129
+ const VALIDATE_ATTR = 'no_overflowing_text,no_overlapping_text,slide_sized_text';
130
+ const FINE_POINTER_MQ = matchMedia('(hover: hover) and (pointer: fine)');
131
+ const NARROW_MQ = matchMedia('(max-width: 640px)');
132
+ // Slide-authored controls that should keep a tap instead of it navigating.
133
+ const INTERACTIVE_SEL = 'a[href], button, input, select, textarea, summary, label, video[controls], audio[controls], [role="button"], [onclick], [tabindex]:not([tabindex^="-"]), [contenteditable]:not([contenteditable="false" i])';
134
+
135
+ // ── 移动端(复刻主仓 @apaas-ai/global-states isMobile() 的 UA 语义;
136
+ // 跨域产物 iframe 无法 import 该包)。刻意不用视口断点:桌面工作台的
137
+ // 预览 iframe 本身就窄,视口宽度会把桌面预览误判成移动端。
138
+ // mode=desktop 逃生阀只作用于壳层自身 location、不透传到本 iframe,不实现。
139
+ const MOBILE_UA_RE = /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i;
140
+ const isMobileUA = () =>
141
+ MOBILE_UA_RE.test(navigator.userAgent) ||
142
+ (/iPad|Tab|Tablet/i.test(navigator.userAgent) && !!navigator.maxTouchPoints && navigator.maxTouchPoints > 1);
143
+ // 移动端视觉常量(Figma node 2965:32138 / 2965:32047 / 2965:34386 已确认)
144
+ const MOB_MARGIN = 12; // 竖排卡片页边距
145
+ const MOB_GAP = 8; // 卡片间距
146
+ const MOB_RADIUS = 8; // 卡片圆角
147
+ const MOB_BADGE_IDLE_MS = 2000; // 滚动停止 → 徽标淡出(UX 确认 2s)
148
+ const MOB_UI_HIDE_MS = 3000; // 播放控件层自动收起
149
+
150
+ const pad2 = (n) => String(n).padStart(2, '0');
151
+
152
+ // Label precedence: data-label → data-screen-label (number stripped) → first heading → "Slide".
153
+ const getSlideLabel = (el) => {
154
+ const explicit = el.getAttribute('data-label');
155
+ if (explicit) return explicit;
156
+
157
+ const existing = el.getAttribute('data-screen-label');
158
+ if (existing) return existing.replace(/^\s*\d+\s*/, '').trim() || existing;
159
+
160
+ const h = el.querySelector('h1, h2, h3, [data-title]');
161
+ const t = h && (h.textContent || '').trim().slice(0, 40);
162
+ if (t) return t;
163
+
164
+ return 'Slide';
165
+ };
166
+
167
+ const stylesheet = `
168
+ :host {
169
+ position: fixed;
170
+ inset: 0;
171
+ display: block;
172
+ background: #F8F9FA;
173
+ font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif;
174
+ overflow: hidden;
175
+ -webkit-tap-highlight-color: transparent;
176
+ }
177
+ /* connectedCallback holds this until document.fonts.ready (capped 2s) so
178
+ * the first visible paint has the deck's real typography + final rail
179
+ * layout. opacity (not visibility) so the active slide can't un-hide
180
+ * itself via the ::slotted([data-deck-active]) visibility:visible rule.
181
+ * Only the stage/rail hide — the black :host background stays, so the
182
+ * iframe doesn't flash the page's default white. */
183
+ :host([data-fonts-pending]) .stage,
184
+ :host([data-fonts-pending]) .rail,
185
+ :host([data-fonts-pending]) .notes { opacity: 0; pointer-events: none; }
186
+
187
+ .stage {
188
+ position: absolute;
189
+ inset: 0;
190
+ display: flex;
191
+ align-items: center;
192
+ justify-content: center;
193
+ background: transparent;
194
+ }
195
+ :host(:not([data-windowed])) .stage { background: #000; }
196
+
197
+ .canvas {
198
+ position: relative;
199
+ transform-origin: center center;
200
+ flex-shrink: 0;
201
+ background: #fff;
202
+ will-change: transform;
203
+ }
204
+ /* Floating card drop-shadow — only when the stage is windowed (20px
205
+ margin), i.e. edit/preview. Full-bleed presenting / noscale / _snthumb
206
+ carry no shadow (_fit toggles data-windowed). */
207
+ :host([data-windowed]) .canvas {
208
+ border: 0.882px solid #DEE0E3;
209
+ box-shadow: 0 0 7.052px 0 rgba(31, 35, 41, 0.06), 0 0 15.867px 0 rgba(31, 35, 41, 0.04), 0 0 22.919px 7.052px rgba(31, 35, 41, 0.04);
210
+ }
211
+
212
+ /* Slides live in light DOM (via <slot>) so authored CSS still applies.
213
+ We absolutely position each slotted child to stack them. */
214
+ ::slotted(*) {
215
+ position: absolute !important;
216
+ inset: 0 !important;
217
+ width: 100% !important;
218
+ height: 100% !important;
219
+ box-sizing: border-box !important;
220
+ overflow: hidden;
221
+ opacity: 0;
222
+ pointer-events: none;
223
+ visibility: hidden;
224
+ }
225
+ ::slotted([data-deck-active]) {
226
+ opacity: 1;
227
+ pointer-events: auto;
228
+ visibility: visible;
229
+ }
230
+
231
+ /* ── Thumbnail rail ──────────────────────────────────────────────────
232
+ Fixed column on the left; each thumbnail is a static deep-clone of
233
+ the light-DOM slide scaled into a 16:9 (or design-aspect) frame. The
234
+ stage re-fits around it (see _fit); hidden during present / noscale
235
+ / print so capture geometry and fullscreen output are unchanged. */
236
+ .rail {
237
+ position: fixed;
238
+ left: 0;
239
+ top: 0;
240
+ bottom: 0;
241
+ width: var(--deck-rail-w, 192px);
242
+ background: #fff;
243
+ border-right: 0.5px solid #DEE0E3;
244
+ overflow-y: auto;
245
+ overflow-x: hidden;
246
+ transform: translate3d(0, 0, 0);
247
+ padding: 20px 15px 20px 10px;
248
+ box-sizing: border-box;
249
+ display: flex;
250
+ flex-direction: column;
251
+ gap: 18px;
252
+ z-index: 2147482500;
253
+ scrollbar-width: thin;
254
+ scrollbar-color: rgba(0,0,0,0.18) transparent;
255
+ }
256
+ .rail::-webkit-scrollbar { width: 8px; }
257
+ .rail::-webkit-scrollbar-track { background: transparent; margin: 2px; }
258
+ .rail::-webkit-scrollbar-thumb {
259
+ background: rgba(0,0,0,0.18);
260
+ border-radius: 4px;
261
+ border: 2px solid transparent;
262
+ background-clip: content-box;
263
+ }
264
+ .rail::-webkit-scrollbar-thumb:hover {
265
+ background: rgba(0,0,0,0.28);
266
+ border: 2px solid transparent;
267
+ background-clip: content-box;
268
+ }
269
+ :host([no-rail]) .rail,
270
+ :host([noscale]) .rail { display: none; }
271
+ .rail[data-presenting] { display: none; }
272
+ @media (max-width: 640px) {
273
+ .rail, .rail-resize { display: none; }
274
+ }
275
+ /* User-driven show/hide (the TweaksPanel toggle) slides instead of
276
+ popping. Transitions are gated on :host([data-rail-anim]) — set only
277
+ for the 200ms around the toggle — so window-resize and rail-width
278
+ drag (which also call _fit) don't lag behind the cursor. */
279
+ .rail[data-user-hidden] { transform: translate3d(-100%, 0, 0); }
280
+ :host([data-rail-anim]) .rail { transition: transform 200ms cubic-bezier(.3,.7,.4,1); }
281
+ :host([data-rail-anim]) .stage { transition: left 200ms cubic-bezier(.3,.7,.4,1); }
282
+ :host([data-rail-anim]) .canvas { transition: transform 200ms cubic-bezier(.3,.7,.4,1); }
283
+ :host([data-rail-anim]) .notes { transition: left 200ms cubic-bezier(.3,.7,.4,1); }
284
+
285
+ .thumb {
286
+ position: relative;
287
+ display: flex;
288
+ align-items: flex-start;
289
+ gap: 9px;
290
+ cursor: pointer;
291
+ user-select: none;
292
+ }
293
+ .thumb[data-dragging] { cursor: grabbing; }
294
+ .thumb .num {
295
+ width: 14px;
296
+ flex-shrink: 0;
297
+ font-size: 12px;
298
+ line-height: 1;
299
+ font-weight: 400;
300
+ text-align: center;
301
+ color: #646A73;
302
+ padding-top: 8px;
303
+ font-variant-numeric: tabular-nums;
304
+ }
305
+ .thumb .frame {
306
+ position: relative;
307
+ flex: 1;
308
+ min-width: 0;
309
+ aspect-ratio: var(--deck-aspect);
310
+ background: #fff;
311
+ border-radius: 6px;
312
+ /* Default hairline as a box-shadow, not a border: the frame is the
313
+ absolutely-positioned origin for the cloned slide, so a real border
314
+ would shift the clone; box-shadow doesn't affect layout, so
315
+ _scaleThumbs' frame.offsetWidth read stays correct. */
316
+ box-shadow: 0 0 0 1px #DEE0E3;
317
+ outline: 2px solid transparent;
318
+ outline-offset: 2px;
319
+ overflow: hidden;
320
+ transition: outline-color 120ms ease, box-shadow 120ms ease;
321
+ }
322
+ .thumb:hover .frame { outline-color: #1f23290d; }
323
+ .thumb { outline: none; }
324
+ .thumb:focus-visible .frame { outline-color: #1f23290d; }
325
+ .thumb[data-current] .num { color: #1F2329; font-weight: 500; }
326
+ .thumb[data-current] .frame { outline-color: #1F2329; }
327
+ /* Elevated "picked up" look on the *stationary* source while dragging
328
+ (outline hidden, card lifted); the ::before drop-line marks the landing
329
+ position. The floating ghost under the cursor is a separate custom
330
+ drag-image built in _makeDragGhost — the browser's native snapshot of
331
+ .thumb clipped this frame's outside-offset ring and carried the number
332
+ gutter, so we no longer let it draw the ghost. */
333
+ .thumb[data-dragging] .frame {
334
+ outline-color: transparent;
335
+ box-shadow: 0 8px 24px rgba(31,35,41,0.22), 0 0 0 1px rgba(31,35,41,0.08);
336
+ }
337
+ .thumb::before {
338
+ content: '';
339
+ position: absolute;
340
+ left: 24px;
341
+ right: 0;
342
+ height: 3px;
343
+ border-radius: 2px;
344
+ background: #336DF4;
345
+ opacity: 0;
346
+ pointer-events: none;
347
+ }
348
+ .thumb[data-drop="before"]::before { top: -8px; opacity: 1; }
349
+ .thumb[data-drop="after"]::before { bottom: -8px; opacity: 1; }
350
+ .thumb .skip-icon {
351
+ display: none;
352
+ position: absolute;
353
+ inset: 0;
354
+ align-items: center;
355
+ justify-content: center;
356
+ background: rgba(255,255,255,0.6);
357
+ z-index: 1;
358
+ }
359
+ .thumb[data-skip] .skip-icon { display: flex; }
360
+
361
+ .ctxmenu {
362
+ position: fixed;
363
+ min-width: 160px;
364
+ padding: 4px;
365
+ background: #fff;
366
+ color: #1F2329;
367
+ border: 0.5px solid #D2D5D8;
368
+ border-radius: 6px;
369
+ box-shadow: 0 4px 8px -8px rgba(0, 0, 0, 0.06), 0 6px 12px 0 rgba(0, 0, 0, 0.04), 0 8px 24px 8px rgba(0, 0, 0, 0.04);
370
+ z-index: 2147483100;
371
+ display: none;
372
+ font-size: 14px;
373
+ line-height: 22px;
374
+ }
375
+ .ctxmenu[data-open] { display: block; }
376
+ .ctxmenu button {
377
+ display: block;
378
+ width: 100%;
379
+ appearance: none;
380
+ border: 0;
381
+ background: transparent;
382
+ color: #1F2329;
383
+ font: inherit;
384
+ text-align: left;
385
+ padding: 6px 8px;
386
+ border-radius: 4px;
387
+ cursor: pointer;
388
+ }
389
+ .ctxmenu button:hover:not(:disabled) { background: #F2F3F5; }
390
+ .ctxmenu button:disabled { opacity: 0.35; cursor: default; }
391
+ .ctxmenu hr {
392
+ border: 0;
393
+ border-top: 0.5px solid #D2D5D8;
394
+ margin: 4px -4px;
395
+ }
396
+
397
+ .rail-resize {
398
+ position: fixed;
399
+ left: calc(var(--deck-rail-w, 192px) - 3px);
400
+ top: 0;
401
+ bottom: 0;
402
+ width: 6px;
403
+ cursor: col-resize;
404
+ z-index: 2147482600;
405
+ touch-action: none;
406
+ }
407
+ .rail-resize:hover,
408
+ .rail-resize[data-dragging] { background: rgba(255,255,255,0.12); }
409
+ :host([no-rail]) .rail-resize,
410
+ :host([noscale]) .rail-resize,
411
+ .rail[data-presenting] + .rail-resize,
412
+ .rail[data-user-hidden] + .rail-resize { display: none; }
413
+
414
+ /* ── 移动端竖排浏览 ─────────────────────────────────────── */
415
+ :host([data-mobile-list]) .stage {
416
+ display: block;
417
+ overflow-y: auto;
418
+ -webkit-overflow-scrolling: touch;
419
+ background: #F8F9FA;
420
+ }
421
+ :host([data-mobile-list]) .canvas {
422
+ transform: none !important;
423
+ background: transparent;
424
+ will-change: auto;
425
+ }
426
+ :host([data-mobile-list][data-windowed]) .canvas { border: none; box-shadow: none; }
427
+ :host([data-mobile-list]) .rail, :host([data-mobile-list]) .rail-resize { display: none; }
428
+ :host([data-mobile-list]) ::slotted(*) {
429
+ opacity: 1;
430
+ visibility: visible;
431
+ pointer-events: auto;
432
+ transform-origin: 0 0;
433
+ box-sizing: border-box;
434
+ }
435
+ /* Skipped slides get no nth-child placement rule from _fitMobileList —
436
+ without this they'd fall back to the base inset:0 stacking rule and,
437
+ now force-visible, paint over the whole list canvas and swallow taps.
438
+ (The equivalent rule further down lives inside @media print only.) */
439
+ :host([data-mobile-list]) ::slotted([data-deck-skip]) { display: none !important; }
440
+
441
+ /* ── 移动端播放:CSS 旋转伪横屏(不依赖 orientation.lock)────
442
+ 只在设备物理竖屏时旋转;用户顺势把手机转成横屏(系统自动旋转)
443
+ 后,视口本身已是横屏,再叠 90° 会让画面侧躺——此时退化为常规
444
+ full-bleed 适配,_fitMobilePresent/_mobPresentZone 同步按
445
+ orientation 分支换轴。 */
446
+ :host([data-mobile-present]) .stage { background: #000; }
447
+ @media (orientation: portrait) {
448
+ :host([data-mobile-present]) .stage {
449
+ inset: auto;
450
+ left: 50%;
451
+ top: 50%;
452
+ width: 100vh;
453
+ height: 100vw;
454
+ transform: translate(-50%, -50%) rotate(90deg);
455
+ }
456
+ }
457
+
458
+ /* ── 移动端页码徽标(规格:Figma 2965:32047)────────────── */
459
+ .mob-badges { display: none; }
460
+ :host([data-mobile-list]) .mob-badges {
461
+ display: block;
462
+ position: absolute;
463
+ inset: 0;
464
+ pointer-events: none;
465
+ z-index: 10;
466
+ }
467
+ .mob-badge {
468
+ position: absolute;
469
+ height: 12px;
470
+ box-sizing: border-box;
471
+ padding: 0 2px;
472
+ border-radius: 3px;
473
+ background: rgba(31, 35, 41, 0.2);
474
+ color: #fff;
475
+ font-family: 'PingFang SC', -apple-system, system-ui, sans-serif;
476
+ font-size: 8px;
477
+ line-height: 12px;
478
+ text-align: center;
479
+ opacity: 0;
480
+ transition: opacity 0.1s linear;
481
+ }
482
+ :host([data-mob-scrolling]) .mob-badge { opacity: 1; }
483
+
484
+ /* ── 移动端播放控件层(规格:Figma 2965:34386)──────────── */
485
+ .mob-ui { display: none; }
486
+ :host([data-mobile-present][data-mob-ui]) .mob-ui {
487
+ display: block;
488
+ position: absolute;
489
+ inset: 0;
490
+ pointer-events: none;
491
+ z-index: 20;
492
+ }
493
+ .mob-back {
494
+ position: absolute;
495
+ left: max(16px, env(safe-area-inset-left));
496
+ top: 12px;
497
+ pointer-events: auto;
498
+ display: flex;
499
+ align-items: center;
500
+ gap: 6px;
501
+ border: none;
502
+ border-radius: 999px;
503
+ padding: 6px 14px;
504
+ background: rgba(0, 0, 0, 0.4);
505
+ color: #fff;
506
+ font-family: 'PingFang SC', -apple-system, system-ui, sans-serif;
507
+ font-size: 15px;
508
+ font-weight: 500;
509
+ cursor: pointer;
510
+ }
511
+ .mob-strip {
512
+ position: absolute;
513
+ left: 0;
514
+ right: 0;
515
+ bottom: 0;
516
+ pointer-events: auto;
517
+ display: flex;
518
+ gap: 8px;
519
+ padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
520
+ /* 透明浮在 slide 上(验收对齐设计稿)——缩略图自带白底+描边已足够区分 */
521
+ background: transparent;
522
+ overflow-x: auto;
523
+ }
524
+ .mob-strip .mthumb { flex: 0 0 132px; cursor: pointer; }
525
+ .mob-strip .mframe {
526
+ width: 132px;
527
+ height: 72px;
528
+ border-radius: 8px;
529
+ border: 0.5px solid #DEE0E3;
530
+ overflow: hidden;
531
+ position: relative;
532
+ background: #fff;
533
+ }
534
+ .mob-strip .mthumb[data-current] .mframe {
535
+ outline: 1px solid #336DF4;
536
+ outline-offset: 2px;
537
+ border-radius: 10px;
538
+ }
539
+ .mob-strip .mnum {
540
+ margin-top: 2px;
541
+ text-align: center;
542
+ font-family: 'PingFang SC', -apple-system, system-ui, sans-serif;
543
+ font-size: 12px;
544
+ line-height: 20px;
545
+ font-weight: 500;
546
+ /* 条带透明、底衬通常是播放态黑底/slide 内容——页码用白色(对齐设计稿) */
547
+ color: rgba(255, 255, 255, 0.9);
548
+ }
549
+ .mob-strip .mthumb[data-current] .mnum { color: #336DF4; }
550
+
551
+ /* ── Speaker-notes dock ──────────────────────────────────────────────
552
+ Bottom panel spanning the non-rail width in edit mode: a resize grip
553
+ (drag to set height, persisted to localStorage) + an editable textarea
554
+ bound to the current slide's note. Hidden while presenting / preview /
555
+ narrow / noscale / no-rail / print — same editing-chrome gating as the
556
+ rail. Its left edge is set to the live rail width by _fit. */
557
+ .notes {
558
+ position: fixed;
559
+ left: var(--deck-rail-w, 192px);
560
+ right: 20px;
561
+ bottom: 0;
562
+ height: var(--deck-notes-h, 160px);
563
+ background: #fff;
564
+ border: 0.5px solid #D2D5D8;
565
+ border-bottom: none;
566
+ border-radius: 12px 12px 0 0;
567
+ box-shadow: 0 2px 4px -4px rgba(31, 35, 41, 0.02), 0 4px 8px 0 rgba(31, 35, 41, 0.02), 0 4px 16px 4px rgba(31, 35, 41, 0.03);
568
+ box-sizing: border-box;
569
+ display: flex;
570
+ flex-direction: column;
571
+ z-index: 2147482400;
572
+ }
573
+ :host([no-rail]) .notes,
574
+ :host([noscale]) .notes { display: none; }
575
+ .notes[data-hidden] { display: none; }
576
+ @media (max-width: 640px) { .notes { display: none; } }
577
+ .notes-handle {
578
+ flex-shrink: 0;
579
+ height: 20px;
580
+ display: flex;
581
+ align-items: center;
582
+ justify-content: center;
583
+ cursor: row-resize;
584
+ color: #8F959E;
585
+ font-size: 18px;
586
+ line-height: 20px;
587
+ letter-spacing: 2px;
588
+ user-select: none;
589
+ touch-action: none;
590
+ }
591
+ .notes-handle:hover,
592
+ .notes-handle[data-dragging] { color: #646A73; }
593
+ .notes-body {
594
+ flex: 1;
595
+ min-height: 0;
596
+ width: 100%;
597
+ box-sizing: border-box;
598
+ border: 0;
599
+ outline: none;
600
+ resize: none;
601
+ background: transparent;
602
+ color: #1F2329;
603
+ font: inherit;
604
+ font-size: 14px;
605
+ line-height: 1.6;
606
+ padding: 4px 24px 12px;
607
+ overflow-y: auto;
608
+ }
609
+ .notes-body::placeholder { color: #8F959E; }
610
+
611
+ /* ── Print: one page per slide, no chrome ────────────────────────────
612
+ The screen layout stacks every slide at inset:0 inside a scaled
613
+ canvas; for print we want them in document flow at the authored
614
+ design size so the browser paginates one slide per sheet. The
615
+ @page size is set from the width/height attributes via the inline
616
+ <style id="deck-stage-print-page"> that connectedCallback injects
617
+ into <head> (the @page at-rule has no effect inside shadow DOM). */
618
+ @media print {
619
+ :host {
620
+ position: static;
621
+ inset: auto;
622
+ background: none;
623
+ overflow: visible;
624
+ color: inherit;
625
+ }
626
+ .stage { position: static; display: block; }
627
+ .canvas {
628
+ transform: none !important;
629
+ width: auto !important;
630
+ height: auto !important;
631
+ background: none;
632
+ will-change: auto;
633
+ /* !important: :host([data-windowed]) .canvas (0,2,0) outranks a bare
634
+ .canvas rule inside this media query. */
635
+ box-shadow: none !important;
636
+ }
637
+ ::slotted(*) {
638
+ position: relative !important;
639
+ inset: auto !important;
640
+ width: var(--deck-design-w) !important;
641
+ height: var(--deck-design-h) !important;
642
+ box-sizing: border-box !important;
643
+ opacity: 1 !important;
644
+ visibility: visible !important;
645
+ pointer-events: auto;
646
+ break-after: page;
647
+ page-break-after: always;
648
+ break-inside: avoid;
649
+ overflow: hidden;
650
+ }
651
+ /* :last-child alone isn't enough once data-deck-skip hides the
652
+ trailing slide(s) — the last *visible* slide still carries
653
+ break-after:page and prints a blank sheet. _markLastVisible()
654
+ maintains data-deck-last-visible on the last non-skipped slide. */
655
+ ::slotted(*:last-child),
656
+ ::slotted([data-deck-last-visible]) {
657
+ break-after: auto;
658
+ page-break-after: auto;
659
+ }
660
+ ::slotted([data-deck-skip]) { display: none !important; }
661
+ .rail, .rail-resize, .ctxmenu, .notes { display: none !important; }
662
+ }
663
+ `;
664
+
665
+ class DeckStage extends HTMLElement {
666
+ static get observedAttributes() { return ['width', 'height', 'noscale', 'no-rail']; }
667
+
668
+ constructor() {
669
+ super();
670
+ this._root = this.attachShadow({ mode: 'open' });
671
+ this._index = 0;
672
+ this._slides = [];
673
+ this._notes = [];
674
+ this._menuIndex = -1;
675
+ this._notesPersistTimer = null;
676
+
677
+ this._onKey = this._onKey.bind(this);
678
+ this._onResize = this._onResize.bind(this);
679
+ this._onSlotChange = this._onSlotChange.bind(this);
680
+ this._onTap = this._onTap.bind(this);
681
+ this._onMessage = this._onMessage.bind(this);
682
+ this._onHashChange = this._onHashChange.bind(this);
683
+ // Capture-phase close so a click anywhere dismisses the menu, but
684
+ // ignore clicks that land inside the menu itself — otherwise the
685
+ // capture handler runs before the menu's own (bubble) handler and
686
+ // clears _menuIndex out from under it.
687
+ this._onDocClick = (e) => {
688
+ if (this._menu && e.composedPath && e.composedPath().includes(this._menu)) return;
689
+ this._closeMenu();
690
+ };
691
+ }
692
+
693
+ get designWidth() {
694
+ return parseInt(this.getAttribute('width'), 10) || DESIGN_W_DEFAULT;
695
+ }
696
+ get designHeight() {
697
+ return parseInt(this.getAttribute('height'), 10) || DESIGN_H_DEFAULT;
698
+ }
699
+
700
+ connectedCallback() {
701
+ // Capture modes that render a clean full-bleed single slide, both routed
702
+ // through the same no-rail path: the host's pre-screenshot ?thumbnail=1
703
+ // (product thumbnail — slide 1) and the presenter-view popup's
704
+ // ?_snthumb=...#N (prev/cur/next thumbnails). Both drop the rail (wrong
705
+ // scale, offsets the stage into a gutter) and render full-bleed — no
706
+ // windowed margin/shadow, see _fit. A bare load with no #N hash starts at
707
+ // slide 1, so ?thumbnail=1 alone yields a clean first-slide frame.
708
+ this._snthumb = /[?&](_snthumb|thumbnail)=/.test(location.search);
709
+ this._mobile = isMobileUA();
710
+ this._syncMobileMode();
711
+ if (this._snthumb) this.setAttribute('no-rail', '');
712
+ this._render();
713
+ this._loadNotes();
714
+ this._syncPrintPageRule();
715
+ window.addEventListener('keydown', this._onKey);
716
+ window.addEventListener('resize', this._onResize);
717
+ window.addEventListener('message', this._onMessage);
718
+ window.addEventListener('hashchange', this._onHashChange);
719
+ window.addEventListener('click', this._onDocClick, true);
720
+ this.addEventListener('click', this._onTap);
721
+ // Print lays every slide out as its own page, so [data-deck-active]-
722
+ // gated entrance styles need the attribute on every slide (not just
723
+ // the current one) or their content prints at the hidden base style.
724
+ // The transient freeze style lands BEFORE the attributes so any
725
+ // attribute-keyed transition fires at 0s (changing transition-
726
+ // duration after a transition has started doesn't affect it).
727
+ this._onBeforePrint = () => {
728
+ if (this._freezeStyle) this._freezeStyle.remove();
729
+ this._freezeStyle = document.createElement('style');
730
+ this._freezeStyle.textContent = '*,*::before,*::after{transition-duration:0s !important}';
731
+ document.head.appendChild(this._freezeStyle);
732
+ this._slides.forEach((s) => s.setAttribute('data-deck-active', ''));
733
+ };
734
+ this._onAfterPrint = () => {
735
+ this._applyIndex({ broadcast: false });
736
+ if (this._freezeStyle) { this._freezeStyle.remove(); this._freezeStyle = null; }
737
+ };
738
+ window.addEventListener('beforeprint', this._onBeforePrint);
739
+ window.addEventListener('afterprint', this._onAfterPrint);
740
+ // Initial collection + layout happens via slotchange, which fires on mount.
741
+ this._enableRail();
742
+ // Hold the stage hidden until webfonts are ready so the first visible
743
+ // paint has the deck's real typography — the :not(:defined) guard in
744
+ // the page HTML only covers custom-element upgrade, not font load.
745
+ // Capped so a 404'd font URL can't blank the deck indefinitely.
746
+ this.setAttribute('data-fonts-pending', '');
747
+ const reveal = () => this.removeAttribute('data-fonts-pending');
748
+ // rAF first: fonts.ready is a pre-resolved promise until layout has
749
+ // resolved the slotted text's font-family and pushed a FontFace into
750
+ // 'loading'. Reading it here in connectedCallback (parse-time) would
751
+ // settle the race in a microtask before any font fetch starts.
752
+ requestAnimationFrame(() => {
753
+ Promise.race([
754
+ document.fonts ? document.fonts.ready : Promise.resolve(),
755
+ new Promise((r) => setTimeout(r, 2000)),
756
+ ]).then(reveal, reveal);
757
+ });
758
+ // Announce to the host that this product IS a deck scene.
759
+ try { window.parent.postMessage({ type: 'miaoda:deck:available' }, '*'); } catch (e) {}
760
+ }
761
+
762
+ _enableRail() {
763
+ // Idempotent — host may post miaoda:deck:rail-enabled multiple times.
764
+ // no-rail guard keeps the observers/stylesheet walk off the cheap path
765
+ // for presenter-popup thumbnail iframes (up to 9 per view).
766
+ if (this._railEnabled || this.hasAttribute('no-rail')) return;
767
+ this._railEnabled = true;
768
+ // Per-viewer preference — restored alongside rail width. Default on;
769
+ // only a stored '0' (from the TweaksPanel toggle) hides it.
770
+ this._railVisible = true;
771
+ try {
772
+ if (localStorage.getItem('deck-stage.railVisible') === '0') this._railVisible = false;
773
+ } catch (e) {}
774
+ // Live thumbnail updates: watch the light-DOM slides for content
775
+ // edits and re-clone just the affected thumb(s), debounced. Ignore
776
+ // the data-deck-* / data-screen-label / data-miaoda-validate attributes
777
+ // this component itself writes so nav and skip don't trigger
778
+ // spurious refreshes.
779
+ const OWN_ATTRS = /^data-(deck-|screen-label$|om-validate$)/;
780
+ this._liveDirty = new Set();
781
+ this._liveObserver = new MutationObserver((records) => {
782
+ for (const r of records) {
783
+ if (r.type === 'attributes' && OWN_ATTRS.test(r.attributeName || '')) continue;
784
+ let n = r.target;
785
+ while (n && n.parentElement !== this) n = n.parentElement;
786
+ if (n && this._slideSet && this._slideSet.has(n)) this._liveDirty.add(n);
787
+ }
788
+ if (this._liveDirty.size && !this._liveTimer) {
789
+ this._liveTimer = setTimeout(() => {
790
+ this._liveTimer = null;
791
+ this._liveDirty.forEach((s) => this._refreshThumb(s));
792
+ this._liveDirty.clear();
793
+ }, 200);
794
+ }
795
+ });
796
+ this._liveObserver.observe(this, {
797
+ subtree: true, childList: true, characterData: true, attributes: true,
798
+ });
799
+ // Lazy thumbnail materialization — clone the slide only when its
800
+ // frame scrolls into (or near) the rail viewport. rootMargin gives
801
+ // ~4 thumbs of pre-load so fast scrolling doesn't flash blanks.
802
+ this._railObserver = new IntersectionObserver((entries) => {
803
+ entries.forEach((e) => {
804
+ if (e.isIntersecting && e.target.__deckThumb) {
805
+ this._materialize(e.target.__deckThumb);
806
+ }
807
+ });
808
+ }, { root: this._rail, rootMargin: '400px 0px' });
809
+ // Tweaks typically change CSS vars / attrs OUTSIDE <deck-stage>
810
+ // (on <html>, <body>, a wrapper div, or a <style> tag), which
811
+ // _liveObserver can't see. Re-snapshot author CSS (constructable
812
+ // sheet is shared by reference, so one replaceSync updates every
813
+ // thumb shadow root) and re-sync each thumb host's attrs + custom
814
+ // properties. In-slide DOM mutations are _liveObserver's job.
815
+ // Debounced so slider drags don't thrash.
816
+ this._onTweakChange = () => {
817
+ clearTimeout(this._tweakTimer);
818
+ this._tweakTimer = setTimeout(() => {
819
+ this._snapshotAuthorCss();
820
+ // One getComputedStyle for the whole batch — each
821
+ // getPropertyValue read below reuses the same computed style
822
+ // as long as nothing invalidates layout between thumbs.
823
+ const cs = getComputedStyle(this);
824
+ (this._thumbs || []).forEach((t) => {
825
+ if (t.host) this._syncThumbHostAttrs(t.host, cs);
826
+ });
827
+ }, 120);
828
+ };
829
+ window.addEventListener('tweakchange', this._onTweakChange);
830
+ this._snapshotAuthorCss();
831
+ // Build the rail now that it's enabled — slotchange already fired,
832
+ // so _renderRail's early-return skipped the initial build.
833
+ this._syncRailHidden();
834
+ this._renderRail();
835
+ this._fit();
836
+ // The notes dock is editing chrome too — show it and load the current
837
+ // slide's note now that the rail feature is enabled.
838
+ this._syncNotesHidden();
839
+ this._syncNotesBody();
840
+ }
841
+
842
+ /** Snapshot document stylesheets into a constructable sheet that each
843
+ * thumbnail's nested shadow root adopts — so author CSS styles the
844
+ * cloned slide content without touching this component's chrome.
845
+ * Re-callable: the constructable sheet is reused via replaceSync so
846
+ * every already-adopted shadow root picks up the fresh CSS.
847
+ *
848
+ * Cross-origin sheets (a CDN styles.css) throw SecurityError on
849
+ * .cssRules — the rules style the light DOM but CSSOM won't expose them,
850
+ * so the snapshot comes back empty and thumbnail clones render unstyled.
851
+ * Re-fetch those sheets' source and re-apply when it lands; the shared
852
+ * adopted sheet means one replaceSync restyles every clone (no re-clone).
853
+ * The fetch is CORS-gated — a CDN without Access-Control-Allow-Origin
854
+ * fails it and we degrade to an unstyled clone, no worse than before. */
855
+ _snapshotAuthorCss() {
856
+ // Generation guard: a stale fetch (from a snapshot a newer call has
857
+ // superseded) must not clobber the current adopted CSS.
858
+ const gen = (this._cssGen = (this._cssGen || 0) + 1);
859
+ if (!this._xoCssCache) this._xoCssCache = new Map(); // href → fetched text ('' = failed)
860
+ const pending = [];
861
+ this._applyAuthorCss(this._collectAuthorCss(pending));
862
+ if (!pending.length) return;
863
+ Promise.all(pending.map((href) =>
864
+ fetch(href).then((r) => (r.ok ? r.text() : '')).catch(() => '')
865
+ .then((text) => { this._xoCssCache.set(href, text); })
866
+ )).then(() => {
867
+ if (this._cssGen !== gen) return;
868
+ this._applyAuthorCss(this._collectAuthorCss(null));
869
+ if (this._adoptedSheet) {
870
+ // Shared sheet already restyled every clone — just re-mirror vars.
871
+ const cs = getComputedStyle(this);
872
+ (this._thumbs || []).forEach((t) => { if (t.host) this._syncThumbHostAttrs(t.host, cs); });
873
+ } else {
874
+ // Fallback (no constructable sheets): each clone baked its own
875
+ // <style> at materialize, so re-clone to pick up the fetched CSS.
876
+ (this._thumbs || []).forEach((t) => { if (t.host) this._refreshThumb(t.slide); });
877
+ }
878
+ });
879
+ }
880
+
881
+ /** Concatenate every document stylesheet's rules in document order. A
882
+ * cross-origin sheet whose .cssRules throws is unreadable via CSSOM:
883
+ * substitute its re-fetched source if cached, else push its href to
884
+ * pendingOut (when provided) and leave a placeholder until the fetch lands. */
885
+ _collectAuthorCss(pendingOut) {
886
+ return Array.from(document.styleSheets).map((sh) => {
887
+ try {
888
+ return Array.from(sh.cssRules).map((r) => r.cssText).join('\n');
889
+ } catch (e) {
890
+ const href = sh.href;
891
+ if (!href) return '';
892
+ if (this._xoCssCache.has(href)) return this._xoCssCache.get(href);
893
+ if (pendingOut) pendingOut.push(href);
894
+ return '';
895
+ }
896
+ }).join('\n');
897
+ }
898
+
899
+ /** Rewrite raw author CSS so its document-scoped selectors match inside a
900
+ * thumbnail's shadow root, then (re)load it into the shared adopted sheet.
901
+ * :root in an adopted sheet inside a shadow root matches nothing (only the
902
+ * document root qualifies), so author rules like
903
+ * `:root[data-voice="modern"] .serif` never reach the clones. Rewrite
904
+ * :root → :host and mirror <html>'s data-*, class and lang onto each thumb
905
+ * host (see _syncThumbHostAttrs) so the same selectors match inside it.
906
+ * The <deck-stage> type selector gets the same treatment: from a clone's
907
+ * point of view inside an isolated thumb shadow root there is no real
908
+ * <deck-stage> ancestor, so a rule like `deck-stage section { background }`
909
+ * (the common per-slide default-background idiom) would never match and
910
+ * the clone renders with a transparent background — through which the
911
+ * page-level html/body background (rewritten to :host above) leaks. The
912
+ * thumb's own host IS the deck-stage from the clone's perspective, so
913
+ * alias deck-stage → :host to restore the match. */
914
+ _applyAuthorCss(rawCss) {
915
+ const authorCss = rawCss
916
+ // The shadow host is featureless outside the functional :host(...)
917
+ // form, so any compound on :root — [attr], .class, #id, :pseudo —
918
+ // must become :host(<compound>) not :host<compound>. Same for the
919
+ // html type selector (Tailwind class-strategy dark mode emits
920
+ // html.dark; Pico uses html[data-theme]), which has nothing to
921
+ // match inside the thumb's shadow tree.
922
+ .replace(/:root((?:\[[^\]]*\]|[.#][-\w]+|:[-\w]+(?:\([^)]*\))?)+)/g, ':host($1)')
923
+ .replace(/:root\b/g, ':host')
924
+ .replace(/(^|[\s,>~+(}])html((?:\[[^\]]*\]|[.#][-\w]+|:[-\w]+(?:\([^)]*\))?)+)(?![-\w])/g, '$1:host($2)')
925
+ .replace(/(^|[\s,>~+(}])html(?![-\w])/g, '$1:host')
926
+ // Same rewrite for the deck-stage type selector (see the doc comment).
927
+ // The negative-lookahead (?![-\w]) keeps `.deck-stage-title` and the
928
+ // `--deck-stage-*` custom-property names untouched.
929
+ .replace(/(^|[\s,>~+(}])deck-stage((?:\[[^\]]*\]|[.#][-\w]+|:[-\w]+(?:\([^)]*\))?)+)(?![-\w])/g, '$1:host($2)')
930
+ .replace(/(^|[\s,>~+(}])deck-stage(?![-\w])/g, '$1:host');
931
+ // Every custom property the author references. _syncThumbHostAttrs
932
+ // mirrors each one's *computed* value at <deck-stage> onto the
933
+ // thumb host so the live value wins over the :host default above
934
+ // regardless of which ancestor the tweak wrote to (<html>, <body>,
935
+ // a wrapper div, or the deck-stage element itself all inherit
936
+ // down to getComputedStyle(this)).
937
+ this._authorVars = new Set(authorCss.match(/--[\w-]+/g) || []);
938
+ try {
939
+ if (!this._adoptedSheet) this._adoptedSheet = new CSSStyleSheet();
940
+ this._adoptedSheet.replaceSync(authorCss);
941
+ } catch (e) {
942
+ this._adoptedSheet = null;
943
+ this._authorCss = authorCss;
944
+ }
945
+ }
946
+
947
+ _syncThumbHostAttrs(host, cs) {
948
+ const de = document.documentElement;
949
+ // setAttribute overwrites but can't delete — an attr removed from
950
+ // <html> (toggleAttribute off, classList emptied) would linger on
951
+ // the host and :host([data-*]) / :host(.foo) rules would keep
952
+ // matching. Remove stale mirrored attrs first; iterate backward
953
+ // because removeAttribute mutates the live NamedNodeMap.
954
+ for (let i = host.attributes.length - 1; i >= 0; i--) {
955
+ const n = host.attributes[i].name;
956
+ if ((n.startsWith('data-') || n === 'class' || n === 'lang')
957
+ && !de.hasAttribute(n)) {
958
+ host.removeAttribute(n);
959
+ }
960
+ }
961
+ for (const a of de.attributes) {
962
+ if (a.name.startsWith('data-') || a.name === 'class' || a.name === 'lang') {
963
+ host.setAttribute(a.name, a.value);
964
+ }
965
+ }
966
+ // The :root→:host rewrite in _snapshotAuthorCss pins each custom
967
+ // property to its stylesheet default on the thumb host, shadowing
968
+ // the live value that would otherwise inherit. Tweaks can write the
969
+ // live value on any ancestor — <html>, <body>, a wrapper div, the
970
+ // deck-stage element — so read it as the *computed* value at
971
+ // <deck-stage> (which sees the whole inheritance chain) rather than
972
+ // trying to guess which element the author wrote to. Inline on the
973
+ // host beats the :host{} rule. remove-stale covers vars dropped
974
+ // from the stylesheet between snapshots.
975
+ const vars = this._authorVars || new Set();
976
+ for (let i = host.style.length - 1; i >= 0; i--) {
977
+ const p = host.style[i];
978
+ if (p.startsWith('--') && !vars.has(p)) host.style.removeProperty(p);
979
+ }
980
+ const live = cs || getComputedStyle(this);
981
+ vars.forEach((p) => {
982
+ const v = live.getPropertyValue(p);
983
+ if (v) host.style.setProperty(p, v.trim());
984
+ else host.style.removeProperty(p);
985
+ });
986
+ }
987
+
988
+ disconnectedCallback() {
989
+ window.removeEventListener('keydown', this._onKey);
990
+ window.removeEventListener('resize', this._onResize);
991
+ window.removeEventListener('message', this._onMessage);
992
+ window.removeEventListener('hashchange', this._onHashChange);
993
+ window.removeEventListener('click', this._onDocClick, true);
994
+ window.removeEventListener('beforeprint', this._onBeforePrint);
995
+ window.removeEventListener('afterprint', this._onAfterPrint);
996
+ if (this._freezeStyle) { this._freezeStyle.remove(); this._freezeStyle = null; }
997
+ if (this._dragGhost) { this._dragGhost.remove(); this._dragGhost = null; }
998
+ this.removeEventListener('click', this._onTap);
999
+ if (this._liveTimer) clearTimeout(this._liveTimer);
1000
+ if (this._tweakTimer) clearTimeout(this._tweakTimer);
1001
+ if (this._railAnimTimer) clearTimeout(this._railAnimTimer);
1002
+ if (this._notesPersistTimer) clearTimeout(this._notesPersistTimer);
1003
+ clearTimeout(this._mobUiTimer);
1004
+ clearTimeout(this._mobScrollIdleTimer);
1005
+ if (this._mobScrollRaf) { cancelAnimationFrame(this._mobScrollRaf); this._mobScrollRaf = null; }
1006
+ if (this._scaleRaf) cancelAnimationFrame(this._scaleRaf);
1007
+ if (this._liveObserver) this._liveObserver.disconnect();
1008
+ if (this._railObserver) this._railObserver.disconnect();
1009
+ if (this._onTweakChange) window.removeEventListener('tweakchange', this._onTweakChange);
1010
+ }
1011
+
1012
+ attributeChangedCallback() {
1013
+ if (this._canvas) {
1014
+ this._canvas.style.width = this.designWidth + 'px';
1015
+ this._canvas.style.height = this.designHeight + 'px';
1016
+ this._canvas.style.setProperty('--deck-design-w', this.designWidth + 'px');
1017
+ this._canvas.style.setProperty('--deck-design-h', this.designHeight + 'px');
1018
+ if (this._rail) {
1019
+ this._rail.style.setProperty('--deck-aspect', this.designWidth + '/' + this.designHeight);
1020
+ }
1021
+ this._syncMobileMode();
1022
+ this._fit();
1023
+ this._scaleThumbs();
1024
+ this._syncPrintPageRule();
1025
+ }
1026
+ }
1027
+
1028
+ _render() {
1029
+ const style = document.createElement('style');
1030
+ style.textContent = stylesheet;
1031
+
1032
+ const stage = document.createElement('div');
1033
+ stage.className = 'stage';
1034
+
1035
+ const canvas = document.createElement('div');
1036
+ canvas.className = 'canvas';
1037
+ canvas.style.width = this.designWidth + 'px';
1038
+ canvas.style.height = this.designHeight + 'px';
1039
+ canvas.style.setProperty('--deck-design-w', this.designWidth + 'px');
1040
+ canvas.style.setProperty('--deck-design-h', this.designHeight + 'px');
1041
+
1042
+ const slot = document.createElement('slot');
1043
+ slot.addEventListener('slotchange', this._onSlotChange);
1044
+ canvas.appendChild(slot);
1045
+ stage.appendChild(canvas);
1046
+
1047
+ this._onMobScroll = this._onMobScroll.bind(this);
1048
+ stage.addEventListener('scroll', this._onMobScroll, { passive: true });
1049
+
1050
+ // 移动竖排的逐页定位规则(::slotted(:nth-child(i))),_fitMobileList 重建。
1051
+ this._mobStyle = document.createElement('style');
1052
+
1053
+ this._mobBadges = document.createElement('div');
1054
+ this._mobBadges.className = 'mob-badges export-hidden';
1055
+ this._mobBadges.setAttribute('data-miaoda-chrome', '');
1056
+ canvas.appendChild(this._mobBadges);
1057
+
1058
+ // Thumbnail rail + context menu. Thumbnails are populated in
1059
+ // _renderRail() after _collectSlides().
1060
+ const rail = document.createElement('div');
1061
+ rail.className = 'rail export-hidden';
1062
+ rail.setAttribute('data-miaoda-chrome', '');
1063
+ rail.style.setProperty('--deck-aspect', this.designWidth + '/' + this.designHeight);
1064
+ // Edge auto-scroll while dragging a thumb near the rail's top/bottom
1065
+ // so off-screen drop targets are reachable. Native dragover fires
1066
+ // continuously while the pointer is stationary, so a per-event nudge
1067
+ // (ramped by edge proximity) is enough — no rAF loop needed.
1068
+ rail.addEventListener('dragover', (e) => {
1069
+ if (this._dragFrom == null) return;
1070
+ // preventDefault makes the whole rail — including the gaps between
1071
+ // thumbs, where the drop-line actually renders — a valid drop target.
1072
+ // Without it a release in a gap is rejected and the slide snaps back
1073
+ // even though the indicator was showing.
1074
+ e.preventDefault();
1075
+ e.dataTransfer.dropEffect = 'move';
1076
+ const r = rail.getBoundingClientRect();
1077
+ const EDGE = 40;
1078
+ const dt = e.clientY - r.top;
1079
+ const db = r.bottom - e.clientY;
1080
+ if (dt < EDGE) rail.scrollTop -= Math.ceil((EDGE - dt) / 3);
1081
+ else if (db < EDGE) rail.scrollTop += Math.ceil((EDGE - db) / 3);
1082
+ // Over a thumb its own dragover set the indicator (O(1)); over a gap
1083
+ // no thumb handler fires, so resolve the nearest slot here.
1084
+ const overThumb = e.target && e.target.closest && e.target.closest('.thumb');
1085
+ if (!overThumb) {
1086
+ const tgt = this._railDropTarget(e.clientY);
1087
+ if (tgt) this._setDrop(tgt.i, tgt.pos);
1088
+ }
1089
+ });
1090
+ rail.addEventListener('drop', (e) => {
1091
+ // A drop over a thumb is handled by the thumb's own handler, which
1092
+ // nulls _dragFrom before the event bubbles here — so this branch only
1093
+ // runs for releases that land in a gap / the rail padding. It commits
1094
+ // to whatever slot the indicator is currently showing.
1095
+ if (this._dragFrom == null) return;
1096
+ e.preventDefault();
1097
+ const on = this._dropOn;
1098
+ const from = this._dragFrom;
1099
+ this._dragFrom = null;
1100
+ let to = on ? (this._dropWhere === 'after' ? on.i + 1 : on.i) : from;
1101
+ if (from < to) to--;
1102
+ this._clearDrop();
1103
+ if (on && to !== from) this._moveSlide(from, to);
1104
+ });
1105
+
1106
+ const menu = document.createElement('div');
1107
+ menu.className = 'ctxmenu export-hidden';
1108
+ menu.setAttribute('data-miaoda-chrome', '');
1109
+ menu.innerHTML = `
1110
+ <button type="button" data-act="skip">隐藏此页</button>
1111
+ <button type="button" data-act="up">上移</button>
1112
+ <button type="button" data-act="down">下移</button>
1113
+ <hr>
1114
+ <button type="button" data-act="delete">删除</button>
1115
+ `;
1116
+ menu.addEventListener('click', (e) => {
1117
+ const act = e.target && e.target.getAttribute && e.target.getAttribute('data-act');
1118
+ if (!act) return;
1119
+ const i = this._menuIndex;
1120
+ this._closeMenu();
1121
+ if (act === 'skip') this._toggleSkip(i);
1122
+ else if (act === 'up') this._moveSlide(i, i - 1);
1123
+ else if (act === 'down') this._moveSlide(i, i + 1);
1124
+ else if (act === 'delete') this._deleteSlide(i);
1125
+ });
1126
+ menu.addEventListener('contextmenu', (e) => e.preventDefault());
1127
+
1128
+ // Rail resize handle — drag to set --deck-rail-w, persisted to
1129
+ // localStorage so the width survives reloads.
1130
+ const resize = document.createElement('div');
1131
+ resize.className = 'rail-resize export-hidden';
1132
+ resize.setAttribute('data-miaoda-chrome', '');
1133
+ resize.addEventListener('pointerdown', (e) => {
1134
+ e.preventDefault();
1135
+ resize.setPointerCapture(e.pointerId);
1136
+ resize.setAttribute('data-dragging', '');
1137
+ const move = (ev) => this._setRailWidth(ev.clientX);
1138
+ const up = () => {
1139
+ resize.removeEventListener('pointermove', move);
1140
+ resize.removeEventListener('pointerup', up);
1141
+ resize.removeEventListener('pointercancel', up);
1142
+ resize.removeAttribute('data-dragging');
1143
+ try { localStorage.setItem('deck-stage.railWidth', String(this._railPx)); } catch (err) {}
1144
+ };
1145
+ resize.addEventListener('pointermove', move);
1146
+ resize.addEventListener('pointerup', up);
1147
+ resize.addEventListener('pointercancel', up);
1148
+ });
1149
+
1150
+ // Speaker-notes dock — a resize grip + an editable textarea bound to the
1151
+ // current slide's note. Chrome (export-hidden / data-miaoda-chrome) like
1152
+ // the rail/overlay so it's excluded from PPTX/screenshot capture.
1153
+ const notes = document.createElement('div');
1154
+ notes.className = 'notes export-hidden';
1155
+ notes.setAttribute('data-miaoda-chrome', '');
1156
+ const notesHandle = document.createElement('div');
1157
+ notesHandle.className = 'notes-handle';
1158
+ notesHandle.innerHTML = '<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M13.3008 11.4434C13.3008 10.822 13.8045 10.3184 14.4258 10.3184C15.047 10.3185 15.5508 10.8221 15.5508 11.4434C15.5508 12.0646 15.047 12.5682 14.4258 12.5684C13.8045 12.5684 13.3008 12.0647 13.3008 11.4434ZM13.3008 6.55664C13.3008 5.93532 13.8045 5.43164 14.4258 5.43164C15.047 5.43179 15.5508 5.93541 15.5508 6.55664C15.5508 7.17787 15.047 7.68149 14.4258 7.68164C13.8045 7.68164 13.3008 7.17796 13.3008 6.55664ZM7.875 11.4434C7.875 10.822 8.37868 10.3184 9 10.3184C9.62132 10.3184 10.125 10.822 10.125 11.4434C10.125 12.0647 9.62132 12.5684 9 12.5684C8.37868 12.5684 7.875 12.0647 7.875 11.4434ZM7.875 6.55664C7.875 5.93532 8.37868 5.43164 9 5.43164C9.62132 5.43164 10.125 5.93532 10.125 6.55664C10.125 7.17796 9.62132 7.68164 9 7.68164C8.37868 7.68164 7.875 7.17796 7.875 6.55664ZM2.44922 11.4434C2.44922 10.822 2.9529 10.3184 3.57422 10.3184C4.19554 10.3184 4.69922 10.822 4.69922 11.4434C4.69922 12.0647 4.19554 12.5684 3.57422 12.5684C2.9529 12.5684 2.44922 12.0647 2.44922 11.4434ZM2.44922 6.55664C2.44922 5.93532 2.9529 5.43164 3.57422 5.43164C4.19554 5.43164 4.69922 5.93532 4.69922 6.55664C4.69922 7.17796 4.19554 7.68164 3.57422 7.68164C2.9529 7.68164 2.44922 7.17796 2.44922 6.55664Z" fill="currentColor"/></svg>';
1159
+ notesHandle.setAttribute('title', '拖动调整高度');
1160
+ const notesBody = document.createElement('textarea');
1161
+ notesBody.className = 'notes-body';
1162
+ notesBody.setAttribute('placeholder', '点击添加演示者备注');
1163
+ notesBody.setAttribute('aria-label', '演示者备注');
1164
+ notesBody.setAttribute('spellcheck', 'false');
1165
+ notes.append(notesHandle, notesBody);
1166
+
1167
+ // Drag the grip to resize the dock height (mirror the rail-resize
1168
+ // pattern); persist to localStorage so it survives reloads.
1169
+ notesHandle.addEventListener('pointerdown', (e) => {
1170
+ e.preventDefault();
1171
+ notesHandle.setPointerCapture(e.pointerId);
1172
+ notesHandle.setAttribute('data-dragging', '');
1173
+ const move = (ev) => this._setNotesHeight(window.innerHeight - ev.clientY);
1174
+ const up = () => {
1175
+ notesHandle.removeEventListener('pointermove', move);
1176
+ notesHandle.removeEventListener('pointerup', up);
1177
+ notesHandle.removeEventListener('pointercancel', up);
1178
+ notesHandle.removeAttribute('data-dragging');
1179
+ try { localStorage.setItem('deck-stage.notesHeight', String(this._notesPx)); } catch (err) {}
1180
+ };
1181
+ notesHandle.addEventListener('pointermove', move);
1182
+ notesHandle.addEventListener('pointerup', up);
1183
+ notesHandle.addEventListener('pointercancel', up);
1184
+ });
1185
+
1186
+ // Edit → write the current slide's note + debounced persist.
1187
+ notesBody.addEventListener('input', () => {
1188
+ if (!Array.isArray(this._notes)) this._notes = [];
1189
+ this._notes[this._index] = notesBody.value;
1190
+ this._scheduleNotesPersist();
1191
+ });
1192
+ // At window scope the keydown target is retargeted to the host
1193
+ // <deck-stage> (not this textarea), so _onKey's INPUT/TEXTAREA guard
1194
+ // never matches — stop propagation here so typing (space/arrows/…) edits
1195
+ // the note instead of navigating slides (same trick as the thumb keydown).
1196
+ notesBody.addEventListener('keydown', (e) => e.stopPropagation());
1197
+
1198
+ this._root.append(style, this._mobStyle, rail, resize, stage, notes, menu);
1199
+ this._canvas = canvas;
1200
+ this._stage = stage;
1201
+ this._slot = slot;
1202
+ this._rail = rail;
1203
+ this._resize = resize;
1204
+ this._menu = menu;
1205
+ this._notesPanel = notes;
1206
+ this._notesHandle = notesHandle;
1207
+ this._notesBody = notesBody;
1208
+
1209
+ // Restore persisted rail width.
1210
+ let rw = 192;
1211
+ try {
1212
+ const s = localStorage.getItem('deck-stage.railWidth');
1213
+ if (s) rw = parseInt(s, 10) || rw;
1214
+ } catch (err) {}
1215
+ this._setRailWidth(rw);
1216
+ this._syncRailHidden();
1217
+
1218
+ // Restore persisted notes-dock height.
1219
+ let nh = 160;
1220
+ try {
1221
+ const s = localStorage.getItem('deck-stage.notesHeight');
1222
+ if (s) nh = parseInt(s, 10) || nh;
1223
+ } catch (err) {}
1224
+ this._setNotesHeight(nh);
1225
+ this._syncNotesHidden();
1226
+ }
1227
+
1228
+ _setRailWidth(px) {
1229
+ const w = Math.max(120, Math.min(360, Math.round(px)));
1230
+ this._railPx = w;
1231
+ this.style.setProperty('--deck-rail-w', w + 'px');
1232
+ this._fit();
1233
+ // _scaleThumbs forces a sync layout (frame.offsetWidth) then writes
1234
+ // N transforms. During a resize drag this runs per-pointermove;
1235
+ // coalesce to one per frame.
1236
+ if (!this._scaleRaf) {
1237
+ this._scaleRaf = requestAnimationFrame(() => {
1238
+ this._scaleRaf = null;
1239
+ this._scaleThumbs();
1240
+ });
1241
+ }
1242
+ }
1243
+
1244
+ // ── Speaker-notes dock ────────────────────────────────────────────────
1245
+
1246
+ _setNotesHeight(px) {
1247
+ const max = Math.max(120, Math.round(window.innerHeight * 0.5));
1248
+ const h = Math.max(58, Math.min(max, Math.round(px)));
1249
+ this._notesPx = h;
1250
+ this.style.setProperty('--deck-notes-h', h + 'px');
1251
+ this._fit();
1252
+ }
1253
+
1254
+ _notesVisible() {
1255
+ if (NOTES_DISABLED) return false;
1256
+ return !!this._hasNotesTag && !!this._railEnabled
1257
+ && !this._presenting && !this._previewMode
1258
+ && !this.hasAttribute('noscale') && !this.hasAttribute('no-rail')
1259
+ && !NARROW_MQ.matches;
1260
+ }
1261
+
1262
+ /** Height _fit subtracts from the stage region. Clamped so a tall dock on
1263
+ * a short window still leaves ~160px of canvas (no negative/NaN scale). */
1264
+ _notesHeight() {
1265
+ if (!this._notesVisible()) return 0;
1266
+ return Math.min(this._notesPx || 0, Math.max(0, window.innerHeight - 160));
1267
+ }
1268
+
1269
+ _syncNotesHidden() {
1270
+ if (!this._notesPanel) return;
1271
+ // Static conditions (no-rail/noscale/narrow/print) live in CSS; toggle
1272
+ // only the runtime-dynamic hide (presenting / preview / rail disabled).
1273
+ const hide = NOTES_DISABLED || !this._hasNotesTag || !this._railEnabled || this._presenting || this._previewMode;
1274
+ this._notesPanel.toggleAttribute('data-hidden', hide);
1275
+ this._notesPanel.inert = !this._notesVisible(); // keep textarea out of tab order when hidden
1276
+ }
1277
+
1278
+ /** Load the current slide's note into the body (no-op if unchanged so it
1279
+ * doesn't disturb an in-progress edit). */
1280
+ _syncNotesBody() {
1281
+ if (!this._notesBody) return;
1282
+ const val = (Array.isArray(this._notes) && this._notes[this._index]) || '';
1283
+ if (this._notesBody.value !== val) this._notesBody.value = val;
1284
+ }
1285
+
1286
+ /** this._notes with holes → '' and length preserved (safe to JSON). */
1287
+ _notesForEmit() {
1288
+ const src = Array.isArray(this._notes) ? this._notes : [];
1289
+ return Array.from(src, (v) => (typeof v === 'string' ? v : (v == null ? '' : String(v))));
1290
+ }
1291
+
1292
+ _scheduleNotesPersist() {
1293
+ clearTimeout(this._notesPersistTimer);
1294
+ this._notesPersistTimer = setTimeout(() => this._emitNotesChange(), 300);
1295
+ }
1296
+
1297
+ /** Persist a pure text edit: mirror the in-DOM #speaker-notes tag (if any)
1298
+ * for in-iframe consumers, and post the full array to the parent so the
1299
+ * host writes it to disk — the tag lives OUTSIDE <deck-stage>, so the
1300
+ * deck-changed outerHTML path can't carry notes. */
1301
+ _emitNotesChange() {
1302
+ const notes = this._notesForEmit();
1303
+ this._notes = notes;
1304
+ const tag = document.getElementById('speaker-notes');
1305
+ if (tag) tag.textContent = JSON.stringify(notes);
1306
+ this.dispatchEvent(new CustomEvent('noteschange', {
1307
+ detail: { notes, index: this._index }, bubbles: true, composed: true,
1308
+ }));
1309
+ try { window.parent.postMessage({ type: 'miaoda:deck:notes-changed', index: this._index, notes }, '*'); } catch (e) {}
1310
+ }
1311
+
1312
+ /** @page must live in the document stylesheet — it's a no-op inside
1313
+ * shadow DOM. Inject/update a single <head> style tag so the print
1314
+ * sheet matches the design size and Save-as-PDF yields one slide per
1315
+ * page with no margins. */
1316
+ _syncPrintPageRule() {
1317
+ const id = 'deck-stage-print-page';
1318
+ let tag = document.getElementById(id);
1319
+ if (!tag) {
1320
+ tag = document.createElement('style');
1321
+ tag.id = id;
1322
+ document.head.appendChild(tag);
1323
+ }
1324
+ tag.textContent =
1325
+ '@page { size: ' + this.designWidth + 'px ' + this.designHeight + 'px; margin: 0; } ' +
1326
+ '@media print { html, body { margin: 0 !important; padding: 0 !important; background: none !important; overflow: visible !important; height: auto !important; } ' +
1327
+ '* { -webkit-print-color-adjust: exact; print-color-adjust: exact; } ' +
1328
+ // Jump authored animations/transitions to their end state so print
1329
+ // never captures mid-entrance — pairs with the beforeprint handler
1330
+ // in connectedCallback that sets data-deck-active on every slide.
1331
+ '*, *::before, *::after { animation-delay: -99s !important; animation-duration: .001s !important; ' +
1332
+ 'animation-iteration-count: 1 !important; animation-fill-mode: both !important; ' +
1333
+ 'animation-play-state: running !important; transition-duration: 0s !important; } }';
1334
+ }
1335
+
1336
+ _onSlotChange() {
1337
+ // Rail mutations (delete/move) already reconcile synchronously and
1338
+ // emit slidechange with reason 'api'; skip the async slotchange that
1339
+ // would otherwise re-broadcast with reason 'init'.
1340
+ if (this._squelchSlotChange) { this._squelchSlotChange = false; return; }
1341
+ this._collectSlides();
1342
+ this._restoreIndex();
1343
+ this._applyIndex({ broadcast: true, reason: 'init' });
1344
+ this._fit();
1345
+ }
1346
+
1347
+ _collectSlides() {
1348
+ const assigned = this._slot.assignedElements({ flatten: true });
1349
+ this._slides = assigned.filter((el) => {
1350
+ // Skip template/style/script nodes even if someone slots them.
1351
+ const tag = el.tagName;
1352
+ return tag !== 'TEMPLATE' && tag !== 'SCRIPT' && tag !== 'STYLE';
1353
+ });
1354
+ this._slideSet = new Set(this._slides);
1355
+
1356
+ this._slides.forEach((slide, i) => {
1357
+ const n = i + 1;
1358
+ slide.setAttribute('data-screen-label', `${pad2(n)} ${getSlideLabel(slide)}`);
1359
+
1360
+ // Validation attribute for comment flow / auto-checks.
1361
+ if (!slide.hasAttribute('data-miaoda-validate')) {
1362
+ slide.setAttribute('data-miaoda-validate', VALIDATE_ATTR);
1363
+ }
1364
+
1365
+ slide.setAttribute('data-deck-slide', String(i));
1366
+ });
1367
+
1368
+ if (this._index >= this._slides.length) this._index = Math.max(0, this._slides.length - 1);
1369
+ this._markLastVisible();
1370
+ this._renderRail();
1371
+ }
1372
+
1373
+ /** Tag the last non-skipped slide so print CSS can drop its
1374
+ * break-after (see the @media print comment above — :last-child
1375
+ * alone matches a hidden skipped slide). */
1376
+ _markLastVisible() {
1377
+ let last = null;
1378
+ this._slides.forEach((s) => {
1379
+ s.removeAttribute('data-deck-last-visible');
1380
+ if (!s.hasAttribute('data-deck-skip')) last = s;
1381
+ });
1382
+ if (last) last.setAttribute('data-deck-last-visible', '');
1383
+ }
1384
+
1385
+ _loadNotes() {
1386
+ const tag = document.getElementById('speaker-notes');
1387
+ this._hasNotesTag = !!tag;
1388
+ if (!tag) { this._notes = []; return; }
1389
+ try {
1390
+ const parsed = JSON.parse(tag.textContent || '[]');
1391
+ if (Array.isArray(parsed)) this._notes = parsed;
1392
+ } catch (e) {
1393
+ console.warn('[deck-stage] Failed to parse #speaker-notes JSON:', e);
1394
+ this._notes = [];
1395
+ }
1396
+ }
1397
+
1398
+ /** Parse a `#<int>` fragment (1-indexed) into a 0-based slide index, or
1399
+ * -1 when the hash is absent/malformed or points outside the deck.
1400
+ * Shared by _restoreIndex (load time) and _onHashChange (runtime). */
1401
+ _slideFromHash() {
1402
+ const h = (location.hash || '').match(/^#(\d+)$/);
1403
+ if (!h) return -1;
1404
+ const n = parseInt(h[1], 10) - 1;
1405
+ return n >= 0 && n < this._slides.length ? n : -1;
1406
+ }
1407
+
1408
+ _restoreIndex() {
1409
+ // The host's ?slide= param is delivered as a #<int> hash (1-indexed) on
1410
+ // the iframe src. No hash → slide 1; the deck itself keeps no position
1411
+ // state across loads.
1412
+ const n = this._slideFromHash();
1413
+ if (n >= 0) this._index = n;
1414
+ }
1415
+
1416
+ _onHashChange() {
1417
+ // Runtime counterpart to _restoreIndex, which only reads the fragment
1418
+ // once (at connectedCallback). A same-document fragment change — a deep
1419
+ // link edited in the address bar, or an automation/screenshot tool that
1420
+ // navigates to #N without a full reload — fires hashchange but never
1421
+ // re-runs connectedCallback, so honor it here too. The deck's own
1422
+ // navigation writes the fragment via history.replaceState, which does
1423
+ // NOT fire hashchange, so this can't feed back into a loop; the index
1424
+ // guard also makes a redundant fragment a no-op.
1425
+ const n = this._slideFromHash();
1426
+ if (n >= 0 && n !== this._index) this._go(n, 'api');
1427
+ }
1428
+
1429
+ _applyIndex({ broadcast = true, reason = 'init' } = {}) {
1430
+ if (!this._slides.length) return;
1431
+ const prev = this._prevIndex == null ? -1 : this._prevIndex;
1432
+ const curr = this._index;
1433
+ // Keep the iframe's own hash in sync so an in-iframe location.reload()
1434
+ // (reload banner path in viewer-handle.ts) lands on the current slide,
1435
+ // not the stale deep-link hash from initial load.
1436
+ try { history.replaceState(null, '', '#' + (curr + 1)); } catch (e) {}
1437
+ this._slides.forEach((s, i) => {
1438
+ if (i === curr) s.setAttribute('data-deck-active', '');
1439
+ else s.removeAttribute('data-deck-active');
1440
+ });
1441
+ // Follow-scroll on every navigation (init deep-link, keyboard, click,
1442
+ // tap, external goTo) — the only time we *don't* want the rail to
1443
+ // track current is after a rail-internal mutation, where _renderRail
1444
+ // has already restored the user's scroll position and yanking back to
1445
+ // current would undo it.
1446
+ this._syncRail(reason !== 'mutation');
1447
+ this._syncNotesBody();
1448
+
1449
+ if (broadcast) {
1450
+ // (1) Parent-window postMessage for speaker-notes renderers.
1451
+ try { window.parent.postMessage({ type: 'miaoda:deck:slide-changed', index: curr, total: this._slides.length, skipped: this._skippedIndices() }, '*'); } catch (e) {}
1452
+
1453
+ // (2) In-page CustomEvent on the <deck-stage> element itself.
1454
+ // Bubbles and composes out of shadow DOM so slide code can listen:
1455
+ // document.querySelector('deck-stage').addEventListener('slidechange', e => {
1456
+ // e.detail.index, e.detail.previousIndex, e.detail.total, e.detail.slide, e.detail.reason
1457
+ // });
1458
+ const detail = {
1459
+ index: curr,
1460
+ previousIndex: prev,
1461
+ total: this._slides.length,
1462
+ slide: this._slides[curr] || null,
1463
+ previousSlide: prev >= 0 ? (this._slides[prev] || null) : null,
1464
+ reason: reason, // 'init' | 'keyboard' | 'click' | 'tap' | 'api'
1465
+ };
1466
+ this.dispatchEvent(new CustomEvent('slidechange', {
1467
+ detail,
1468
+ bubbles: true,
1469
+ composed: true,
1470
+ }));
1471
+ }
1472
+
1473
+ if (this.hasAttribute('data-mob-ui')) this._syncMobStrip();
1474
+
1475
+ this._prevIndex = curr;
1476
+ }
1477
+
1478
+ // 移动端两种呈现互斥:竖排浏览(list)/ 旋转横屏播放(present)。
1479
+ // noscale / _snthumb(导出与演讲者缩略图捕获)必须保持桌面几何。
1480
+ _syncMobileMode() {
1481
+ const eligible = this._mobile && !this.hasAttribute('noscale') && !this._snthumb;
1482
+ this.toggleAttribute('data-mobile-list', eligible && !this._presenting);
1483
+ this.toggleAttribute('data-mobile-present', eligible && !!this._presenting);
1484
+ }
1485
+
1486
+ _fitMobileList() {
1487
+ const dw = this.designWidth, dh = this.designHeight;
1488
+ const vw = window.innerWidth;
1489
+ this._mobLastVw = vw; // _onResize 高度变化跳过的基线
1490
+ const stage = this._canvas.parentElement;
1491
+ // 清掉桌面 _fit 写过的 inset 内联值
1492
+ if (stage) { stage.style.left = '0'; stage.style.right = '0'; stage.style.top = '0'; stage.style.bottom = '0'; }
1493
+ this.removeAttribute('data-windowed');
1494
+ const cardW = vw - 2 * MOB_MARGIN;
1495
+ const s = cardW / dw;
1496
+ const cardH = dh * s;
1497
+ const kids = Array.prototype.slice.call(this.children);
1498
+ const rules = [];
1499
+ const cards = [];
1500
+ let y = MOB_MARGIN;
1501
+ this._slides.forEach((slide, i) => {
1502
+ if (slide.hasAttribute('data-deck-skip')) return;
1503
+ const nth = kids.indexOf(slide) + 1;
1504
+ if (nth <= 0) return;
1505
+ rules.push(
1506
+ ':host([data-mobile-list]) ::slotted(:nth-child(' + nth + ')) {' +
1507
+ ' inset: auto !important; top: 0 !important; left: 0 !important;' +
1508
+ ' width: ' + dw + 'px !important; height: ' + dh + 'px !important;' +
1509
+ ' transform: translate(' + MOB_MARGIN + 'px, ' + y + 'px) scale(' + s + ') !important;' +
1510
+ // 圆角/描边随 scale 反向放大,落地后视觉为 8px / 0.5px
1511
+ ' border-radius: ' + (MOB_RADIUS / s) + 'px; overflow: hidden !important;' +
1512
+ ' border: ' + (0.5 / s) + 'px solid #DEE0E3; }'
1513
+ );
1514
+ cards.push({ slide, i, y, h: cardH });
1515
+ y += cardH + MOB_GAP;
1516
+ });
1517
+ // 竖排规则只作用于屏幕媒体——print 的 ::slotted 分页规则特异性更低,
1518
+ // 否则每页会带着列表位移/缩放进入打印流。
1519
+ this._mobStyle.textContent = '@media screen {\n' + rules.join('\n') + '\n}';
1520
+ this._canvas.style.transform = 'none';
1521
+ this._canvas.style.width = vw + 'px';
1522
+ this._canvas.style.height = Math.max(y - MOB_GAP + MOB_MARGIN, 0) + 'px';
1523
+ this._mobCards = cards;
1524
+ this._mobScale = s;
1525
+
1526
+ // 徽标:每卡片左下角内 6px;宽度按位数 16/16/20。布局是确定性的,
1527
+ // 直接按 _mobCards 几何摆放,无需 IntersectionObserver。
1528
+ this._mobBadges.textContent = '';
1529
+ cards.forEach((c, ordinal) => {
1530
+ const b = document.createElement('div');
1531
+ b.className = 'mob-badge';
1532
+ const n = ordinal + 1;
1533
+ b.textContent = String(n);
1534
+ b.style.width = (String(n).length >= 3 ? 20 : 16) + 'px';
1535
+ b.style.left = (MOB_MARGIN + 6) + 'px';
1536
+ b.style.top = (c.y + c.h - 6 - 12) + 'px';
1537
+ this._mobBadges.appendChild(b);
1538
+ });
1539
+
1540
+ this._mobScrollTo(this._index, false);
1541
+ }
1542
+
1543
+ _onMobScroll() {
1544
+ if (!this.hasAttribute('data-mobile-list') || !this._mobCards || !this._mobCards.length) return;
1545
+
1546
+ // 徽标显隐:程序化滚动也算"滚动",两种来源都给可见性反馈。
1547
+ this.setAttribute('data-mob-scrolling', '');
1548
+ clearTimeout(this._mobScrollIdleTimer);
1549
+ this._mobScrollIdleTimer = setTimeout(
1550
+ () => this.removeAttribute('data-mob-scrolling'), MOB_BADGE_IDLE_MS);
1551
+
1552
+ if (this._mobScrollRaf) return;
1553
+ this._mobScrollRaf = requestAnimationFrame(() => {
1554
+ this._mobScrollRaf = null;
1555
+ const stage = this._canvas.parentElement;
1556
+ const top = stage.scrollTop;
1557
+ const moved = this._mobLastTop == null ? Infinity : Math.abs(top - this._mobLastTop);
1558
+ this._mobLastTop = top; // 抑制期间也持续跟踪位置
1559
+ if (this._mobProgUntil && performance.now() < this._mobProgUntil) {
1560
+ // 程序化滚动事件流仍在到达:续期窗口,动画多长都盖得住;
1561
+ // 事件停止 120ms 后窗口自然过期,之后才恢复用户滚动反推。
1562
+ this._mobProgUntil = Math.max(this._mobProgUntil, performance.now() + 120);
1563
+ return;
1564
+ }
1565
+ if (moved < 1) return; // 无真实位移(合成事件/终点回调)不反推
1566
+ // 纯视口中心公式在卡片高 < 半视口时到不了首/末页(卡片高 ~= 0.5625 * 宽,
1567
+ // 竖屏视口高通常远大于宽的一半),两端改用 clamp,中段维持视口中心语义。
1568
+ const max = stage.scrollHeight - stage.clientHeight;
1569
+ let cur;
1570
+ if (max <= 0 || stage.scrollTop <= 1) {
1571
+ cur = this._mobCards[0];
1572
+ } else if (stage.scrollTop >= max - 1) {
1573
+ cur = this._mobCards[this._mobCards.length - 1];
1574
+ } else {
1575
+ const center = stage.scrollTop + stage.clientHeight / 2;
1576
+ cur = this._mobCards[0];
1577
+ for (const c of this._mobCards) {
1578
+ if (center >= c.y) cur = c; else break;
1579
+ }
1580
+ }
1581
+ if (cur.i !== this._index) {
1582
+ this._index = cur.i;
1583
+ // 广播走现有内核:hash 同步 + slide-changed + slidechange 事件。
1584
+ // data-deck-active 的切换在竖排 CSS 下无视觉副作用。
1585
+ this._applyIndex({ broadcast: true, reason: 'api' });
1586
+ }
1587
+ });
1588
+ }
1589
+
1590
+ _mobScrollTo(i, smooth) {
1591
+ const card = (this._mobCards || []).find((c) => c.i === i);
1592
+ if (!card) return;
1593
+ const stage = this._canvas.parentElement;
1594
+ const top = Math.max(0, card.y - Math.max(0, (stage.clientHeight - card.h) / 2));
1595
+ // 程序化滚动期间抑制 _onMobScroll 的反推——goto/deep-link 已直接设定
1596
+ // _index,多张边缘卡片的居中目标会被 clamp 到同一个 scrollTop,
1597
+ // 反推无法区分它们对应哪一页,故导航直接落 index、反推只处理真实
1598
+ // 用户位移。
1599
+ this._mobProgUntil = performance.now() + (smooth ? 800 : 200);
1600
+ // 目标位置立为基线:即便这次 scrollTo 因目标已等于当前位置(如居中
1601
+ // 目标 clamp 到 0)而不产生真实 scroll 事件,_mobLastTop 也不会停留
1602
+ // 在 null——避免之后任意一次无位移事件被当成"首次滚动"误判有位移。
1603
+ this._mobLastTop = top;
1604
+ stage.scrollTo({ top, behavior: smooth ? 'smooth' : 'auto' });
1605
+ }
1606
+
1607
+ _fitMobilePresent() {
1608
+ const stage = this._canvas.parentElement;
1609
+ if (stage) { stage.style.left = ''; stage.style.right = ''; stage.style.top = ''; stage.style.bottom = ''; }
1610
+ this.removeAttribute('data-windowed');
1611
+ this._canvas.style.width = this.designWidth + 'px';
1612
+ this._canvas.style.height = this.designHeight + 'px';
1613
+ // 物理竖屏:CSS 旋转 90°,stage 逻辑尺寸 = (innerHeight × innerWidth);
1614
+ // 物理横屏(用户顺势转了手机):不旋转,按真实视口常规适配。
1615
+ // CSS 侧由 @media (orientation) 同步分支。
1616
+ const portrait = window.innerHeight >= window.innerWidth;
1617
+ const lw = portrait ? window.innerHeight : window.innerWidth;
1618
+ const lh = portrait ? window.innerWidth : window.innerHeight;
1619
+ const s = Math.min(lw / this.designWidth, lh / this.designHeight);
1620
+ this._canvas.style.transform = 'scale(' + s + ')';
1621
+ }
1622
+
1623
+ _railWidth() {
1624
+ // State-based, no offsetWidth: the first _fit() can run before the
1625
+ // rail has had layout on some load paths, and a 0 there paints the
1626
+ // slide full-width for one frame before the post-slotchange _fit()
1627
+ // corrects it.
1628
+ if (!this._railEnabled || !this._railVisible || this.hasAttribute('no-rail')
1629
+ || this.hasAttribute('noscale') || this._presenting || this._previewMode
1630
+ || NARROW_MQ.matches) return 0;
1631
+ return this._railPx || 0;
1632
+ }
1633
+
1634
+ _fit() {
1635
+ if (!this._canvas) return;
1636
+ if (this.hasAttribute('data-mobile-list')) return this._fitMobileList();
1637
+ if (this.hasAttribute('data-mobile-present')) return this._fitMobilePresent();
1638
+ const stage = this._canvas.parentElement;
1639
+ // PPTX export sets noscale so the DOM capture sees authored-size
1640
+ // geometry — the scaled canvas is in shadow DOM, so the exporter's
1641
+ // resetTransformSelector can't reach .canvas.style.transform directly.
1642
+ if (this.hasAttribute('noscale')) {
1643
+ this._canvas.style.transform = 'none';
1644
+ this.removeAttribute('data-windowed');
1645
+ if (stage) { stage.style.left = '0'; stage.style.right = '0'; stage.style.top = '0'; stage.style.bottom = '0'; }
1646
+ return;
1647
+ }
1648
+ this._canvas.style.width = this.designWidth + 'px';
1649
+ this._canvas.style.height = this.designHeight + 'px';
1650
+ const rw = this._railWidth();
1651
+ const nh = this._notesHeight();
1652
+ // Windowed (20px margin + card shadow) in edit/preview; full-bleed while
1653
+ // presenting or inside presenter-thumbnail iframes (_snthumb).
1654
+ const windowed = !this._presenting && !this._snthumb;
1655
+ const m = windowed ? DECK_MARGIN : 0;
1656
+ this.toggleAttribute('data-windowed', windowed);
1657
+ // Explicit four-side inset shrinks the flex box to the safe region; the
1658
+ // design-size canvas centres in it, then scale() shrinks it around the
1659
+ // centre — so it stays inside the inset with equal margins on all sides.
1660
+ if (stage) {
1661
+ stage.style.left = (rw + m) + 'px';
1662
+ stage.style.right = m + 'px';
1663
+ stage.style.top = m + 'px';
1664
+ stage.style.bottom = (m + nh) + 'px';
1665
+ }
1666
+ // The notes dock spans the non-rail width; its left edge tracks the rail.
1667
+ if (this._notesPanel) this._notesPanel.style.left = (rw + DECK_MARGIN) + 'px';
1668
+ const vw = window.innerWidth - rw - 2 * m;
1669
+ const vh = window.innerHeight - nh - 2 * m;
1670
+ const s = Math.min(vw / this.designWidth, vh / this.designHeight);
1671
+ this._canvas.style.transform = `scale(${s})`;
1672
+ }
1673
+
1674
+ _onResize() {
1675
+ // 竖排列表几何只依赖视口宽度。移动浏览器地址栏收起/展开只改高度,
1676
+ // 若照常全量 refit(重建规则/徽标 + 无条件回中当前页),用户滚动中
1677
+ // 会被可见地"拽"一下——纯高度变化直接跳过。宽度变化(旋转/分屏)
1678
+ // 照常走完整 refit。
1679
+ if (this.hasAttribute('data-mobile-list')) {
1680
+ if (this._mobLastVw === window.innerWidth) return;
1681
+ this._mobLastVw = window.innerWidth;
1682
+ }
1683
+ this._fit();
1684
+ // Crossing the narrow-viewport breakpoint reveals the rail — rerun the
1685
+ // thumbnail scale the same way _setRailWidth does.
1686
+ if (!this._scaleRaf) {
1687
+ this._scaleRaf = requestAnimationFrame(() => {
1688
+ this._scaleRaf = null;
1689
+ this._scaleThumbs();
1690
+ });
1691
+ }
1692
+ }
1693
+
1694
+ _onMessage(e) {
1695
+ const d = e.data;
1696
+ if (d && d.type === 'miaoda:deck:presenting' && typeof d.on === 'boolean') {
1697
+ this._presenting = d.on;
1698
+ this._syncMobileMode();
1699
+ if (!d.on) this._toggleMobUi(false);
1700
+ this._syncRailHidden();
1701
+ this._syncNotesHidden();
1702
+ this._closeMenu();
1703
+ this._fit();
1704
+ this._scaleThumbs();
1705
+ }
1706
+ // Host's Preview segment (ViewerMode='none'): the rail's drag-reorder /
1707
+ // right-click skip-delete affordances are editing chrome, so hide it
1708
+ // while the user is just looking at the deck. Same hard-hide path as
1709
+ // presenting; independent of the user's _railVisible preference so
1710
+ // returning to Edit restores whatever they had.
1711
+ if (d && d.type === 'miaoda:deck:preview-mode' && typeof d.on === 'boolean') {
1712
+ if (d.on === this._previewMode) return;
1713
+ this._previewMode = d.on;
1714
+ this._syncRailHidden();
1715
+ this._syncNotesHidden();
1716
+ this._closeMenu();
1717
+ this._fit();
1718
+ this._scaleThumbs();
1719
+ }
1720
+ // Per-viewer show/hide, driven by the TweaksPanel's auto-injected
1721
+ // "Thumbnail rail" toggle (or any author script). Independent of
1722
+ // whether the Tweaks panel itself is open — closing the panel
1723
+ // doesn't change rail visibility. Persists alongside rail width.
1724
+ if (d && d.type === 'miaoda:deck:rail-visible' && typeof d.on === 'boolean') {
1725
+ if (d.on === this._railVisible) return;
1726
+ this._railVisible = d.on;
1727
+ try { localStorage.setItem('deck-stage.railVisible', d.on ? '1' : '0'); } catch (e) {}
1728
+ // Notify the parent so its toolbar state stays in sync.
1729
+ try { window.parent.postMessage({ type: 'miaoda:deck:rail-visible-changed', on: d.on }, '*'); } catch (e) {}
1730
+ // Arm the transition, commit it, then flip state — otherwise the
1731
+ // browser coalesces both writes and nothing animates on show.
1732
+ this.setAttribute('data-rail-anim', '');
1733
+ void (this._rail && this._rail.offsetHeight);
1734
+ this._syncRailHidden();
1735
+ this._fit();
1736
+ this._scaleThumbs();
1737
+ clearTimeout(this._railAnimTimer);
1738
+ this._railAnimTimer = setTimeout(() => this.removeAttribute('data-rail-anim'), 220);
1739
+ }
1740
+ if (d && d.type === 'miaoda:deck:rail-enabled') this._enableRail();
1741
+ if (d && d.type === 'miaoda:deck:goto' && typeof d.index === 'number') this._go(d.index | 0, 'api');
1742
+ }
1743
+
1744
+ _syncRailHidden() {
1745
+ if (!this._rail) return;
1746
+ // data-presenting is the hard hide (display:none) for flag-off,
1747
+ // presentation mode, and the host's Preview segment — instant, no
1748
+ // transition. data-user-hidden is the soft hide (translateX(-100%))
1749
+ // for the viewer's rail toggle, so show/hide slides under
1750
+ // :host([data-rail-anim]).
1751
+ const hard = !this._railEnabled || this._presenting || this._previewMode;
1752
+ if (hard) this._rail.setAttribute('data-presenting', '');
1753
+ else this._rail.removeAttribute('data-presenting');
1754
+ if (!this._railVisible) this._rail.setAttribute('data-user-hidden', '');
1755
+ else this._rail.removeAttribute('data-user-hidden');
1756
+ // translateX hide leaves thumbs (tabIndex=0) in the tab order —
1757
+ // inert keeps them unfocusable while the rail is off-screen.
1758
+ this._rail.inert = hard || !this._railVisible;
1759
+ }
1760
+
1761
+ _onTap(e) {
1762
+ // Touch-only for normal browse/edit — on desktop there, keyboard + the
1763
+ // overlay toolbar cover nav. But in presenting mode the overlay is hidden
1764
+ // and click-to-advance is the expected gesture (Keynote/PPT), so allow it
1765
+ // on fine pointers too.
1766
+ if (FINE_POINTER_MQ.matches && !this._presenting) return;
1767
+ // Only taps that land on the stage (slide content or letterbox); the
1768
+ // overlay / rail / menus are siblings with their own click handlers.
1769
+ const path = e.composedPath();
1770
+ if (!this._stage || !path.includes(this._stage)) return;
1771
+ // Let interactive slide content keep the tap. composedPath (not
1772
+ // e.target.closest) so we see through open shadow roots — a <button>
1773
+ // inside a slide-authored custom element retargets e.target to the
1774
+ // host but still appears in the composed path.
1775
+ if (e.defaultPrevented) return;
1776
+ for (const n of path) {
1777
+ if (n === this._stage) break;
1778
+ if (n.matches && n.matches(INTERACTIVE_SEL)) return;
1779
+ }
1780
+ // 竖排浏览是滚动列表,tap 不翻页(spec 决策 #5)
1781
+ if (this.hasAttribute('data-mobile-list')) return;
1782
+ e.preventDefault();
1783
+ if (this.hasAttribute('data-mobile-present')) {
1784
+ const zone = this._mobPresentZone(e);
1785
+ if (zone === 'menu') this._toggleMobUi();
1786
+ else {
1787
+ this._advance(zone === 'prev' ? -1 : 1, 'tap');
1788
+ if (this.hasAttribute('data-mob-ui')) this._toggleMobUi(true);
1789
+ }
1790
+ return;
1791
+ }
1792
+ const rw = this._railWidth();
1793
+ const mid = rw + (window.innerWidth - rw) / 2;
1794
+ this._advance(e.clientX < mid ? -1 : 1, 'tap');
1795
+ }
1796
+
1797
+ // rotate(90deg) 后视觉横屏的 x 轴对应视口 y 轴。方向以真机/仿真实测为准:
1798
+ // 若实测发现 prev/next 颠倒,翻转此处的比较符(保持单点修改)。
1799
+ _mobPresentZone(e) {
1800
+ const rect = this._stage.getBoundingClientRect();
1801
+ // 物理竖屏 = stage 被 CSS 旋转 90°,视觉横屏 x 轴对应视口 y 轴;
1802
+ // 物理横屏 = 无旋转,直接用视口 x 轴。与 _fitMobilePresent 同分支。
1803
+ const portrait = window.innerHeight >= window.innerWidth;
1804
+ const x = portrait
1805
+ ? (e.clientY - rect.top) / Math.max(1, rect.height)
1806
+ : (e.clientX - rect.left) / Math.max(1, rect.width);
1807
+ return x < 1 / 3 ? 'prev' : x > 2 / 3 ? 'next' : 'menu';
1808
+ }
1809
+
1810
+ _toggleMobUi(force) {
1811
+ const wasOpen = this.hasAttribute('data-mob-ui');
1812
+ const on = force != null ? !!force : !wasOpen;
1813
+ this.toggleAttribute('data-mob-ui', on);
1814
+ clearTimeout(this._mobUiTimer);
1815
+ if (!on) return;
1816
+ // 从隐藏态唤出才全量重建(吸收直编/重排/skip 变化——strip 是静态
1817
+ // 快照、不接 _liveObserver 刷新管线);已展开时的调用(缩略图点击/
1818
+ // 翻页重置计时)只同步高亮,避免高频路径 O(页数) 重建。
1819
+ if (!wasOpen || !this._mobUi) {
1820
+ if (this._mobUi) { this._mobUi.remove(); this._mobUi = null; }
1821
+ this._buildMobUi();
1822
+ }
1823
+ this._syncMobStrip();
1824
+ this._mobUiTimer = setTimeout(() => this._toggleMobUi(false), MOB_UI_HIDE_MS);
1825
+ }
1826
+
1827
+ _buildMobUi() {
1828
+ // 移动 viewer 语境 host 可能从不发 rail-enabled,author-CSS 快照
1829
+ // (_enableRail 才建)不存在时缩略图克隆会裸渲染——此处兜底建一次。
1830
+ // _snapshotAuthorCss 可重入且带 generation guard。
1831
+ if (!this._adoptedSheet && this._authorCss == null) this._snapshotAuthorCss();
1832
+ const ui = document.createElement('div');
1833
+ ui.className = 'mob-ui export-hidden';
1834
+ ui.setAttribute('data-miaoda-chrome', '');
1835
+ const back = document.createElement('button');
1836
+ back.className = 'mob-back';
1837
+ back.type = 'button';
1838
+ back.textContent = '‹ 演示模式';
1839
+ back.addEventListener('click', (e) => {
1840
+ e.stopPropagation();
1841
+ this._presenting = false;
1842
+ this._syncMobileMode();
1843
+ this._syncRailHidden();
1844
+ this._syncNotesHidden();
1845
+ this._fit();
1846
+ this._toggleMobUi(false);
1847
+ try { window.parent.postMessage({ type: 'miaoda:deck:presenting-dismissed' }, '*'); } catch (err) {}
1848
+ });
1849
+ const strip = document.createElement('div');
1850
+ strip.className = 'mob-strip';
1851
+ this._mobStripEntries = this._slides
1852
+ .map((slide, i) => ({ slide, i }))
1853
+ .filter((e2) => !e2.slide.hasAttribute('data-deck-skip'))
1854
+ .map((e2, ordinal) => {
1855
+ const t = document.createElement('div');
1856
+ t.className = 'mthumb';
1857
+ const frame = document.createElement('div');
1858
+ frame.className = 'mframe';
1859
+ const num = document.createElement('div');
1860
+ num.className = 'mnum';
1861
+ num.textContent = String(ordinal + 1);
1862
+ t.append(frame, num);
1863
+ t.addEventListener('click', (ev) => {
1864
+ ev.stopPropagation();
1865
+ this._go(e2.i, 'click');
1866
+ this._toggleMobUi(true); // 重置自动收起计时,保持控件层可见
1867
+ });
1868
+ strip.appendChild(t);
1869
+ // 复用 _materialize 的克隆管线(media 熄火、canvas 快照、custom
1870
+ // element 中性化):它会自建 host/shadow 并直接挂进 entry.frame,
1871
+ // 所以这里不需要再手工搭 host——只需在其后把统一走 _thumbScale
1872
+ // (rail 宽度)算出的缩放,改写成本条带 132px 专属的缩放。
1873
+ const entry = { slide: e2.slide, frame, host: null, clone: null };
1874
+ this._materialize(entry);
1875
+ if (entry.clone) entry.clone.style.transform = 'scale(' + (132 / this.designWidth) + ')';
1876
+ return { el: t, i: e2.i };
1877
+ });
1878
+ // 控件层内任何点击不冒泡成翻页 tap
1879
+ ui.addEventListener('click', (e) => e.stopPropagation());
1880
+ ui.append(back, strip);
1881
+ this._stage.appendChild(ui);
1882
+ this._mobUi = ui;
1883
+ }
1884
+
1885
+ _syncMobStrip() {
1886
+ (this._mobStripEntries || []).forEach((en) => {
1887
+ en.el.toggleAttribute('data-current', en.i === this._index);
1888
+ if (en.i === this._index) en.el.scrollIntoView({ block: 'nearest', inline: 'center' });
1889
+ });
1890
+ }
1891
+
1892
+ _onKey(e) {
1893
+ // Ignore when the user is typing.
1894
+ const t = e.target;
1895
+ if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
1896
+ if (e.key === 'Escape' && this._menu && this._menu.hasAttribute('data-open')) {
1897
+ this._closeMenu();
1898
+ e.preventDefault();
1899
+ return;
1900
+ }
1901
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
1902
+
1903
+ const key = e.key;
1904
+ let handled = true;
1905
+
1906
+ if (key === 'ArrowRight' || key === 'ArrowDown' || key === 'PageDown' || key === ' ' || key === 'Spacebar') {
1907
+ this._advance(1, 'keyboard');
1908
+ } else if (key === 'ArrowLeft' || key === 'ArrowUp' || key === 'PageUp') {
1909
+ this._advance(-1, 'keyboard');
1910
+ } else if (key === 'Home') {
1911
+ this._go(0, 'keyboard');
1912
+ } else if (key === 'End') {
1913
+ this._go(this._slides.length - 1, 'keyboard');
1914
+ } else if (key === 'r' || key === 'R') {
1915
+ this._go(0, 'keyboard');
1916
+ } else if (/^[0-9]$/.test(key)) {
1917
+ // 1..9 jump to that slide; 0 jumps to 10.
1918
+ const n = key === '0' ? 9 : parseInt(key, 10) - 1;
1919
+ if (n < this._slides.length) this._go(n, 'keyboard');
1920
+ } else {
1921
+ handled = false;
1922
+ }
1923
+
1924
+ if (handled) {
1925
+ e.preventDefault();
1926
+ }
1927
+ }
1928
+
1929
+ _go(i, reason = 'api') {
1930
+ if (!this._slides.length) return;
1931
+ const clamped = Math.max(0, Math.min(this._slides.length - 1, i));
1932
+ // 竖排浏览:导航直接落 index(与桌面语义一致),滚动只是视觉跟随;
1933
+ // 不再依赖 _onMobScroll 从滚动位置反推——多张边缘卡片的居中滚动目标
1934
+ // 会被 clamp 到同一个 scrollTop,反推无法区分它们是哪一页。
1935
+ if (this.hasAttribute('data-mobile-list')) {
1936
+ const changed = clamped !== this._index;
1937
+ this._index = clamped;
1938
+ // 同页 goto 不重复广播(与桌面分支语义一致),但仍需重新居中滚动。
1939
+ if (changed) this._applyIndex({ broadcast: true, reason });
1940
+ this._mobScrollTo(clamped, true);
1941
+ return;
1942
+ }
1943
+ if (clamped === this._index) return;
1944
+ this._index = clamped;
1945
+ this._applyIndex({ broadcast: true, reason });
1946
+ }
1947
+
1948
+ /** Step forward/back skipping any slide marked data-deck-skip. Clamps at
1949
+ * the ends (no-op past the first/last non-skipped slide). */
1950
+ _advance(dir, reason) {
1951
+ if (!this._slides.length) return;
1952
+ let i = this._index + dir;
1953
+ while (i >= 0 && i < this._slides.length && this._slides[i].hasAttribute('data-deck-skip')) {
1954
+ i += dir;
1955
+ }
1956
+ if (i < 0 || i >= this._slides.length) return;
1957
+ this._go(i, reason);
1958
+ }
1959
+
1960
+ // ── Thumbnail rail ────────────────────────────────────────────────────
1961
+ //
1962
+ // Thumbs are keyed by slide element and reused across _renderRail()
1963
+ // calls, so a reorder/delete is an O(changed) DOM shuffle instead of an
1964
+ // O(N) teardown-and-re-clone. Each thumb starts as a lightweight shell
1965
+ // (num + empty frame); the clone is materialized lazily by an
1966
+ // IntersectionObserver when the frame scrolls into (or near) view, so
1967
+ // only visible-ish slides pay the clone + image-decode cost.
1968
+
1969
+ _renderRail() {
1970
+ if (!this._rail || !this._railEnabled) { this._thumbs = []; return; }
1971
+ // FLIP: record each *materialized* thumb's top before the reconcile.
1972
+ // Off-screen (non-materialized) thumbs don't need the animation and
1973
+ // skipping their getBoundingClientRect saves a forced layout per
1974
+ // off-screen thumb on large decks.
1975
+ const prevTops = new Map();
1976
+ (this._thumbs || []).forEach(({ thumb, slide, host }) => {
1977
+ if (host) prevTops.set(slide, thumb.getBoundingClientRect().top);
1978
+ });
1979
+ const st = this._rail.scrollTop;
1980
+
1981
+ // Reconcile: reuse thumbs that already exist for a slide, create
1982
+ // shells for new slides, drop thumbs for removed slides.
1983
+ const bySlide = new Map();
1984
+ (this._thumbs || []).forEach((t) => bySlide.set(t.slide, t));
1985
+ const next = [];
1986
+ this._slides.forEach((slide) => {
1987
+ let t = bySlide.get(slide);
1988
+ if (t) bySlide.delete(slide);
1989
+ else t = this._makeThumb(slide);
1990
+ next.push(t);
1991
+ });
1992
+ // Orphans — slides removed since last render.
1993
+ bySlide.forEach((t) => {
1994
+ if (this._railObserver) this._railObserver.unobserve(t.frame);
1995
+ t.thumb.remove();
1996
+ });
1997
+ // Put thumbs into document order to match _slides. insertBefore on
1998
+ // an already-correctly-placed node is a no-op, so this is cheap
1999
+ // when nothing moved.
2000
+ next.forEach((t, i) => {
2001
+ const want = t.thumb;
2002
+ const at = this._rail.children[i];
2003
+ if (at !== want) this._rail.insertBefore(want, at || null);
2004
+ t.i = i;
2005
+ t.num.textContent = String(i + 1);
2006
+ if (t.slide.hasAttribute('data-deck-skip')) t.thumb.setAttribute('data-skip', '');
2007
+ else t.thumb.removeAttribute('data-skip');
2008
+ });
2009
+ this._thumbs = next;
2010
+
2011
+ this._rail.scrollTop = st;
2012
+ if (prevTops.size) {
2013
+ const moved = [];
2014
+ this._thumbs.forEach(({ thumb, slide }) => {
2015
+ const old = prevTops.get(slide);
2016
+ if (old == null) return;
2017
+ const dy = old - thumb.getBoundingClientRect().top;
2018
+ if (Math.abs(dy) < 1) return;
2019
+ thumb.style.transition = 'none';
2020
+ thumb.style.transform = `translateY(${dy}px)`;
2021
+ moved.push(thumb);
2022
+ });
2023
+ if (moved.length) {
2024
+ // Commit the inverted positions before flipping the transition
2025
+ // on — otherwise the browser coalesces both style writes and
2026
+ // nothing animates.
2027
+ void this._rail.offsetHeight;
2028
+ moved.forEach((t) => {
2029
+ t.style.transition = 'transform 180ms cubic-bezier(.2,.7,.3,1)';
2030
+ t.style.transform = '';
2031
+ });
2032
+ setTimeout(() => moved.forEach((t) => { t.style.transition = ''; }), 220);
2033
+ }
2034
+ }
2035
+ requestAnimationFrame(() => this._scaleThumbs());
2036
+ this._syncRail(false);
2037
+ }
2038
+
2039
+ /** Create a lightweight thumb shell for one slide. The clone is
2040
+ * materialized later by the IntersectionObserver. Event handlers
2041
+ * look up the thumb's *current* index (via _thumbs.indexOf) so the
2042
+ * same element can be reused across reorders. */
2043
+ _makeThumb(slide) {
2044
+ const thumb = document.createElement('div');
2045
+ thumb.className = 'thumb';
2046
+ thumb.tabIndex = 0;
2047
+ const num = document.createElement('div');
2048
+ num.className = 'num';
2049
+ const frame = document.createElement('div');
2050
+ frame.className = 'frame';
2051
+ const skipIcon = document.createElement('div');
2052
+ skipIcon.className = 'skip-icon';
2053
+ skipIcon.innerHTML = '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1.8447 1.82978C2.16723 1.50736 2.68943 1.51112 3.0119 1.8336L18.3385 17.1606C18.6638 17.4859 18.6637 18.0136 18.3384 18.3389C18.0131 18.6641 17.4855 18.6643 17.1602 18.339L1.83317 3.01234C1.51069 2.68986 1.50692 2.16767 1.82935 1.84514C1.83447 1.84002 1.83958 1.8349 1.8447 1.82978ZM3.33085 6.27753L6.68668 9.63337C6.63272 10.1199 6.68658 10.6123 6.84443 11.0757C7.00228 11.5391 7.26025 11.962 7.60001 12.3144C7.93976 12.6668 8.35296 12.94 8.81024 13.1147C9.26753 13.2894 9.75767 13.3612 10.2458 13.325L10.3667 13.3134L13.3529 16.2996C12.2833 16.8225 11.1604 17.0838 9.98626 17.0838C6.65746 17.0838 3.75395 14.9833 1.27539 10.7819C0.991215 10.3003 0.987158 9.70269 1.26629 9.21806C1.91897 8.08486 2.60738 7.10484 3.33085 6.27753ZM13.3333 10.0005C13.3334 9.54197 13.2389 9.08841 13.0556 8.66813C12.8724 8.24784 12.6045 7.86987 12.2686 7.55784C11.9327 7.24581 11.536 7.00644 11.1033 6.85468C10.6707 6.70293 10.2114 6.64205 9.75418 6.67587L9.63293 6.68712L6.6246 3.67878C7.67308 3.17422 8.82227 2.91381 9.98585 2.91712C13.3783 2.91712 16.2919 5.01573 18.7276 9.21295C19.0101 9.6999 19.0068 10.3018 18.7193 10.7858C18.0552 11.9035 17.3618 12.8724 16.6388 13.693L13.3133 10.3675C13.3267 10.2467 13.3333 10.1246 13.3333 10.0005Z" fill="#646A73"/></svg>';
2054
+ frame.append(skipIcon);
2055
+ thumb.append(num, frame);
2056
+
2057
+ const entry = { thumb, num, frame, slide, clone: null, host: null, i: -1 };
2058
+ // entry.i is refreshed on every _renderRail reconcile pass, so
2059
+ // handlers read the thumb's current position without an O(N) scan.
2060
+ const idx = () => entry.i;
2061
+
2062
+ thumb.addEventListener('click', () => this._go(idx(), 'click'));
2063
+ // ↑/↓ step through the rail when a thumb has focus. _go clamps at the
2064
+ // ends and _applyIndex→_syncRail scrolls the new current thumb into
2065
+ // view; we move focus to it (preventScroll — _syncRail already
2066
+ // scrolled) so a held key walks the whole list. stopPropagation keeps
2067
+ // this out of the window-level _onKey nav handler.
2068
+ thumb.addEventListener('keydown', (e) => {
2069
+ if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
2070
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
2071
+ e.preventDefault();
2072
+ e.stopPropagation();
2073
+ this._go(idx() + (e.key === 'ArrowDown' ? 1 : -1), 'keyboard');
2074
+ const cur = this._thumbs && this._thumbs[this._index];
2075
+ if (cur) cur.thumb.focus({ preventScroll: true });
2076
+ });
2077
+ thumb.addEventListener('contextmenu', (e) => {
2078
+ e.preventDefault();
2079
+ this._openMenu(idx(), e.clientX, e.clientY);
2080
+ });
2081
+ thumb.draggable = true;
2082
+ thumb.addEventListener('dragstart', (e) => {
2083
+ this._dragFrom = idx();
2084
+ thumb.setAttribute('data-dragging', '');
2085
+ e.dataTransfer.effectAllowed = 'move';
2086
+ try { e.dataTransfer.setData('text/plain', String(this._dragFrom)); } catch (err) {}
2087
+ // Hand the browser a clean elevated card instead of its raw .thumb
2088
+ // bitmap; anchor it under the cursor at the grab point (no jump).
2089
+ try {
2090
+ const g = this._makeDragGhost(entry);
2091
+ if (g) {
2092
+ const r = frame.getBoundingClientRect();
2093
+ const gx = g.pad + Math.max(0, Math.min(g.w, e.clientX - r.left));
2094
+ const gy = g.pad + Math.max(0, Math.min(g.h, e.clientY - r.top));
2095
+ e.dataTransfer.setDragImage(g.el, gx, gy);
2096
+ }
2097
+ } catch (err) {}
2098
+ });
2099
+ thumb.addEventListener('dragend', () => {
2100
+ thumb.removeAttribute('data-dragging');
2101
+ this._clearDrop();
2102
+ this._dragFrom = null;
2103
+ if (this._dragGhost) { this._dragGhost.remove(); this._dragGhost = null; }
2104
+ });
2105
+ thumb.addEventListener('dragover', (e) => {
2106
+ if (this._dragFrom == null) return;
2107
+ e.preventDefault();
2108
+ e.dataTransfer.dropEffect = 'move';
2109
+ const r = thumb.getBoundingClientRect();
2110
+ this._setDrop(idx(), e.clientY < r.top + r.height / 2 ? 'before' : 'after');
2111
+ });
2112
+ thumb.addEventListener('drop', (e) => {
2113
+ if (this._dragFrom == null) return;
2114
+ e.preventDefault();
2115
+ const i = idx();
2116
+ const r = thumb.getBoundingClientRect();
2117
+ let to = e.clientY >= r.top + r.height / 2 ? i + 1 : i;
2118
+ if (this._dragFrom < to) to--;
2119
+ const from = this._dragFrom;
2120
+ this._clearDrop();
2121
+ this._dragFrom = null;
2122
+ if (to !== from) this._moveSlide(from, to);
2123
+ });
2124
+
2125
+ if (this._railObserver) this._railObserver.observe(frame);
2126
+ frame.__deckThumb = entry;
2127
+ return entry;
2128
+ }
2129
+
2130
+ /** Build the floating drag-image for a rail thumb.
2131
+ * The browser's default drag-image is a raw bitmap of the whole .thumb:
2132
+ * it includes the number gutter and clips the current-slide's dark ring
2133
+ * (which sits 2px outside the frame via outline-offset) at the bitmap
2134
+ * edge. Instead we hand setDragImage a purpose-built card — just the
2135
+ * slide frame, rounded, with a soft drop-shadow, transparent around it.
2136
+ * The card lives inside a transparent padded wrapper because the bitmap
2137
+ * crops to the element's border-box, so the shadow needs room inside the
2138
+ * box or it clips too. Returns { el, pad, w, h } for the anchor math, or
2139
+ * null if the frame has no size yet. */
2140
+ _makeDragGhost(entry) {
2141
+ if (this._dragGhost) { this._dragGhost.remove(); this._dragGhost = null; }
2142
+ this._materialize(entry);
2143
+ const w = entry.frame.offsetWidth, h = entry.frame.offsetHeight;
2144
+ if (!w || !h) return null;
2145
+ const pad = 36; // ≥ the shadow's blur + offset so it stays in the bitmap
2146
+ const wrap = document.createElement('div');
2147
+ wrap.style.cssText =
2148
+ 'position:fixed;top:0;left:-100000px;pointer-events:none;' +
2149
+ 'background:transparent;box-sizing:content-box;padding:' + pad + 'px;';
2150
+ const card = document.createElement('div');
2151
+ // Drop-shadow only — no hairline ring (the 0 0 0 1px shadow reads as a
2152
+ // border, which we don't want on the floating card).
2153
+ card.style.cssText =
2154
+ 'position:relative;width:' + w + 'px;height:' + h + 'px;background:#fff;' +
2155
+ 'border-radius:6px;overflow:hidden;' +
2156
+ 'box-shadow:0 6px 10px 0 rgba(31, 35, 41, 0.20);cursor:grabbing;';
2157
+ const host = document.createElement('div');
2158
+ host.style.cssText = 'position:absolute;inset:0;';
2159
+ this._syncThumbHostAttrs(host);
2160
+ const sr = host.attachShadow({ mode: 'open' });
2161
+ if (this._adoptedSheet) sr.adoptedStyleSheets = [this._adoptedSheet];
2162
+ else {
2163
+ const st = document.createElement('style');
2164
+ st.textContent = this._authorCss || '';
2165
+ sr.appendChild(st);
2166
+ }
2167
+ if (entry.clone) sr.appendChild(entry.clone.cloneNode(true));
2168
+ card.appendChild(host);
2169
+ wrap.appendChild(card);
2170
+ (this.shadowRoot || document.body).appendChild(wrap);
2171
+ this._dragGhost = wrap;
2172
+ return { el: wrap, pad, w, h };
2173
+ }
2174
+
2175
+ /** Lazily build the clone for a thumb that has scrolled into view. */
2176
+ _materialize(entry) {
2177
+ if (entry.host) return;
2178
+ const dw = this.designWidth, dh = this.designHeight;
2179
+ let clone = entry.slide.cloneNode(true);
2180
+ clone.removeAttribute('id');
2181
+ clone.removeAttribute('data-deck-active');
2182
+ clone.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
2183
+ // Neuter heavy media; replace <video> with its poster so the box
2184
+ // keeps a visual. <iframe>/<audio> become empty placeholders.
2185
+ clone.querySelectorAll('iframe, audio, object, embed').forEach((el) => {
2186
+ el.removeAttribute('src');
2187
+ el.removeAttribute('srcdoc');
2188
+ el.removeAttribute('data');
2189
+ el.innerHTML = '';
2190
+ });
2191
+ clone.querySelectorAll('video').forEach((el) => {
2192
+ if (!el.poster) { el.removeAttribute('src'); el.innerHTML = ''; return; }
2193
+ const img = document.createElement('img');
2194
+ img.src = el.poster;
2195
+ img.alt = '';
2196
+ img.style.cssText = el.style.cssText + ';object-fit:cover;width:100%;height:100%;';
2197
+ img.className = el.className;
2198
+ el.replaceWith(img);
2199
+ });
2200
+ // Canvas: cloneNode does not copy drawn pixels — snapshot each
2201
+ // original canvas into an <img> so ECharts / Chart.js / custom
2202
+ // drawings appear in the thumbnail instead of a blank rectangle.
2203
+ const origCanvases = entry.slide.querySelectorAll('canvas');
2204
+ const cloneCanvases = clone.querySelectorAll('canvas');
2205
+ cloneCanvases.forEach((el, ci) => {
2206
+ const orig = origCanvases[ci];
2207
+ if (!orig || !orig.width || !orig.height) return;
2208
+ try {
2209
+ const img = document.createElement('img');
2210
+ img.src = orig.toDataURL();
2211
+ img.alt = '';
2212
+ img.style.cssText = (el.getAttribute('style') || '') +
2213
+ ';width:' + orig.offsetWidth + 'px;height:' + orig.offsetHeight + 'px;';
2214
+ img.className = el.className;
2215
+ el.replaceWith(img);
2216
+ } catch (e) {}
2217
+ });
2218
+ // Images: defer decode and let the browser pick the smallest
2219
+ // srcset candidate for the ~140px thumb. Same-URL clones reuse the
2220
+ // slide's decoded bitmap (URL-keyed cache), so the remaining cost
2221
+ // is paint/composite — lazy+async keeps that off the main thread.
2222
+ clone.querySelectorAll('img').forEach((el) => {
2223
+ el.loading = 'lazy';
2224
+ el.decoding = 'async';
2225
+ if (el.srcset) el.sizes = (this._railPx || 192) + 'px';
2226
+ });
2227
+ // Custom elements inside the slide would have their
2228
+ // connectedCallback fire when the clone is appended. Replace them
2229
+ // with inert boxes so a component-heavy deck doesn't run N copies
2230
+ // of each component's mount logic in the rail. Children are
2231
+ // preserved so layout-wrapper elements (<my-column><h2>…</h2>)
2232
+ // still show their authored content; the querySelectorAll NodeList
2233
+ // is static, so nested custom elements in the moved subtree are
2234
+ // still visited on later iterations.
2235
+ const neuter = (el) => {
2236
+ const box = document.createElement('div');
2237
+ box.style.cssText = (el.getAttribute('style') || '') +
2238
+ ';background:rgba(0,0,0,0.06);border:1px dashed rgba(0,0,0,0.15);';
2239
+ box.className = el.className;
2240
+ // Preserve theming/i18n hooks so [data-*] / :lang() / [dir]
2241
+ // descendant selectors still match the neutered root.
2242
+ for (const a of el.attributes) {
2243
+ const n = a.name;
2244
+ if (n.startsWith('data-') || n.startsWith('aria-') ||
2245
+ n === 'lang' || n === 'dir' || n === 'role' || n === 'title') {
2246
+ box.setAttribute(n, a.value);
2247
+ }
2248
+ }
2249
+ while (el.firstChild) box.appendChild(el.firstChild);
2250
+ return box;
2251
+ };
2252
+ // querySelectorAll('*') returns descendants only — a custom-element
2253
+ // slide root (<my-slide>…</my-slide>) would slip through and upgrade
2254
+ // on append. Swap the root first.
2255
+ if (clone.tagName.includes('-')) clone = neuter(clone);
2256
+ clone.querySelectorAll('*').forEach((el) => {
2257
+ if (el.tagName.includes('-')) el.replaceWith(neuter(el));
2258
+ });
2259
+ clone.style.cssText += ';position:absolute;top:0;left:0;transform-origin:0 0;' +
2260
+ 'pointer-events:none;width:' + dw + 'px;height:' + dh + 'px;' +
2261
+ 'box-sizing:border-box;overflow:hidden;visibility:visible;opacity:1;';
2262
+ const host = document.createElement('div');
2263
+ host.style.cssText = 'position:absolute;inset:0;';
2264
+ this._syncThumbHostAttrs(host);
2265
+ const sr = host.attachShadow({ mode: 'open' });
2266
+ if (this._adoptedSheet) sr.adoptedStyleSheets = [this._adoptedSheet];
2267
+ else {
2268
+ const st = document.createElement('style');
2269
+ st.textContent = this._authorCss || '';
2270
+ sr.appendChild(st);
2271
+ }
2272
+ sr.appendChild(clone);
2273
+ entry.frame.appendChild(host);
2274
+ entry.host = host;
2275
+ entry.clone = clone;
2276
+ if (this._thumbScale) clone.style.transform = 'scale(' + this._thumbScale + ')';
2277
+ // Once materialized the IO callback is a no-op early-return —
2278
+ // unobserve so scroll doesn't keep firing it.
2279
+ if (this._railObserver) this._railObserver.unobserve(entry.frame);
2280
+ }
2281
+
2282
+ /** Re-clone a single thumb (live-update path). No-op if the thumb
2283
+ * hasn't been materialized yet — it'll pick up current content when
2284
+ * it scrolls into view. */
2285
+ _refreshThumb(slide) {
2286
+ const entry = (this._thumbs || []).find((t) => t.slide === slide);
2287
+ if (!entry || !entry.host) return;
2288
+ entry.host.remove();
2289
+ entry.host = entry.clone = null;
2290
+ this._materialize(entry);
2291
+ }
2292
+
2293
+ _scaleThumbs() {
2294
+ if (!this._thumbs || !this._thumbs.length) return;
2295
+ // Every frame is the same width; if it reads 0 the rail is
2296
+ // display:none (noscale / no-rail / presenting / print) — leave the
2297
+ // clones as-is and re-run when the rail is revealed.
2298
+ const fw = this._thumbs[0].frame.offsetWidth;
2299
+ if (!fw) return;
2300
+ this._thumbScale = fw / this.designWidth;
2301
+ this._thumbs.forEach(({ clone }) => {
2302
+ if (clone) clone.style.transform = 'scale(' + this._thumbScale + ')';
2303
+ });
2304
+ }
2305
+
2306
+ /** Nearest reorder slot for a Y coordinate anywhere in the rail — used when
2307
+ * the pointer is over a gap, where no thumb dragover fires. Returns
2308
+ * { i, pos } matching _setDrop's contract, or null. Mirrors the thumb
2309
+ * handler's midpoint test so the gap indicator lines up with the thumbs. */
2310
+ _railDropTarget(clientY) {
2311
+ const ts = this._thumbs;
2312
+ if (!ts || !ts.length) return null;
2313
+ for (let k = 0; k < ts.length; k++) {
2314
+ const r = ts[k].thumb.getBoundingClientRect();
2315
+ if (clientY < r.top + r.height / 2) return { i: ts[k].i, pos: 'before' };
2316
+ const next = ts[k + 1];
2317
+ if (!next) return { i: ts[k].i, pos: 'after' };
2318
+ const nr = next.thumb.getBoundingClientRect();
2319
+ if (clientY < nr.top + nr.height / 2) return { i: ts[k].i, pos: 'after' };
2320
+ }
2321
+ const last = ts[ts.length - 1];
2322
+ return { i: last.i, pos: 'after' };
2323
+ }
2324
+
2325
+ _setDrop(i, where) {
2326
+ // dragover fires at pointer-event rate; touch only the previous
2327
+ // and new target rather than sweeping all N thumbs.
2328
+ const t = this._thumbs && this._thumbs[i];
2329
+ if (this._dropOn && this._dropOn !== t) {
2330
+ this._dropOn.thumb.removeAttribute('data-drop');
2331
+ }
2332
+ if (t) t.thumb.setAttribute('data-drop', where);
2333
+ this._dropOn = t || null;
2334
+ // Remembered so a drop that lands in a gap (rail handler) can commit to
2335
+ // the slot the indicator is showing without re-deriving before/after.
2336
+ this._dropWhere = t ? where : null;
2337
+ }
2338
+
2339
+ _clearDrop() {
2340
+ if (this._dropOn) this._dropOn.thumb.removeAttribute('data-drop');
2341
+ this._dropOn = null;
2342
+ this._dropWhere = null;
2343
+ }
2344
+
2345
+ _syncRail(follow) {
2346
+ if (!this._thumbs) return;
2347
+ this._thumbs.forEach(({ thumb }, i) => {
2348
+ if (i === this._index) {
2349
+ thumb.setAttribute('data-current', '');
2350
+ if (follow && typeof thumb.scrollIntoView === 'function') {
2351
+ thumb.scrollIntoView({ block: 'nearest' });
2352
+ }
2353
+ } else {
2354
+ thumb.removeAttribute('data-current');
2355
+ }
2356
+ });
2357
+ }
2358
+
2359
+ _openMenu(i, x, y) {
2360
+ if (!this._menu) return;
2361
+ this._menuIndex = i;
2362
+ const slide = this._slides[i];
2363
+ const skip = slide && slide.hasAttribute('data-deck-skip');
2364
+ this._menu.querySelector('[data-act="skip"]').textContent = skip ? '取消隐藏' : '隐藏此页';
2365
+ this._menu.querySelector('[data-act="up"]').disabled = i <= 0;
2366
+ this._menu.querySelector('[data-act="down"]').disabled = i >= this._slides.length - 1;
2367
+ this._menu.querySelector('[data-act="delete"]').disabled = this._slides.length <= 1;
2368
+ // Place, then clamp to viewport after it's measurable.
2369
+ this._menu.style.left = x + 'px';
2370
+ this._menu.style.top = y + 'px';
2371
+ this._menu.setAttribute('data-open', '');
2372
+ const r = this._menu.getBoundingClientRect();
2373
+ const nx = Math.min(x, window.innerWidth - r.width - 4);
2374
+ const ny = Math.min(y, window.innerHeight - r.height - 4);
2375
+ this._menu.style.left = Math.max(4, nx) + 'px';
2376
+ this._menu.style.top = Math.max(4, ny) + 'px';
2377
+ }
2378
+
2379
+ _closeMenu() {
2380
+ if (this._menu) this._menu.removeAttribute('data-open');
2381
+ this._menuIndex = -1;
2382
+ }
2383
+
2384
+ _emitDeckChange(detail) {
2385
+ // A structural edit carries the (possibly reindexed) notes array so the
2386
+ // host writes deck-stage + speaker-notes in ONE file write. Cancel any
2387
+ // pending text-edit notes-changed so the two never race two writes at
2388
+ // the same file (etag conflict would drop the second).
2389
+ clearTimeout(this._notesPersistTimer);
2390
+ this.dispatchEvent(new CustomEvent('deckchange', {
2391
+ detail, bubbles: true, composed: true,
2392
+ }));
2393
+ // Forward to parent so the host can persist mutations (the CustomEvent
2394
+ // cannot cross iframe boundaries). detail.slide is an Element and can't
2395
+ // survive structured-clone, so only the scalars cross.
2396
+ try {
2397
+ window.parent.postMessage({
2398
+ type: 'miaoda:deck:deck-changed',
2399
+ action: detail.action, from: detail.from, to: detail.to,
2400
+ html: this.outerHTML,
2401
+ notes: this._notesForEmit(),
2402
+ }, '*');
2403
+ } catch (e) {}
2404
+ }
2405
+
2406
+ _deleteSlide(i) {
2407
+ const slide = this._slides[i];
2408
+ if (!slide || this._slides.length <= 1) return;
2409
+ const wasCurrent = i === this._index;
2410
+ if (i < this._index || (wasCurrent && i === this._slides.length - 1)) this._index--;
2411
+ this._squelchSlotChange = true;
2412
+ slide.remove();
2413
+ // Keep the per-slide-indexed notes array aligned with the deck.
2414
+ if (Array.isArray(this._notes)) this._notes.splice(i, 1);
2415
+ this._emitDeckChange({ action: 'delete', from: i, slide });
2416
+ this._collectSlides();
2417
+ this._applyIndex({ broadcast: true, reason: 'mutation' });
2418
+ }
2419
+
2420
+ _toggleSkip(i) {
2421
+ const slide = this._slides[i];
2422
+ if (!slide) return;
2423
+ const on = !slide.hasAttribute('data-deck-skip');
2424
+ if (on) slide.setAttribute('data-deck-skip', '');
2425
+ else slide.removeAttribute('data-deck-skip');
2426
+ if (this._thumbs && this._thumbs[i]) {
2427
+ if (on) this._thumbs[i].thumb.setAttribute('data-skip', '');
2428
+ else this._thumbs[i].thumb.removeAttribute('data-skip');
2429
+ }
2430
+ this._markLastVisible();
2431
+ this._emitDeckChange({ action: on ? 'skip' : 'unskip', from: i, slide });
2432
+ // Re-broadcast so the presenter popup's prev/next thumbnails re-pick
2433
+ // the nearest non-skipped slide without waiting for a nav event.
2434
+ try { window.parent.postMessage({ type: 'miaoda:deck:slide-changed', index: this._index, total: this._slides.length, skipped: this._skippedIndices() }, '*'); } catch (e) {}
2435
+ }
2436
+
2437
+ _skippedIndices() {
2438
+ const out = [];
2439
+ for (let i = 0; i < this._slides.length; i++) {
2440
+ if (this._slides[i].hasAttribute('data-deck-skip')) out.push(i);
2441
+ }
2442
+ return out;
2443
+ }
2444
+
2445
+ _moveSlide(i, j) {
2446
+ if (j < 0 || j >= this._slides.length || j === i) return;
2447
+ const slide = this._slides[i];
2448
+ const ref = j < i ? this._slides[j] : this._slides[j].nextSibling;
2449
+ // Track the active slide across the reorder so the same content
2450
+ // stays on screen.
2451
+ const cur = this._index;
2452
+ if (cur === i) this._index = j;
2453
+ else if (i < cur && j >= cur) this._index = cur - 1;
2454
+ else if (i > cur && j <= cur) this._index = cur + 1;
2455
+ this._squelchSlotChange = true;
2456
+ this.insertBefore(slide, ref);
2457
+ // Move the note entry in lockstep so notes stay aligned to slides.
2458
+ if (Array.isArray(this._notes)) {
2459
+ const [n] = this._notes.splice(i, 1);
2460
+ this._notes.splice(j, 0, n === undefined ? '' : n);
2461
+ }
2462
+ this._emitDeckChange({ action: 'move', from: i, to: j, slide });
2463
+ this._collectSlides();
2464
+ this._applyIndex({ broadcast: true, reason: 'mutation' });
2465
+ }
2466
+
2467
+ // Public API ------------------------------------------------------------
2468
+
2469
+ /** Current slide index (0-based). */
2470
+ get index() { return this._index; }
2471
+ /** Total slide count. */
2472
+ get length() { return this._slides.length; }
2473
+ /** Programmatically navigate. */
2474
+ goTo(i) { this._go(i, 'api'); }
2475
+ next() { this._advance(1, 'api'); }
2476
+ prev() { this._advance(-1, 'api'); }
2477
+ reset() { this._go(0, 'api'); }
2478
+ }
2479
+
2480
+ if (!customElements.get('deck-stage')) {
2481
+ customElements.define('deck-stage', DeckStage);
2482
+ }
2483
+ })();