@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.
- package/README.md +5 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -2
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/skills/lark-approval/references/lark-approval-initiate.md +2 -5
- package/skills/lark-approval/references/lark-approval-instances-initiated.md +6 -0
- package/skills/lark-approval/references/lark-approval-tasks-query.md +9 -0
- package/skills/lark-approval/references/lark-approval-tasks-rollback.md +8 -2
- package/skills/lark-apps/SKILL.md +46 -16
- package/skills/lark-apps/creative-design/agents/assets/vision-probe.png +0 -0
- package/skills/lark-apps/creative-design/agents/fork-verifier-agent.md +71 -0
- package/skills/lark-apps/creative-design/agents/vision-probe-agent.md +41 -0
- package/skills/lark-apps/creative-design/assets/index.html +27 -0
- package/skills/lark-apps/creative-design/creative-design.md +239 -0
- package/skills/lark-apps/creative-design/references/aily.md +39 -0
- package/skills/lark-apps/creative-design/references/animated-video.md +34 -0
- package/skills/lark-apps/creative-design/references/charts.md +165 -0
- package/skills/lark-apps/creative-design/references/claude.md +36 -0
- package/skills/lark-apps/creative-design/references/codex.md +32 -0
- package/skills/lark-apps/creative-design/references/data-report.md +108 -0
- package/skills/lark-apps/creative-design/references/frontend-design.md +71 -0
- package/skills/lark-apps/creative-design/references/hi-fi-design.md +32 -0
- package/skills/lark-apps/creative-design/references/interactive-prototype.md +24 -0
- package/skills/lark-apps/creative-design/references/make-a-deck.md +133 -0
- package/skills/lark-apps/creative-design/references/visual-exposure.md +82 -0
- package/skills/lark-apps/creative-design/references/wireframe.md +14 -0
- package/skills/lark-apps/creative-design/starter-components/android-frame.jsx +188 -0
- package/skills/lark-apps/creative-design/starter-components/animations.jsx +773 -0
- package/skills/lark-apps/creative-design/starter-components/browser-window.jsx +122 -0
- package/skills/lark-apps/creative-design/starter-components/deck-stage.js +2483 -0
- package/skills/lark-apps/creative-design/starter-components/design-canvas.jsx +1432 -0
- package/skills/lark-apps/creative-design/starter-components/ios-frame.jsx +270 -0
- package/skills/lark-apps/creative-design/starter-components/macos-window.jsx +197 -0
- package/skills/lark-apps/creative-design/starter-components/tweaks-panel.jsx +752 -0
- package/skills/lark-apps/references/lark-apps-access-scope-set.md +1 -1
- package/skills/lark-apps/references/lark-apps-automation.md +242 -0
- package/skills/lark-apps/references/lark-apps-cache.md +61 -0
- package/skills/lark-apps/references/lark-apps-cloud-dev.md +0 -1
- package/skills/lark-apps/references/lark-apps-create.md +1 -2
- package/skills/lark-apps/references/lark-apps-db-execute.md +186 -2
- package/skills/lark-apps/references/lark-apps-db.md +4 -4
- package/skills/lark-apps/references/lark-apps-env-pull.md +1 -1
- package/skills/lark-apps/references/lark-apps-file.md +2 -2
- package/skills/lark-apps/references/lark-apps-get.md +43 -0
- package/skills/lark-apps/references/lark-apps-git-credential.md +1 -1
- package/skills/lark-apps/references/lark-apps-html-publish.md +5 -4
- package/skills/lark-apps/references/lark-apps-init.md +2 -3
- package/skills/lark-apps/references/lark-apps-list.md +1 -1
- package/skills/lark-apps/references/lark-apps-local-dev.md +54 -11
- package/skills/lark-apps/references/lark-apps-openapi-key.md +1 -1
- package/skills/lark-apps/references/lark-apps-release-create.md +5 -3
- package/skills/lark-apps/references/lark-apps-release-get.md +3 -3
- package/skills/lark-apps/references/lark-apps-role.md +133 -0
- package/skills/lark-base/SKILL.md +26 -15
- package/skills/lark-base/references/dashboard-block-data-config.md +28 -2
- package/skills/lark-base/references/lark-base-cell-value.md +12 -7
- package/skills/lark-base/references/lark-base-dashboard-block-get-data.md +7 -7
- package/skills/lark-base/references/lark-base-dashboard.md +11 -2
- package/skills/lark-base/references/lark-base-data-query.md +20 -11
- package/skills/lark-base/references/lark-base-field-create.md +8 -2
- package/skills/lark-base/references/lark-base-field-json.md +56 -19
- package/skills/lark-base/references/lark-base-field-update.md +21 -3
- package/skills/lark-base/references/lark-base-filter-condition.md +179 -0
- package/skills/lark-base/references/lark-base-form-questions-create.md +40 -7
- package/skills/lark-base/references/lark-base-form-questions-update.md +73 -20
- package/skills/lark-base/references/lark-base-form-submit.md +16 -7
- package/skills/lark-base/references/lark-base-record-batch-create.md +12 -10
- package/skills/lark-base/references/lark-base-record-batch-update.md +11 -9
- package/skills/lark-base/references/lark-base-record-upsert.md +1 -1
- package/skills/lark-base/references/lark-base-role-guide.md +11 -0
- package/skills/lark-base/references/lark-base-view-set-filter.md +14 -138
- package/skills/lark-base/references/role-config.md +31 -5
- package/skills/lark-calendar/SKILL.md +101 -37
- package/skills/lark-calendar/references/lark-calendar-create.md +13 -43
- package/skills/lark-calendar/references/lark-calendar-recurring.md +1 -0
- package/skills/lark-calendar/references/lark-calendar-room-find.md +7 -10
- package/skills/lark-calendar/references/lark-calendar-rsvp.md +1 -5
- package/skills/lark-calendar/references/lark-calendar-schedule-clear-time.md +60 -0
- package/skills/lark-calendar/references/lark-calendar-schedule-fuzzy-time.md +88 -0
- package/skills/lark-calendar/references/lark-calendar-schedule-meeting.md +67 -210
- package/skills/lark-calendar/references/lark-calendar-suggestion.md +2 -6
- package/skills/lark-calendar/references/lark-calendar-update.md +12 -11
- package/skills/lark-contact/SKILL.md +19 -3
- package/skills/lark-contact/references/lark-contact-search-bot.md +60 -0
- package/skills/lark-doc/SKILL.md +1 -1
- package/skills/lark-doc/references/lark-doc-fetch.md +14 -4
- package/skills/lark-doc/references/lark-doc-mindnote.md +17 -2
- package/skills/lark-doc/references/lark-doc-whiteboard.md +13 -8
- package/skills/lark-doc/references/lark-doc-xml-extended-blocks.md +76 -0
- package/skills/lark-doc/references/lark-doc-xml.md +6 -4
- package/skills/lark-drive/SKILL.md +35 -43
- package/skills/lark-drive/references/lark-drive-add-comment.md +2 -4
- package/skills/lark-drive/references/lark-drive-add-reply.md +47 -0
- package/skills/lark-drive/references/lark-drive-apply-permission.md +2 -2
- package/skills/lark-drive/references/lark-drive-batch-query-comments.md +46 -0
- package/skills/lark-drive/references/lark-drive-comment-content.md +50 -0
- package/skills/lark-drive/references/lark-drive-comment-location.md +18 -12
- package/skills/lark-drive/references/lark-drive-delete-reply.md +48 -0
- package/skills/lark-drive/references/lark-drive-delete.md +35 -11
- package/skills/lark-drive/references/lark-drive-download.md +5 -1
- package/skills/lark-drive/references/lark-drive-export.md +39 -10
- package/skills/lark-drive/references/lark-drive-files-list.md +27 -2
- package/skills/lark-drive/references/lark-drive-inspect.md +2 -0
- package/skills/lark-drive/references/lark-drive-list-comments.md +82 -0
- package/skills/lark-drive/references/lark-drive-list-replies.md +54 -0
- package/skills/lark-drive/references/lark-drive-member-add.md +3 -3
- package/skills/lark-drive/references/lark-drive-member-list.md +65 -0
- package/skills/lark-drive/references/lark-drive-move.md +5 -3
- package/skills/lark-drive/references/lark-drive-permission-get-setting.md +48 -0
- package/skills/lark-drive/references/lark-drive-permission-guide.md +12 -0
- package/skills/lark-drive/references/lark-drive-preview.md +11 -1
- package/skills/lark-drive/references/lark-drive-pull.md +3 -3
- package/skills/lark-drive/references/lark-drive-push.md +33 -6
- package/skills/lark-drive/references/lark-drive-react-reply.md +51 -0
- package/skills/lark-drive/references/lark-drive-reactions.md +27 -25
- package/skills/lark-drive/references/lark-drive-resolve-comment.md +45 -0
- package/skills/lark-drive/references/lark-drive-restore-comment.md +46 -0
- package/skills/lark-drive/references/lark-drive-search.md +7 -1
- package/skills/lark-drive/references/lark-drive-secure-label.md +1 -1
- package/skills/lark-drive/references/lark-drive-status.md +12 -14
- package/skills/lark-drive/references/lark-drive-task-result.md +58 -5
- package/skills/lark-drive/references/lark-drive-update-reply.md +46 -0
- package/skills/lark-drive/references/lark-drive-upload.md +1 -0
- package/skills/lark-drive/references/lark-drive-workflow-knowledge-organize.md +26 -20
- package/skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md +38 -8
- package/skills/lark-drive/references/lark-drive-workflow-permission-governance-outputs.md +10 -10
- package/skills/lark-drive/references/lark-drive-workflow-permission-governance.md +22 -20
- package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-execute.md +273 -0
- package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-recall.md +202 -0
- package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-resolve-verify.md +231 -0
- package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-review-plan.md +248 -0
- package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-setup.md +174 -0
- package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector.md +202 -0
- package/skills/lark-drive/references/lark-drive-workflow.md +5 -3
- package/skills/lark-event/SKILL.md +3 -1
- package/skills/lark-event/references/lark-event-application.md +38 -0
- package/skills/lark-event/references/lark-event-approval.md +170 -0
- package/skills/lark-im/SKILL.md +6 -5
- package/skills/lark-im/references/card/card-2.0-schema.md +1 -1
- package/skills/lark-im/references/card/lark-im-card-style.md +4 -4
- package/skills/lark-im/references/card/resource/icons.md +14 -0
- package/skills/lark-im/references/lark-im-flag-list.md +8 -7
- package/skills/lark-im/references/lark-im-messages-reply.md +1 -1
- package/skills/lark-im/references/lark-im-messages-send.md +1 -1
- package/skills/lark-mail/SKILL.md +12 -9
- package/skills/lark-mail/references/lark-mail-forward.md +1 -1
- package/skills/lark-mail/references/lark-mail-message-modify.md +48 -0
- package/skills/lark-mail/references/lark-mail-message-trash.md +41 -0
- package/skills/lark-mail/references/lark-mail-reply-all.md +1 -1
- package/skills/lark-mail/references/lark-mail-reply.md +1 -1
- package/skills/lark-mail/references/lark-mail-watch.md +1 -1
- package/skills/lark-markdown/SKILL.md +3 -2
- package/skills/lark-markdown/references/lark-markdown-create.md +22 -2
- package/skills/lark-minutes/SKILL.md +19 -4
- package/skills/lark-minutes/references/lark-minutes-download.md +0 -2
- package/skills/lark-minutes/references/lark-minutes-search.md +0 -2
- package/skills/lark-minutes/references/lark-minutes-speaker-replace.md +0 -2
- package/skills/lark-minutes/references/lark-minutes-summary.md +0 -2
- package/skills/lark-minutes/references/lark-minutes-todo.md +2 -4
- package/skills/lark-minutes/references/lark-minutes-update.md +0 -2
- package/skills/lark-minutes/references/lark-minutes-upload.md +10 -10
- package/skills/lark-okr/SKILL.md +71 -26
- package/skills/lark-okr/references/lark-okr-batch-create.md +19 -18
- package/skills/lark-okr/references/lark-okr-create.md +173 -0
- package/skills/lark-okr/references/lark-okr-cycle-list.md +17 -7
- package/skills/lark-okr/references/lark-okr-entities.md +1 -0
- package/skills/lark-okr/references/lark-okr-indicator-update.md +3 -1
- package/skills/lark-okr/references/lark-okr-indicators.md +61 -12
- package/skills/lark-okr/references/lark-okr-progress-list.md +21 -9
- package/skills/lark-shared/SKILL.md +26 -8
- package/skills/lark-sheets/SKILL.md +98 -29
- package/skills/lark-sheets/references/lark-sheets-batch-update.md +18 -9
- package/skills/lark-sheets/references/lark-sheets-changeset.md +105 -0
- package/skills/lark-sheets/references/lark-sheets-chart.md +4 -2
- package/skills/lark-sheets/references/lark-sheets-conditional-format.md +2 -0
- package/skills/lark-sheets/references/lark-sheets-filter-view.md +1 -1
- package/skills/lark-sheets/references/lark-sheets-float-image.md +6 -6
- package/skills/lark-sheets/references/lark-sheets-formula-translation.md +12 -3
- package/skills/lark-sheets/references/lark-sheets-formula-verify.md +77 -0
- package/skills/lark-sheets/references/lark-sheets-history.md +93 -0
- package/skills/lark-sheets/references/lark-sheets-pivot-table.md +7 -2
- package/skills/lark-sheets/references/lark-sheets-range-operations.md +44 -14
- package/skills/lark-sheets/references/lark-sheets-read-data.md +3 -3
- package/skills/lark-sheets/references/lark-sheets-sheet-structure.md +4 -4
- package/skills/lark-sheets/references/lark-sheets-visual-standards.md +4 -4
- package/skills/lark-sheets/references/lark-sheets-workbook.md +29 -4
- package/skills/lark-sheets/references/lark-sheets-write-cells.md +21 -11
- package/skills/lark-slides/SKILL.md +121 -63
- package/skills/lark-slides/references/asset-planning.md +18 -5
- package/skills/lark-slides/references/iconpark.md +3 -3
- package/skills/lark-slides/references/lark-slides-create.md +30 -3
- package/skills/lark-slides/references/lark-slides-history.md +132 -0
- package/skills/lark-slides/references/lark-slides-media-upload.md +1 -3
- package/skills/lark-slides/references/lark-slides-pptx-template-workflows.md +85 -0
- package/skills/lark-slides/references/lark-slides-replace-pages.md +1 -1
- package/skills/lark-slides/references/lark-slides-replace-slide.md +1 -4
- package/skills/lark-slides/references/lark-slides-screenshot.md +11 -8
- package/skills/lark-slides/references/lark-slides-xml-presentation-slide-create.md +5 -6
- package/skills/lark-slides/references/lark-slides-xml-presentation-slide-delete.md +5 -2
- package/skills/lark-slides/references/lark-slides-xml-presentation-slide-get.md +5 -5
- package/skills/lark-slides/references/lark-slides-xml-presentation-slide-replace.md +14 -13
- package/skills/lark-slides/references/lark-slides-xml-presentations-get.md +67 -31
- package/skills/lark-slides/references/planning-layer.md +41 -10
- package/skills/lark-slides/references/slides_chart_demo.xml +1416 -0
- package/skills/lark-slides/references/slides_xml_schema_definition.xml +499 -78
- package/skills/lark-slides/references/troubleshooting.md +5 -5
- package/skills/lark-slides/references/validation-checklist.md +65 -19
- package/skills/lark-slides/references/visual-planning.md +26 -22
- package/skills/lark-slides/references/xml-schema-quick-ref.md +285 -45
- package/skills/lark-slides/scripts/sxsd_validator.py +908 -0
- package/skills/lark-slides/scripts/xml_text_overlap_lint.py +2429 -91
- package/skills/lark-slides/scripts/xml_text_overlap_lint_test.py +3567 -70
- package/skills/lark-task/SKILL.md +8 -0
- package/skills/lark-task/references/lark-task-complete.md +6 -2
- package/skills/lark-task/references/lark-task-create.md +23 -1
- package/skills/lark-task/references/lark-task-update.md +6 -2
- package/skills/lark-vc/SKILL.md +6 -3
- package/skills/lark-vc/references/lark-vc-recording.md +0 -2
- package/skills/lark-vc/references/vc-domain-boundaries.md +9 -1
- package/skills/lark-vc-agent/SKILL.md +25 -15
- package/skills/lark-vc-agent/references/lark-vc-agent-meeting-events.md +65 -37
- package/skills/lark-vc-agent/references/lark-vc-agent-meeting-leave.md +1 -1
- package/skills/lark-vc-agent/references/lark-vc-agent-meeting-list-active.md +8 -8
- package/skills/lark-whiteboard/SKILL.md +13 -12
- package/skills/lark-whiteboard/elements/layout.md +1 -1
- package/skills/lark-whiteboard/elements/schema.md +2 -2
- package/skills/lark-whiteboard/references/{lark-whiteboard-query.md → lark-whiteboard-export.md} +15 -15
- package/skills/lark-whiteboard/references/lark-whiteboard-update.md +3 -3
- package/skills/lark-whiteboard/references/lark-whiteboard-workflow.md +12 -19
- package/skills/lark-whiteboard/routes/dsl.md +3 -3
- package/skills/lark-whiteboard/routes/mermaid.md +2 -2
- package/skills/lark-whiteboard/routes/svg-edit.md +4 -4
- package/skills/lark-whiteboard/routes/svg.md +11 -6
- package/skills/lark-whiteboard/scenes/bar-chart.md +1 -1
- package/skills/lark-whiteboard/scenes/fishbone.md +1 -1
- package/skills/lark-whiteboard/scenes/flywheel.md +1 -1
- package/skills/lark-whiteboard/scenes/line-chart.md +1 -1
- package/skills/lark-whiteboard/scenes/treemap.md +1 -1
- package/skills/lark-wiki/SKILL.md +8 -3
- package/skills/lark-wiki/references/lark-wiki-move-to-drive.md +122 -0
- package/skills/lark-wiki/references/lark-wiki-move.md +5 -3
- package/skills/lark-wiki/references/lark-wiki-node-get.md +1 -1
- package/skills/lark-wiki/references/lark-wiki-node-list.md +9 -2
- package/skills/lark-calendar/references/lark-calendar-agenda.md +0 -78
- package/skills/lark-calendar/references/lark-calendar-freebusy.md +0 -124
- package/skills/lark-calendar/references/lark-calendar-search-event.md +0 -29
- package/skills/lark-drive/references/lark-drive-comments-guide.md +0 -72
- package/skills/lark-sheets/references/lark-sheets-core-operations.md +0 -103
- package/skills/lark-slides/references/examples.md +0 -261
- package/skills/lark-slides/references/lark-slides-whiteboard.md +0 -330
- package/skills/lark-slides/references/slide-templates.md +0 -201
- package/skills/lark-slides/references/slides_demo.xml +0 -226
- 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
|
+
})();
|