@agent-native/core 0.131.5 → 0.131.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/corpus/README.md +3 -3
- package/corpus/core/CHANGELOG.md +70 -0
- package/corpus/core/docs/content/agent-native-toolkit.mdx +13 -6
- package/corpus/core/docs/content/cloneable-saas.mdx +5 -5
- package/corpus/core/docs/content/creating-templates.mdx +9 -0
- package/corpus/core/docs/content/deployment.mdx +18 -12
- package/corpus/core/docs/content/doctor.mdx +8 -1
- package/corpus/core/docs/content/drop-in-agent.mdx +1 -1
- package/corpus/core/docs/content/extensions.mdx +18 -18
- package/corpus/core/docs/content/faq.mdx +4 -4
- package/corpus/core/docs/content/key-concepts.mdx +30 -21
- package/corpus/core/docs/content/security.mdx +2 -2
- package/corpus/core/docs/content/template-brain-developers.mdx +4 -1
- package/corpus/core/docs/content/template-mail-developers.mdx +4 -1
- package/corpus/core/docs/content/what-is-agent-native.mdx +6 -6
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/a2a/client.ts +449 -66
- package/corpus/core/src/a2a/correlation.ts +31 -0
- package/corpus/core/src/a2a/invoke.ts +4 -2
- package/corpus/core/src/a2a/types.ts +4 -0
- package/corpus/core/src/action.ts +3 -0
- package/corpus/core/src/agent/action-continuation-guidance.ts +2 -1
- package/corpus/core/src/agent/engine/registry.ts +98 -6
- package/corpus/core/src/agent/production-agent.ts +151 -18
- package/corpus/core/src/agent/run-loop-with-resume.ts +190 -3
- package/corpus/core/src/agent/run-manager.ts +117 -41
- package/corpus/core/src/agent/thread-debug-history.ts +86 -0
- package/corpus/core/src/agent/types.ts +3 -1
- package/corpus/core/src/cli/create.ts +11 -1
- package/corpus/core/src/cli/templates-meta.ts +2 -2
- package/corpus/core/src/client/agent-chat-adapter.ts +91 -13
- package/corpus/core/src/client/resources/McpIntegrationDialog.tsx +11 -9
- package/corpus/core/src/client/sse-event-processor.ts +17 -3
- package/corpus/core/src/db/client.ts +57 -17
- package/corpus/core/src/guards/no-env-credentials.ts +2 -0
- package/corpus/core/src/integrations/adapters/slack.ts +8 -3
- package/corpus/core/src/localization/default-messages.ts +44 -0
- package/corpus/core/src/observability/traces.ts +78 -5
- package/corpus/core/src/scripts/call-agent.ts +294 -11
- package/corpus/core/src/scripts/describe-workspace-apps.ts +2 -2
- package/corpus/core/src/secrets/crypto.ts +156 -35
- package/corpus/core/src/secrets/storage.ts +64 -26
- package/corpus/core/src/server/agent-capabilities.ts +1 -1
- package/corpus/core/src/server/agent-chat/action-filters-a2a.ts +113 -7
- package/corpus/core/src/server/agent-chat/context-tools.ts +2 -0
- package/corpus/core/src/server/agent-chat/plugin-options.ts +34 -1
- package/corpus/core/src/server/agent-chat/prompt-resources.ts +1 -1
- package/corpus/core/src/server/agent-chat-plugin.ts +91 -59
- package/corpus/core/src/templates/default/.agents/skills/secrets/SKILL.md +19 -7
- package/corpus/core/src/templates/headless/.agents/skills/secrets/SKILL.md +19 -7
- package/corpus/core/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +18 -7
- package/corpus/core/src/templates/workspace-core/.agents/skills/composable-mini-apps/SKILL.md +15 -14
- package/corpus/core/src/templates/workspace-core/.agents/skills/secrets/SKILL.md +19 -7
- package/corpus/core/src/vite/client.ts +1 -0
- package/corpus/templates/analytics/.agents/skills/dashboard-ops/SKILL.md +5 -4
- package/corpus/templates/analytics/.agents/skills/gong/SKILL.md +6 -4
- package/corpus/templates/analytics/AGENTS.md +7 -4
- package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +4 -3
- package/corpus/templates/analytics/changelog/2026-07-30-delegated-analytics-questions-now-return-a-useful-current-me.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-30-scheduled-dashboard-emails-complete-every-panel-without-pool.md +6 -0
- package/corpus/templates/analytics/scripts/emit-netlify-dashboard-report-cron.ts +28 -1
- package/corpus/templates/analytics/server/jobs/dashboard-report.ts +18 -11
- package/corpus/templates/analytics/server/lib/analytics-connector-catalog.ts +4 -4
- package/corpus/templates/analytics/server/lib/canonical-first-party-dashboard-repair.ts +9 -5
- package/corpus/templates/analytics/server/lib/dashboard-panel-query.ts +6 -2
- package/corpus/templates/analytics/server/lib/dashboard-panel-source-resolver.ts +11 -4
- package/corpus/templates/analytics/server/lib/dashboard-report-render.ts +49 -31
- package/corpus/templates/analytics/server/lib/first-party-analytics.ts +16 -8
- package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +9 -3
- package/corpus/templates/analytics/server/lib/production-serverless-runtime.ts +11 -0
- package/corpus/templates/analytics/server/lib/real-data-actions.ts +10 -0
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +3 -2
- package/corpus/templates/analytics/server/plugins/analytics-alert-jobs.ts +2 -1
- package/corpus/templates/analytics/server/plugins/dashboard-report-jobs.ts +2 -1
- package/corpus/templates/analytics/server/plugins/uptime-monitor-jobs.ts +1 -12
- package/corpus/templates/brain/.agents/skills/brain/RUNBOOK.md +54 -6
- package/corpus/templates/brain/.agents/skills/brain/SKILL.md +20 -9
- package/corpus/templates/brain/.agents/skills/ingestion-and-connectors/SKILL.md +33 -1
- package/corpus/templates/brain/AGENTS.md +3 -1
- package/corpus/templates/brain/README.md +4 -1
- package/corpus/templates/brain/actions/_schemas.ts +30 -8
- package/corpus/templates/brain/actions/ask-brain.ts +124 -14
- package/corpus/templates/brain/actions/create-source.ts +18 -2
- package/corpus/templates/brain/actions/enqueue-captures-distillation.ts +8 -123
- package/corpus/templates/brain/actions/enqueue-distillation.ts +6 -127
- package/corpus/templates/brain/actions/import-capture.ts +17 -5
- package/corpus/templates/brain/actions/import-transcript.ts +17 -5
- package/corpus/templates/brain/actions/update-source.ts +25 -5
- package/corpus/templates/brain/app/components/layout/Sidebar.tsx +1 -1
- package/corpus/templates/brain/changelog/2026-07-30-blessed-resources-can-feed-cited-company-answers.md +6 -0
- package/corpus/templates/brain/changelog/2026-07-30-the-left-sidebar-no-longer-shows-a-blank-organization-placeh.md +6 -0
- package/corpus/templates/brain/server/lib/brain.ts +49 -19
- package/corpus/templates/brain/server/lib/distillation-queue.ts +147 -0
- package/corpus/templates/brain/server/lib/source-policy.ts +264 -0
- package/corpus/templates/brain/server/plugins/agent-chat.ts +3 -2
- package/corpus/templates/brain/server/routes/api/_agent-native/brain/ingest.post.ts +116 -19
- package/corpus/templates/clips/app/components/meetings/google-oauth-identity-notice.tsx +51 -0
- package/corpus/templates/clips/app/i18n/en-US.ts +4 -0
- package/corpus/templates/clips/app/routes/_app.meetings._index.tsx +3 -1
- package/corpus/templates/clips/changelog/2026-07-30-google-calendar-warning.md +6 -0
- package/corpus/templates/content/app/components/editor/VisualEditor.tsx +72 -8
- package/corpus/templates/content/changelog/2026-07-30-the-slash-command-hint-now-stays-on-only-the-active-empty-li.md +6 -0
- package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +506 -11
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +148 -17
- package/corpus/templates/design/app/components/design/canvas-interactions/design-canvas-interactions.ts +357 -0
- package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +7 -466
- package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +15 -311
- package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +19 -997
- package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +8 -1216
- package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +31 -38
- package/corpus/templates/design/package.json +0 -1
- package/corpus/templates/dispatch/AGENTS.md +8 -0
- package/corpus/templates/dispatch/DEVELOPING.md +29 -0
- package/corpus/templates/dispatch/actions/view-screen.ts +34 -2
- package/corpus/templates/dispatch/app/i18n-data.ts +457 -0
- package/corpus/templates/dispatch/changelog/2026-07-30-delegated-dispatch-work-continues-reliably-in-the-background.md +6 -0
- package/corpus/templates/dispatch/changelog/2026-07-30-find-failed-runs-across-apps.md +6 -0
- package/corpus/templates/dispatch/netlify.toml +3 -0
- package/corpus/templates/plan/actions/get-visual-plan.ts +10 -6
- package/corpus/templates/plan/actions/patch-visual-plan-source.ts +117 -1
- package/corpus/templates/plan/actions/update-visual-plan.ts +56 -14
- package/corpus/templates/plan/app/pages/PlansPage.tsx +1 -1
- package/corpus/templates/plan/changelog/2026-07-30-plan-agents-recover-with-targeted-writes.md +6 -0
- package/corpus/templates/plan/shared/plan-content.ts +35 -0
- package/corpus/templates/slides/.agents/skills/analytics-data-for-decks/SKILL.md +8 -12
- package/corpus/templates/slides/AGENTS.md +4 -4
- package/corpus/templates/slides/actions/create-deck.ts +1 -1
- package/corpus/templates/slides/actions/duplicate-deck.ts +19 -4
- package/corpus/templates/slides/app/components/deck/DeckCard.tsx +2 -10
- package/corpus/templates/slides/app/components/deck/MermaidRenderer.tsx +12 -1
- package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +30 -13
- package/corpus/templates/slides/app/components/editor/DeckEditorSkeleton.tsx +35 -0
- package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +63 -56
- package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +21 -76
- package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +1110 -292
- package/corpus/templates/slides/app/components/editor/SlideOverflowWarning.tsx +53 -0
- package/corpus/templates/slides/app/components/editor/SlideStyleInspector.tsx +235 -18
- package/corpus/templates/slides/app/components/editor/SpeakerNotesPanel.tsx +4 -1
- package/corpus/templates/slides/app/components/editor/canvas-interactions/index.ts +11 -0
- package/corpus/templates/slides/app/components/editor/canvas-interactions/slides-canvas-adapter.ts +129 -0
- package/corpus/templates/slides/app/components/editor/selection-overlay-measurement.ts +70 -0
- package/corpus/templates/slides/app/components/editor/slide-object-interactions.ts +519 -65
- package/corpus/templates/slides/app/components/visual-editor/CanvasCommentPins.tsx +36 -426
- package/corpus/templates/slides/app/components/visual-editor/DrawOverlay.tsx +19 -458
- package/corpus/templates/slides/app/context/DeckContext.tsx +179 -72
- package/corpus/templates/slides/app/global.css +105 -0
- package/corpus/templates/slides/app/i18n/en-US.ts +8 -0
- package/corpus/templates/slides/app/lib/deck-editor-loading.ts +26 -0
- package/corpus/templates/slides/app/lib/mermaid-blocks.ts +27 -0
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +71 -38
- package/corpus/templates/slides/app/pages/Index.tsx +98 -41
- package/corpus/templates/slides/changelog/2026-07-30-deck-loading-no-longer-flashes-an-unavailable-message.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-30-duplicating-a-deck-from-the-deck-list-now-opens-the-copy-imm.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-30-layout-overflow-warning-is-readable-and-dismissible.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-30-new-decks-now-start-with-an-empty-slide-list-when-no-slides-.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-30-shared-canvas-annotations.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-30-slide-objects-can-be-moved-as-a-group-copied-and-pasted-with.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-30-slide-text-editing-and-object-controls-now-match-google-slides.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-30-style-panel-now-scrolls-with-speaker-notes-open-shows-the-sl.md +6 -0
- package/corpus/templates/slides/changelog/2026-07-30-top-toolbar-controls-now-use-consistent-sizing-with-undo-and.md +6 -0
- package/corpus/templates/slides/netlify.toml +3 -1
- package/corpus/templates/slides/package.json +0 -1
- package/corpus/templates/slides/server/lib/chat-attachments.ts +18 -0
- package/corpus/templates/slides/server/plugins/agent-chat.ts +5 -0
- package/corpus/templates/slides/vite.config.ts +0 -1
- package/corpus/toolkit/CHANGELOG.md +13 -0
- package/corpus/toolkit/README.md +19 -0
- package/corpus/toolkit/agent-native.eject.json +38 -0
- package/corpus/toolkit/package.json +29 -1
- package/corpus/toolkit/src/canvas-annotations/CanvasCommentPins.tsx +861 -0
- package/corpus/toolkit/src/canvas-annotations/DrawOverlay.tsx +1233 -0
- package/corpus/toolkit/src/canvas-annotations/index.ts +15 -0
- package/corpus/toolkit/src/canvas-annotations/types.ts +14 -0
- package/corpus/toolkit/src/canvas-interactions/canvas-interactions.ts +933 -0
- package/corpus/toolkit/src/canvas-interactions/index.ts +67 -0
- package/corpus/toolkit/src/design-tweaks/scrub-input-utils.ts +311 -0
- package/corpus/toolkit/src/design-tweaks/scrub-input.tsx +491 -0
- package/corpus/toolkit/src/design-tweaks/visual-style-controls.tsx +26 -171
- package/corpus/toolkit/src/index.ts +1 -0
- package/dist/a2a/client.d.ts +23 -0
- package/dist/a2a/client.d.ts.map +1 -1
- package/dist/a2a/client.js +309 -44
- package/dist/a2a/client.js.map +1 -1
- package/dist/a2a/correlation.d.ts +1 -0
- package/dist/a2a/correlation.d.ts.map +1 -1
- package/dist/a2a/correlation.js +27 -0
- package/dist/a2a/correlation.js.map +1 -1
- package/dist/a2a/invoke.d.ts.map +1 -1
- package/dist/a2a/invoke.js +4 -2
- package/dist/a2a/invoke.js.map +1 -1
- package/dist/a2a/types.d.ts +4 -0
- package/dist/a2a/types.d.ts.map +1 -1
- package/dist/a2a/types.js.map +1 -1
- package/dist/action.d.ts +3 -0
- package/dist/action.d.ts.map +1 -1
- package/dist/action.js.map +1 -1
- package/dist/agent/action-continuation-guidance.d.ts.map +1 -1
- package/dist/agent/action-continuation-guidance.js +2 -1
- package/dist/agent/action-continuation-guidance.js.map +1 -1
- package/dist/agent/engine/registry.d.ts.map +1 -1
- package/dist/agent/engine/registry.js +82 -8
- package/dist/agent/engine/registry.js.map +1 -1
- package/dist/agent/production-agent.d.ts +40 -1
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +113 -15
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/agent/run-loop-with-resume.d.ts +21 -0
- package/dist/agent/run-loop-with-resume.d.ts.map +1 -1
- package/dist/agent/run-loop-with-resume.js +163 -4
- package/dist/agent/run-loop-with-resume.js.map +1 -1
- package/dist/agent/run-manager.d.ts +16 -9
- package/dist/agent/run-manager.d.ts.map +1 -1
- package/dist/agent/run-manager.js +93 -42
- package/dist/agent/run-manager.js.map +1 -1
- package/dist/agent/thread-debug-history.d.ts +10 -0
- package/dist/agent/thread-debug-history.d.ts.map +1 -0
- package/dist/agent/thread-debug-history.js +68 -0
- package/dist/agent/thread-debug-history.js.map +1 -0
- package/dist/agent/types.d.ts +3 -1
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/cli/create.d.ts.map +1 -1
- package/dist/cli/create.js +6 -1
- package/dist/cli/create.js.map +1 -1
- package/dist/cli/templates-meta.js +2 -2
- package/dist/cli/templates-meta.js.map +1 -1
- package/dist/client/agent-chat-adapter.d.ts +1 -1
- package/dist/client/agent-chat-adapter.d.ts.map +1 -1
- package/dist/client/agent-chat-adapter.js +76 -13
- package/dist/client/agent-chat-adapter.js.map +1 -1
- package/dist/client/resources/McpIntegrationDialog.d.ts.map +1 -1
- package/dist/client/resources/McpIntegrationDialog.js +1 -1
- package/dist/client/resources/McpIntegrationDialog.js.map +1 -1
- package/dist/client/sse-event-processor.d.ts.map +1 -1
- package/dist/client/sse-event-processor.js +12 -3
- package/dist/client/sse-event-processor.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +48 -17
- package/dist/db/client.js.map +1 -1
- package/dist/guards/no-env-credentials.d.ts.map +1 -1
- package/dist/guards/no-env-credentials.js +2 -0
- package/dist/guards/no-env-credentials.js.map +1 -1
- package/dist/integrations/adapters/slack.js +7 -3
- package/dist/integrations/adapters/slack.js.map +1 -1
- package/dist/localization/default-messages.d.ts +43 -0
- package/dist/localization/default-messages.d.ts.map +1 -1
- package/dist/localization/default-messages.js +43 -0
- package/dist/localization/default-messages.js.map +1 -1
- package/dist/observability/traces.d.ts +3 -1
- package/dist/observability/traces.d.ts.map +1 -1
- package/dist/observability/traces.js +73 -4
- package/dist/observability/traces.js.map +1 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
- package/dist/provider-api/actions/provider-api.d.ts +13 -13
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/scripts/call-agent.d.ts.map +1 -1
- package/dist/scripts/call-agent.js +237 -12
- package/dist/scripts/call-agent.js.map +1 -1
- package/dist/scripts/describe-workspace-apps.js +2 -2
- package/dist/scripts/describe-workspace-apps.js.map +1 -1
- package/dist/secrets/crypto.d.ts +36 -21
- package/dist/secrets/crypto.d.ts.map +1 -1
- package/dist/secrets/crypto.js +134 -33
- package/dist/secrets/crypto.js.map +1 -1
- package/dist/secrets/storage.d.ts +6 -6
- package/dist/secrets/storage.d.ts.map +1 -1
- package/dist/secrets/storage.js +43 -26
- package/dist/secrets/storage.js.map +1 -1
- package/dist/server/agent-capabilities.js +1 -1
- package/dist/server/agent-capabilities.js.map +1 -1
- package/dist/server/agent-chat/action-filters-a2a.d.ts +17 -3
- package/dist/server/agent-chat/action-filters-a2a.d.ts.map +1 -1
- package/dist/server/agent-chat/action-filters-a2a.js +94 -3
- package/dist/server/agent-chat/action-filters-a2a.js.map +1 -1
- package/dist/server/agent-chat/context-tools.d.ts.map +1 -1
- package/dist/server/agent-chat/context-tools.js +2 -0
- package/dist/server/agent-chat/context-tools.js.map +1 -1
- package/dist/server/agent-chat/plugin-options.d.ts +23 -0
- package/dist/server/agent-chat/plugin-options.d.ts.map +1 -1
- package/dist/server/agent-chat/plugin-options.js +4 -1
- package/dist/server/agent-chat/plugin-options.js.map +1 -1
- package/dist/server/agent-chat/prompt-resources.js +1 -1
- package/dist/server/agent-chat/prompt-resources.js.map +1 -1
- package/dist/server/agent-chat-plugin.d.ts +2 -1
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +79 -56
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/templates/default/.agents/skills/secrets/SKILL.md +19 -7
- package/dist/templates/headless/.agents/skills/secrets/SKILL.md +19 -7
- package/dist/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +18 -7
- package/dist/templates/workspace-core/.agents/skills/composable-mini-apps/SKILL.md +15 -14
- package/dist/templates/workspace-core/.agents/skills/secrets/SKILL.md +19 -7
- package/dist/vite/client.d.ts.map +1 -1
- package/dist/vite/client.js +1 -0
- package/dist/vite/client.js.map +1 -1
- package/docs/content/agent-native-toolkit.mdx +13 -6
- package/docs/content/cloneable-saas.mdx +5 -5
- package/docs/content/creating-templates.mdx +9 -0
- package/docs/content/deployment.mdx +18 -12
- package/docs/content/doctor.mdx +8 -1
- package/docs/content/drop-in-agent.mdx +1 -1
- package/docs/content/extensions.mdx +18 -18
- package/docs/content/faq.mdx +4 -4
- package/docs/content/key-concepts.mdx +30 -21
- package/docs/content/security.mdx +2 -2
- package/docs/content/template-brain-developers.mdx +4 -1
- package/docs/content/template-mail-developers.mdx +4 -1
- package/docs/content/what-is-agent-native.mdx +6 -6
- package/package.json +3 -3
- package/src/a2a/client.ts +449 -66
- package/src/a2a/correlation.ts +31 -0
- package/src/a2a/invoke.ts +4 -2
- package/src/a2a/types.ts +4 -0
- package/src/action.ts +3 -0
- package/src/agent/action-continuation-guidance.ts +2 -1
- package/src/agent/engine/registry.ts +98 -6
- package/src/agent/production-agent.ts +151 -18
- package/src/agent/run-loop-with-resume.ts +190 -3
- package/src/agent/run-manager.ts +117 -41
- package/src/agent/thread-debug-history.ts +86 -0
- package/src/agent/types.ts +3 -1
- package/src/cli/create.ts +11 -1
- package/src/cli/templates-meta.ts +2 -2
- package/src/client/agent-chat-adapter.ts +91 -13
- package/src/client/resources/McpIntegrationDialog.tsx +11 -9
- package/src/client/sse-event-processor.ts +17 -3
- package/src/db/client.ts +57 -17
- package/src/guards/no-env-credentials.ts +2 -0
- package/src/integrations/adapters/slack.ts +8 -3
- package/src/localization/default-messages.ts +44 -0
- package/src/observability/traces.ts +78 -5
- package/src/scripts/call-agent.ts +294 -11
- package/src/scripts/describe-workspace-apps.ts +2 -2
- package/src/secrets/crypto.ts +156 -35
- package/src/secrets/storage.ts +64 -26
- package/src/server/agent-capabilities.ts +1 -1
- package/src/server/agent-chat/action-filters-a2a.ts +113 -7
- package/src/server/agent-chat/context-tools.ts +2 -0
- package/src/server/agent-chat/plugin-options.ts +34 -1
- package/src/server/agent-chat/prompt-resources.ts +1 -1
- package/src/server/agent-chat-plugin.ts +91 -59
- package/src/templates/default/.agents/skills/secrets/SKILL.md +19 -7
- package/src/templates/headless/.agents/skills/secrets/SKILL.md +19 -7
- package/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +18 -7
- package/src/templates/workspace-core/.agents/skills/composable-mini-apps/SKILL.md +15 -14
- package/src/templates/workspace-core/.agents/skills/secrets/SKILL.md +19 -7
- package/src/vite/client.ts +1 -0
|
@@ -16,13 +16,7 @@ import {
|
|
|
16
16
|
RecentEditHighlights,
|
|
17
17
|
} from "@agent-native/toolkit/collab-ui";
|
|
18
18
|
import { appStateKeyForBrowserTab } from "@shared/app-state-tabs";
|
|
19
|
-
import {
|
|
20
|
-
IconAlertTriangle,
|
|
21
|
-
IconMaximize,
|
|
22
|
-
IconX,
|
|
23
|
-
IconZoomIn,
|
|
24
|
-
IconZoomOut,
|
|
25
|
-
} from "@tabler/icons-react";
|
|
19
|
+
import { IconMaximize, IconZoomIn, IconZoomOut } from "@tabler/icons-react";
|
|
26
20
|
import {
|
|
27
21
|
useState,
|
|
28
22
|
useCallback,
|
|
@@ -55,13 +49,14 @@ import {
|
|
|
55
49
|
CanvasCommentPins,
|
|
56
50
|
MultiSelectChip,
|
|
57
51
|
} from "@/components/visual-editor";
|
|
58
|
-
import type { Slide } from "@/context/DeckContext";
|
|
52
|
+
import type { Slide, UpdateSlideOptions } from "@/context/DeckContext";
|
|
59
53
|
import { getAspectRatioDims, type AspectRatio } from "@/lib/aspect-ratios";
|
|
60
54
|
import {
|
|
61
55
|
computeCanvasFitZoom,
|
|
62
56
|
MAX_CANVAS_ZOOM,
|
|
63
57
|
MIN_CANVAS_ZOOM,
|
|
64
58
|
} from "@/lib/canvas-zoom";
|
|
59
|
+
import { extractMermaidBlocks } from "@/lib/mermaid-blocks";
|
|
65
60
|
import {
|
|
66
61
|
createPlaceholderImageTarget,
|
|
67
62
|
imageFileLooksSupported,
|
|
@@ -71,6 +66,12 @@ import { enterSelectionMode } from "@/root";
|
|
|
71
66
|
|
|
72
67
|
import type { DesignSystemData } from "../../../shared/api";
|
|
73
68
|
import { BlockBubbleMenu } from "./BlockBubbleMenu";
|
|
69
|
+
import {
|
|
70
|
+
createSlidesCanvasGestureController,
|
|
71
|
+
isWithinSlidesCanvasEdgeMoveBand,
|
|
72
|
+
resolveSlidesCanvasPointerIntent,
|
|
73
|
+
slidesCanvasInteractionCore,
|
|
74
|
+
} from "./canvas-interactions";
|
|
74
75
|
import ImageOverlay from "./ImageOverlay";
|
|
75
76
|
import {
|
|
76
77
|
applyInlineTextStyle,
|
|
@@ -81,29 +82,45 @@ import {
|
|
|
81
82
|
type InlineTextStylePatch,
|
|
82
83
|
type InlineTextStyleSnapshot,
|
|
83
84
|
} from "./rich-text-selection";
|
|
85
|
+
import {
|
|
86
|
+
createSelectionOverlayAutofitKey,
|
|
87
|
+
createSelectionOverlayMeasurementKey,
|
|
88
|
+
currentSelectionOverlayRect,
|
|
89
|
+
isSelectionOverlayAutofitSettled,
|
|
90
|
+
isSelectionOverlayOnActiveSlide,
|
|
91
|
+
type SelectionOverlayMeasurement,
|
|
92
|
+
} from "./selection-overlay-measurement";
|
|
84
93
|
import { decideSlideEscape } from "./slide-escape-arbiter";
|
|
85
94
|
import {
|
|
95
|
+
applySlideObjectMoveDelta,
|
|
96
|
+
buildPastedSlideObjects,
|
|
86
97
|
clientPointToSlideCoordinates,
|
|
87
98
|
cloneSlideObject,
|
|
99
|
+
collectMovableSlideObjects,
|
|
100
|
+
copySlideObjects,
|
|
101
|
+
computeSlideObjectZOrder,
|
|
88
102
|
createSlidesSelectionState,
|
|
89
103
|
ensureSlideObjectId,
|
|
90
|
-
|
|
104
|
+
ensureSlideTextBoxCanvas,
|
|
91
105
|
findSlideObjectById,
|
|
92
106
|
freezeSlideElementForFreeform,
|
|
93
|
-
|
|
107
|
+
getSlideTextBoxDefaultColor,
|
|
94
108
|
getSlideSelectionIdentity,
|
|
95
109
|
getSlideSelectionMode,
|
|
96
110
|
removeSlideObjectAndLayoutSpacer,
|
|
97
111
|
resolveSlideObjectContainingBlock,
|
|
98
|
-
|
|
112
|
+
type CopiedSlideObjects,
|
|
99
113
|
type ResizeHandle,
|
|
100
114
|
type SlideObjectGeometry,
|
|
115
|
+
type SlideObjectZOrderTarget,
|
|
101
116
|
type SlidesSelectionMode,
|
|
102
117
|
type SlidesSelectionState as BaseSlidesSelectionState,
|
|
103
118
|
type SlidesSelectionTool,
|
|
104
119
|
} from "./slide-object-interactions";
|
|
105
120
|
import { getPassiveSlidePresenceUsers } from "./slide-presence";
|
|
121
|
+
import { SlideOverflowWarning } from "./SlideOverflowWarning";
|
|
106
122
|
import {
|
|
123
|
+
SlideBackgroundInspector,
|
|
107
124
|
SlideStyleInspector,
|
|
108
125
|
type SlideStylePatch,
|
|
109
126
|
type SlideStyleSnapshot,
|
|
@@ -112,35 +129,17 @@ import { SpeakerNotesPanel } from "./SpeakerNotesPanel";
|
|
|
112
129
|
|
|
113
130
|
let builderIdCounter = 0;
|
|
114
131
|
const CANVAS_ZOOM_PRESETS = [10, 25, 50, 75, 100, 125, 150, 200] as const;
|
|
115
|
-
// Keep the text itself a normal click-to-edit surface. Only the slim perimeter
|
|
116
|
-
// is reserved for object movement, matching the familiar Slides interaction.
|
|
117
|
-
const ELEMENT_EDGE_MOVE_BAND = 8;
|
|
118
|
-
|
|
119
|
-
function isWithinElementEdgeMoveBand(
|
|
120
|
-
rect: DOMRect,
|
|
121
|
-
clientX: number,
|
|
122
|
-
clientY: number,
|
|
123
|
-
): boolean {
|
|
124
|
-
// Tiny labels still need a usable center click target. Cap the move band at
|
|
125
|
-
// one quarter of either dimension so its opposite edges never consume it.
|
|
126
|
-
const edgeBand = Math.min(
|
|
127
|
-
ELEMENT_EDGE_MOVE_BAND,
|
|
128
|
-
rect.width / 4,
|
|
129
|
-
rect.height / 4,
|
|
130
|
-
);
|
|
131
|
-
const outerBand = edgeBand / 2;
|
|
132
|
-
const withinExpandedBounds =
|
|
133
|
-
clientX >= rect.left - outerBand &&
|
|
134
|
-
clientX <= rect.right + outerBand &&
|
|
135
|
-
clientY >= rect.top - outerBand &&
|
|
136
|
-
clientY <= rect.bottom + outerBand;
|
|
137
|
-
if (!withinExpandedBounds) return false;
|
|
138
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Object operations are intentionally narrower than ordinary canvas
|
|
135
|
+
* selection. A flow-layout node may be selectable for inspection, but must
|
|
136
|
+
* never be copied, reordered, deleted, or moved as though it were a canvas
|
|
137
|
+
* object.
|
|
138
|
+
*/
|
|
139
|
+
function isPersistedFreeformObject(element: HTMLElement): boolean {
|
|
139
140
|
return (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
Math.abs(clientY - rect.top) <= edgeBand ||
|
|
143
|
-
Math.abs(clientY - rect.bottom) <= edgeBand
|
|
141
|
+
Boolean(element.getAttribute("data-slide-object-id")) &&
|
|
142
|
+
window.getComputedStyle(element).position === "absolute"
|
|
144
143
|
);
|
|
145
144
|
}
|
|
146
145
|
|
|
@@ -440,6 +439,8 @@ function buildStyleSnapshot(
|
|
|
440
439
|
const paddingRight = cssPx(computed.paddingRight);
|
|
441
440
|
const paddingTop = cssPx(computed.paddingTop);
|
|
442
441
|
const paddingBottom = cssPx(computed.paddingBottom);
|
|
442
|
+
const parsedZIndex = Number(computed.zIndex);
|
|
443
|
+
const zIndex = Number.isFinite(parsedZIndex) ? parsedZIndex : 0;
|
|
443
444
|
|
|
444
445
|
const selectedTextStyle =
|
|
445
446
|
inlineTextStyle?.scope === "selection" ? inlineTextStyle : null;
|
|
@@ -477,6 +478,7 @@ function buildStyleSnapshot(
|
|
|
477
478
|
borderColor: normalizedColor(computed.borderTopColor),
|
|
478
479
|
paddingX: Math.round((paddingLeft + paddingRight) / 2),
|
|
479
480
|
paddingY: Math.round((paddingTop + paddingBottom) / 2),
|
|
481
|
+
zIndex,
|
|
480
482
|
textStyleScope: inlineTextStyle?.scope ?? "block",
|
|
481
483
|
mixedTextStyles: selectedTextStyle?.mixed ?? [],
|
|
482
484
|
isAbsolute,
|
|
@@ -556,6 +558,7 @@ interface SlideEditorProps {
|
|
|
556
558
|
onUpdateSlide: (
|
|
557
559
|
updates: Partial<Omit<Slide, "id">>,
|
|
558
560
|
slideIdOverride?: string,
|
|
561
|
+
options?: UpdateSlideOptions,
|
|
559
562
|
) => void;
|
|
560
563
|
/** When true, all inline-edit affordances are disabled — the slide is
|
|
561
564
|
* navigable but contentEditable / image overlays don't activate.
|
|
@@ -781,17 +784,15 @@ function ElementSelectionOutline({
|
|
|
781
784
|
onResizeStart?: (handle: ResizeHandle, e: React.PointerEvent) => void;
|
|
782
785
|
}) {
|
|
783
786
|
const pad = 2;
|
|
784
|
-
const
|
|
785
|
-
const handleClass =
|
|
786
|
-
"absolute size-[7px] touch-none rounded-sm border border-background bg-[#609FF8] shadow-sm";
|
|
787
|
+
const handleClass = "absolute touch-none rounded-sm";
|
|
787
788
|
const edgeHandleClass =
|
|
788
789
|
"absolute flex touch-none items-center justify-center bg-transparent p-0";
|
|
789
|
-
const edgeBarClass =
|
|
790
|
-
"rounded-sm border border-background bg-[#609FF8] shadow-sm";
|
|
790
|
+
const edgeBarClass = "rounded-sm";
|
|
791
791
|
return (
|
|
792
792
|
<SelectionOverlayPortal viewportRect={viewportRect} zIndex={51}>
|
|
793
793
|
<div
|
|
794
794
|
data-slide-selection-outline="true"
|
|
795
|
+
data-slide-selection-chrome="true"
|
|
795
796
|
style={{
|
|
796
797
|
position: "absolute",
|
|
797
798
|
top: rect.top - pad,
|
|
@@ -799,7 +800,7 @@ function ElementSelectionOutline({
|
|
|
799
800
|
width: rect.width + pad * 2,
|
|
800
801
|
height: rect.height + pad * 2,
|
|
801
802
|
pointerEvents: "none",
|
|
802
|
-
border: "
|
|
803
|
+
border: "1px solid #609FF8",
|
|
803
804
|
borderRadius: 3,
|
|
804
805
|
boxShadow: "0 0 0 1px rgba(96, 159, 248, 0.2)",
|
|
805
806
|
}}
|
|
@@ -809,8 +810,6 @@ function ElementSelectionOutline({
|
|
|
809
810
|
onPointerDown={(e) => onResizeStart?.("nw", e)}
|
|
810
811
|
className={handleClass}
|
|
811
812
|
style={{
|
|
812
|
-
left: -handle / 2,
|
|
813
|
-
top: -handle / 2,
|
|
814
813
|
pointerEvents: "auto",
|
|
815
814
|
cursor: "nwse-resize",
|
|
816
815
|
zIndex: 2,
|
|
@@ -821,8 +820,6 @@ function ElementSelectionOutline({
|
|
|
821
820
|
onPointerDown={(e) => onResizeStart?.("ne", e)}
|
|
822
821
|
className={handleClass}
|
|
823
822
|
style={{
|
|
824
|
-
right: -handle / 2,
|
|
825
|
-
top: -handle / 2,
|
|
826
823
|
pointerEvents: "auto",
|
|
827
824
|
cursor: "nesw-resize",
|
|
828
825
|
zIndex: 2,
|
|
@@ -833,8 +830,6 @@ function ElementSelectionOutline({
|
|
|
833
830
|
onPointerDown={(e) => onResizeStart?.("sw", e)}
|
|
834
831
|
className={handleClass}
|
|
835
832
|
style={{
|
|
836
|
-
left: -handle / 2,
|
|
837
|
-
bottom: -handle / 2,
|
|
838
833
|
pointerEvents: "auto",
|
|
839
834
|
cursor: "nesw-resize",
|
|
840
835
|
zIndex: 2,
|
|
@@ -845,8 +840,6 @@ function ElementSelectionOutline({
|
|
|
845
840
|
onPointerDown={(e) => onResizeStart?.("se", e)}
|
|
846
841
|
className={handleClass}
|
|
847
842
|
style={{
|
|
848
|
-
right: -handle / 2,
|
|
849
|
-
bottom: -handle / 2,
|
|
850
843
|
pointerEvents: "auto",
|
|
851
844
|
cursor: "nwse-resize",
|
|
852
845
|
zIndex: 2,
|
|
@@ -857,68 +850,48 @@ function ElementSelectionOutline({
|
|
|
857
850
|
onPointerDown={(e) => onResizeStart?.("n", e)}
|
|
858
851
|
className={edgeHandleClass}
|
|
859
852
|
style={{
|
|
860
|
-
width: 22,
|
|
861
|
-
height: 12,
|
|
862
|
-
left: "50%",
|
|
863
|
-
top: -6,
|
|
864
|
-
transform: "translateX(-50%)",
|
|
865
853
|
pointerEvents: "auto",
|
|
866
854
|
cursor: "ns-resize",
|
|
867
855
|
zIndex: 1,
|
|
868
856
|
}}
|
|
869
857
|
>
|
|
870
|
-
<span className={
|
|
858
|
+
<span data-slide-resize-handle-bar="true" className={edgeBarClass} />
|
|
871
859
|
</span>
|
|
872
860
|
<span
|
|
873
861
|
data-slide-resize-handle="e"
|
|
874
862
|
onPointerDown={(e) => onResizeStart?.("e", e)}
|
|
875
863
|
className={edgeHandleClass}
|
|
876
864
|
style={{
|
|
877
|
-
width: 12,
|
|
878
|
-
height: 22,
|
|
879
|
-
right: -6,
|
|
880
|
-
top: "50%",
|
|
881
|
-
transform: "translateY(-50%)",
|
|
882
865
|
pointerEvents: "auto",
|
|
883
866
|
cursor: "ew-resize",
|
|
884
867
|
zIndex: 1,
|
|
885
868
|
}}
|
|
886
869
|
>
|
|
887
|
-
<span className={
|
|
870
|
+
<span data-slide-resize-handle-bar="true" className={edgeBarClass} />
|
|
888
871
|
</span>
|
|
889
872
|
<span
|
|
890
873
|
data-slide-resize-handle="s"
|
|
891
874
|
onPointerDown={(e) => onResizeStart?.("s", e)}
|
|
892
875
|
className={edgeHandleClass}
|
|
893
876
|
style={{
|
|
894
|
-
width: 22,
|
|
895
|
-
height: 12,
|
|
896
|
-
left: "50%",
|
|
897
|
-
bottom: -6,
|
|
898
|
-
transform: "translateX(-50%)",
|
|
899
877
|
pointerEvents: "auto",
|
|
900
878
|
cursor: "ns-resize",
|
|
901
879
|
zIndex: 1,
|
|
902
880
|
}}
|
|
903
881
|
>
|
|
904
|
-
<span className={
|
|
882
|
+
<span data-slide-resize-handle-bar="true" className={edgeBarClass} />
|
|
905
883
|
</span>
|
|
906
884
|
<span
|
|
907
885
|
data-slide-resize-handle="w"
|
|
908
886
|
onPointerDown={(e) => onResizeStart?.("w", e)}
|
|
909
887
|
className={edgeHandleClass}
|
|
910
888
|
style={{
|
|
911
|
-
width: 12,
|
|
912
|
-
height: 22,
|
|
913
|
-
left: -6,
|
|
914
|
-
top: "50%",
|
|
915
|
-
transform: "translateY(-50%)",
|
|
916
889
|
pointerEvents: "auto",
|
|
917
890
|
cursor: "ew-resize",
|
|
918
891
|
zIndex: 1,
|
|
919
892
|
}}
|
|
920
893
|
>
|
|
921
|
-
<span className={
|
|
894
|
+
<span data-slide-resize-handle-bar="true" className={edgeBarClass} />
|
|
922
895
|
</span>
|
|
923
896
|
</div>
|
|
924
897
|
</SelectionOverlayPortal>
|
|
@@ -1130,17 +1103,53 @@ export default function SlideEditor({
|
|
|
1130
1103
|
const [multiSelectionRects, setMultiSelectionRects] = useState<
|
|
1131
1104
|
Map<string, { rect: DOMRect; text: string; selector: string }>
|
|
1132
1105
|
>(() => new Map());
|
|
1106
|
+
const copiedObjectClipboardRef = useRef<{
|
|
1107
|
+
copied: CopiedSlideObjects;
|
|
1108
|
+
deckId?: string;
|
|
1109
|
+
slideId: string;
|
|
1110
|
+
sourceObjectId: string;
|
|
1111
|
+
} | null>(null);
|
|
1133
1112
|
const [selectedElementPath, setSelectedElementPath] = useState<
|
|
1134
1113
|
number[] | null
|
|
1135
1114
|
>(null);
|
|
1136
1115
|
const [selectedObjectId, setSelectedObjectId] = useState<string | null>(null);
|
|
1116
|
+
const [selectedElementSlideId, setSelectedElementSlideId] = useState<
|
|
1117
|
+
string | null
|
|
1118
|
+
>(null);
|
|
1137
1119
|
const [selectedElementSelector, setSelectedElementSelector] = useState<
|
|
1138
1120
|
string | null
|
|
1139
1121
|
>(null);
|
|
1140
|
-
const [
|
|
1141
|
-
useState<
|
|
1122
|
+
const [selectedElementMeasurement, setSelectedElementMeasurement] =
|
|
1123
|
+
useState<SelectionOverlayMeasurement | null>(null);
|
|
1124
|
+
const [selectionMeasurementRevision, setSelectionMeasurementRevision] =
|
|
1125
|
+
useState(0);
|
|
1126
|
+
const canvasAutofitKey = createSelectionOverlayAutofitKey(slide.id, content);
|
|
1127
|
+
const [settledAutofitKey, setSettledAutofitKey] = useState<string | null>(
|
|
1128
|
+
null,
|
|
1129
|
+
);
|
|
1142
1130
|
const [selectedStyleSnapshot, setSelectedStyleSnapshot] =
|
|
1143
1131
|
useState<SlideStyleSnapshot | null>(null);
|
|
1132
|
+
const selectionOverlayMeasurementKey = createSelectionOverlayMeasurementKey({
|
|
1133
|
+
slideId: slide.id,
|
|
1134
|
+
content,
|
|
1135
|
+
objectId: selectedObjectId,
|
|
1136
|
+
selector: selectedElementSelector,
|
|
1137
|
+
path: selectedElementPath,
|
|
1138
|
+
canvasZoom,
|
|
1139
|
+
stylePanelOpen,
|
|
1140
|
+
revision: selectionMeasurementRevision,
|
|
1141
|
+
});
|
|
1142
|
+
const selectedElementRect = currentSelectionOverlayRect(
|
|
1143
|
+
selectedElementMeasurement,
|
|
1144
|
+
selectionOverlayMeasurementKey,
|
|
1145
|
+
);
|
|
1146
|
+
const invalidateSelectionOverlayMeasurement = useCallback(() => {
|
|
1147
|
+
setSelectedElementMeasurement(null);
|
|
1148
|
+
setSelectionMeasurementRevision((revision) => revision + 1);
|
|
1149
|
+
}, []);
|
|
1150
|
+
const handleAutofitSettled = useCallback(() => {
|
|
1151
|
+
setSettledAutofitKey(canvasAutofitKey);
|
|
1152
|
+
}, [canvasAutofitKey]);
|
|
1144
1153
|
/** Anchor rect for the floating chip (the slide canvas) */
|
|
1145
1154
|
const [chipAnchorRect, setChipAnchorRect] = useState<DOMRect | null>(null);
|
|
1146
1155
|
/** Active marquee rectangle (viewport coords). null = not dragging. */
|
|
@@ -1158,6 +1167,8 @@ export default function SlideEditor({
|
|
|
1158
1167
|
null,
|
|
1159
1168
|
);
|
|
1160
1169
|
const [isAskingAgentToFix, setIsAskingAgentToFix] = useState(false);
|
|
1170
|
+
const [isOverflowWarningDismissed, setIsOverflowWarningDismissed] =
|
|
1171
|
+
useState(false);
|
|
1161
1172
|
const dims = getAspectRatioDims(aspectRatio);
|
|
1162
1173
|
const [fitCanvasZoom, setFitCanvasZoom] = useState(100);
|
|
1163
1174
|
const userSetCanvasZoomRef = useRef(false);
|
|
@@ -1276,6 +1287,7 @@ export default function SlideEditor({
|
|
|
1276
1287
|
useEffect(() => {
|
|
1277
1288
|
setOverflowInfo(null);
|
|
1278
1289
|
setIsAskingAgentToFix(false);
|
|
1290
|
+
setIsOverflowWarningDismissed(false);
|
|
1279
1291
|
syncOverflowToAppState(null);
|
|
1280
1292
|
}, [slide.id, slide.content]);
|
|
1281
1293
|
|
|
@@ -1386,8 +1398,53 @@ export default function SlideEditor({
|
|
|
1386
1398
|
const slideContent = containerRef.current?.querySelector(
|
|
1387
1399
|
".slide-content",
|
|
1388
1400
|
) as HTMLElement | null;
|
|
1389
|
-
|
|
1390
|
-
|
|
1401
|
+
if (!slideContent) return null;
|
|
1402
|
+
// SlideRenderer swaps each `<div class="mermaid">` for a
|
|
1403
|
+
// `data-mermaid-index` placeholder and renders the diagram as SVG via
|
|
1404
|
+
// MermaidRenderer — the live DOM never contains the original mermaid
|
|
1405
|
+
// syntax. Serializing it as-is here would permanently bake the rendered
|
|
1406
|
+
// SVG into slide.content and turn a diagram edited or moved alongside
|
|
1407
|
+
// (e.g. another text block on the same slide) into inert markup that can
|
|
1408
|
+
// never be resized, edited, or re-rendered again. Restore the original
|
|
1409
|
+
// `<div class="mermaid">` markup from slide.content — the untouched
|
|
1410
|
+
// source of truth — before saving.
|
|
1411
|
+
const clone = slideContent.cloneNode(true) as HTMLElement;
|
|
1412
|
+
const placeholders = clone.querySelectorAll("[data-mermaid-index]");
|
|
1413
|
+
// Look up source blocks by index before touching the DOM. If slide.content
|
|
1414
|
+
// changed since this placeholder last rendered (e.g. a concurrent update),
|
|
1415
|
+
// its index may no longer have a matching block — leave that placeholder's
|
|
1416
|
+
// node untouched rather than swapping in a marker with nothing to restore
|
|
1417
|
+
// it, which would otherwise persist as inert marker text.
|
|
1418
|
+
const { blocks } = extractMermaidBlocks(slide.content);
|
|
1419
|
+
// Swap each rendered node for a plain-text marker now, and splice the
|
|
1420
|
+
// real `<div class="mermaid">` markup back in as a raw string AFTER
|
|
1421
|
+
// stripBuilderIds() below. stripBuilderIds round-trips through
|
|
1422
|
+
// DOMParser + innerHTML, which HTML-escapes `>` in text nodes (mangling
|
|
1423
|
+
// `A --> B` into `A --> B`) — the same reason SlideRenderer extracts
|
|
1424
|
+
// mermaid blocks before its own sanitization pass. Doing the real
|
|
1425
|
+
// substitution as a plain string replace, after all DOM round-trips,
|
|
1426
|
+
// avoids that entirely.
|
|
1427
|
+
const nonce = Math.random().toString(36).slice(2);
|
|
1428
|
+
const markerFor = (idx: number) => `__mermaid_${nonce}_${idx}__`;
|
|
1429
|
+
const restorable = new Map<number, string>();
|
|
1430
|
+
placeholders.forEach((placeholder) => {
|
|
1431
|
+
const idx = Number(placeholder.getAttribute("data-mermaid-index"));
|
|
1432
|
+
const definition = blocks[idx];
|
|
1433
|
+
if (definition === undefined) return;
|
|
1434
|
+
restorable.set(idx, definition);
|
|
1435
|
+
placeholder.replaceWith(
|
|
1436
|
+
clone.ownerDocument.createTextNode(markerFor(idx)),
|
|
1437
|
+
);
|
|
1438
|
+
});
|
|
1439
|
+
let html = stripBuilderIds(clone.innerHTML);
|
|
1440
|
+
restorable.forEach((definition, idx) => {
|
|
1441
|
+
html = html.replace(
|
|
1442
|
+
markerFor(idx),
|
|
1443
|
+
`<div class="mermaid">${definition}</div>`,
|
|
1444
|
+
);
|
|
1445
|
+
});
|
|
1446
|
+
return html;
|
|
1447
|
+
}, [slide.content]);
|
|
1391
1448
|
|
|
1392
1449
|
const captureInlineEditDraft = useCallback(
|
|
1393
1450
|
(slideId = slide.id) => {
|
|
@@ -1414,27 +1471,80 @@ export default function SlideEditor({
|
|
|
1414
1471
|
* the rest of the slide does not reflow when Escape selects the block.
|
|
1415
1472
|
*/
|
|
1416
1473
|
const freezeElementForFreeformSelection = useCallback(
|
|
1417
|
-
(
|
|
1474
|
+
(
|
|
1475
|
+
element: HTMLElement,
|
|
1476
|
+
): { element: HTMLElement; restoreMarkdownTree?: () => void } | null => {
|
|
1418
1477
|
if (window.getComputedStyle(element).position === "absolute") {
|
|
1419
1478
|
ensureSlideObjectId(element);
|
|
1420
|
-
return element;
|
|
1479
|
+
return { element };
|
|
1421
1480
|
}
|
|
1422
1481
|
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1482
|
+
// Markdown slides normally render directly into their AutoFit root. Take
|
|
1483
|
+
// the visual snapshot before promotion, then use the same fmd canvas as
|
|
1484
|
+
// manually placed text boxes. Persisting a bare absolute Markdown node
|
|
1485
|
+
// would switch the renderer to raw HTML without its coordinate root.
|
|
1486
|
+
const originalRect = element.getBoundingClientRect();
|
|
1487
|
+
const originalComputed = window.getComputedStyle(element);
|
|
1488
|
+
let fmdSlide = element.closest(".fmd-slide") as HTMLElement | null;
|
|
1489
|
+
let positioningLayer: HTMLElement | null = null;
|
|
1490
|
+
let restoreMarkdownTree: (() => void) | undefined;
|
|
1491
|
+
if (fmdSlide) {
|
|
1492
|
+
positioningLayer =
|
|
1493
|
+
Array.from(fmdSlide.children).find(
|
|
1426
1494
|
(child): child is HTMLElement =>
|
|
1427
1495
|
child instanceof HTMLElement &&
|
|
1428
1496
|
child.hasAttribute("data-fmd-autofit-content"),
|
|
1429
|
-
) ?? fmdSlide
|
|
1430
|
-
|
|
1431
|
-
|
|
1497
|
+
) ?? fmdSlide;
|
|
1498
|
+
} else {
|
|
1499
|
+
const markdownRoot = element.closest(".slide-content");
|
|
1500
|
+
const originalChildren = markdownRoot
|
|
1501
|
+
? Array.from(markdownRoot.childNodes)
|
|
1502
|
+
: [];
|
|
1503
|
+
const originalClassName = element.className;
|
|
1504
|
+
const originalStyle = element.getAttribute("style");
|
|
1505
|
+
const originalContentEditable = element.getAttribute("contenteditable");
|
|
1506
|
+
const originalEditingBlock = element.getAttribute("data-editing-block");
|
|
1507
|
+
const promoted = containerRef.current
|
|
1508
|
+
? ensureSlideTextBoxCanvas(containerRef.current)
|
|
1509
|
+
: null;
|
|
1510
|
+
// Two-column Markdown has independent AutoFit roots. Do not save one
|
|
1511
|
+
// column as raw HTML and silently discard the other one.
|
|
1512
|
+
if (!promoted) return null;
|
|
1513
|
+
fmdSlide = promoted.fmdSlide;
|
|
1514
|
+
positioningLayer = promoted.positioningLayer;
|
|
1515
|
+
// The promotion moves ReactMarkdown's live child nodes. Restore its
|
|
1516
|
+
// original tree after serializing the raw fmd HTML and before the
|
|
1517
|
+
// parent state write, otherwise React tries to delete a child that we
|
|
1518
|
+
// already moved and the Markdown-to-raw rerender can fail.
|
|
1519
|
+
restoreMarkdownTree = () => {
|
|
1520
|
+
if (!markdownRoot) return;
|
|
1521
|
+
for (const child of originalChildren) markdownRoot.append(child);
|
|
1522
|
+
fmdSlide?.remove();
|
|
1523
|
+
element.className = originalClassName;
|
|
1524
|
+
if (originalStyle === null) element.removeAttribute("style");
|
|
1525
|
+
else element.setAttribute("style", originalStyle);
|
|
1526
|
+
if (originalContentEditable === null) {
|
|
1527
|
+
element.removeAttribute("contenteditable");
|
|
1528
|
+
} else {
|
|
1529
|
+
element.setAttribute("contenteditable", originalContentEditable);
|
|
1530
|
+
}
|
|
1531
|
+
if (originalEditingBlock === null) {
|
|
1532
|
+
element.removeAttribute("data-editing-block");
|
|
1533
|
+
} else {
|
|
1534
|
+
element.setAttribute("data-editing-block", originalEditingBlock);
|
|
1535
|
+
}
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// Markdown blocks become persisted raw HTML when Escape makes them
|
|
1540
|
+
// freeform, so their renderer canvas is the initial positioning layer.
|
|
1541
|
+
if (!positioningLayer) return { element };
|
|
1432
1542
|
|
|
1433
1543
|
const containingBlock = resolveSlideObjectContainingBlock(
|
|
1434
1544
|
element,
|
|
1435
1545
|
positioningLayer,
|
|
1436
1546
|
);
|
|
1437
|
-
const elementRect =
|
|
1547
|
+
const elementRect = originalRect;
|
|
1438
1548
|
const layerRect = containingBlock.getBoundingClientRect();
|
|
1439
1549
|
if (
|
|
1440
1550
|
!elementRect.width ||
|
|
@@ -1444,7 +1554,7 @@ export default function SlideEditor({
|
|
|
1444
1554
|
!containingBlock.offsetWidth ||
|
|
1445
1555
|
!containingBlock.offsetHeight
|
|
1446
1556
|
) {
|
|
1447
|
-
return element;
|
|
1557
|
+
return { element, restoreMarkdownTree };
|
|
1448
1558
|
}
|
|
1449
1559
|
|
|
1450
1560
|
const { x, y } = clientPointToSlideCoordinates(
|
|
@@ -1456,7 +1566,6 @@ export default function SlideEditor({
|
|
|
1456
1566
|
);
|
|
1457
1567
|
const scaleX = containingBlock.offsetWidth / layerRect.width;
|
|
1458
1568
|
const scaleY = containingBlock.offsetHeight / layerRect.height;
|
|
1459
|
-
const computed = window.getComputedStyle(element);
|
|
1460
1569
|
freezeSlideElementForFreeform(
|
|
1461
1570
|
element,
|
|
1462
1571
|
{
|
|
@@ -1466,14 +1575,30 @@ export default function SlideEditor({
|
|
|
1466
1575
|
height: Math.round(elementRect.height * scaleY),
|
|
1467
1576
|
},
|
|
1468
1577
|
{
|
|
1469
|
-
display:
|
|
1470
|
-
flexGrow:
|
|
1471
|
-
flexShrink:
|
|
1472
|
-
flexBasis:
|
|
1473
|
-
alignSelf:
|
|
1578
|
+
display: originalComputed.display,
|
|
1579
|
+
flexGrow: originalComputed.flexGrow,
|
|
1580
|
+
flexShrink: originalComputed.flexShrink,
|
|
1581
|
+
flexBasis: originalComputed.flexBasis,
|
|
1582
|
+
alignSelf: originalComputed.alignSelf,
|
|
1583
|
+
},
|
|
1584
|
+
{
|
|
1585
|
+
color: originalComputed.color,
|
|
1586
|
+
direction: originalComputed.direction,
|
|
1587
|
+
fontFamily: originalComputed.fontFamily,
|
|
1588
|
+
fontSize: originalComputed.fontSize,
|
|
1589
|
+
fontStyle: originalComputed.fontStyle,
|
|
1590
|
+
fontWeight: originalComputed.fontWeight,
|
|
1591
|
+
letterSpacing: originalComputed.letterSpacing,
|
|
1592
|
+
lineHeight: originalComputed.lineHeight,
|
|
1593
|
+
textAlign: originalComputed.textAlign,
|
|
1594
|
+
textDecoration: originalComputed.textDecoration,
|
|
1595
|
+
textShadow: originalComputed.textShadow,
|
|
1596
|
+
textTransform: originalComputed.textTransform,
|
|
1597
|
+
whiteSpace: originalComputed.whiteSpace,
|
|
1598
|
+
wordSpacing: originalComputed.wordSpacing,
|
|
1474
1599
|
},
|
|
1475
1600
|
);
|
|
1476
|
-
return element;
|
|
1601
|
+
return { element, restoreMarkdownTree };
|
|
1477
1602
|
},
|
|
1478
1603
|
[],
|
|
1479
1604
|
);
|
|
@@ -1513,11 +1638,33 @@ export default function SlideEditor({
|
|
|
1513
1638
|
richTextSelectionRef.current = null;
|
|
1514
1639
|
setSelectedElementPath(null);
|
|
1515
1640
|
setSelectedObjectId(null);
|
|
1641
|
+
setSelectedElementSlideId(null);
|
|
1516
1642
|
setSelectedElementSelector(null);
|
|
1517
|
-
|
|
1643
|
+
setSelectedElementMeasurement(null);
|
|
1518
1644
|
setSelectedStyleSnapshot(null);
|
|
1519
1645
|
}, []);
|
|
1520
1646
|
|
|
1647
|
+
// `slide.background` paints the canvas wrapper, but a generated `.fmd-slide`
|
|
1648
|
+
// root usually carries its own inline background that covers the whole
|
|
1649
|
+
// canvas. Writing only the field would leave the picker looking broken on
|
|
1650
|
+
// exactly the slides the agent produces, so repaint the root as well when it
|
|
1651
|
+
// declares one.
|
|
1652
|
+
const applySlideBackground = useCallback(
|
|
1653
|
+
(background: string) => {
|
|
1654
|
+
const updates: Partial<Omit<Slide, "id">> = { background };
|
|
1655
|
+
const root = getSlideContent()?.querySelector(
|
|
1656
|
+
".fmd-slide",
|
|
1657
|
+
) as HTMLElement | null;
|
|
1658
|
+
if (root && (root.style.background || root.style.backgroundColor)) {
|
|
1659
|
+
root.style.background = background;
|
|
1660
|
+
const html = readCurrentSlideContentHtml();
|
|
1661
|
+
if (html !== null) updates.content = html;
|
|
1662
|
+
}
|
|
1663
|
+
onUpdateSlideRef.current(updates);
|
|
1664
|
+
},
|
|
1665
|
+
[getSlideContent, readCurrentSlideContentHtml],
|
|
1666
|
+
);
|
|
1667
|
+
|
|
1521
1668
|
const selectElementForStyling = useCallback(
|
|
1522
1669
|
(
|
|
1523
1670
|
element: HTMLElement,
|
|
@@ -1531,8 +1678,12 @@ export default function SlideEditor({
|
|
|
1531
1678
|
const snapshot = buildStyleSnapshot(element, selector);
|
|
1532
1679
|
setSelectedElementPath(path);
|
|
1533
1680
|
setSelectedObjectId(element.getAttribute("data-slide-object-id"));
|
|
1681
|
+
setSelectedElementSlideId(slide.id);
|
|
1534
1682
|
setSelectedElementSelector(selector);
|
|
1535
|
-
|
|
1683
|
+
// Geometry can be invalidated by an async slide-content commit or
|
|
1684
|
+
// AutoFit transform. Reveal the portal only after the layout effect
|
|
1685
|
+
// measures this exact rendered selection.
|
|
1686
|
+
invalidateSelectionOverlayMeasurement();
|
|
1536
1687
|
setSelectedStyleSnapshot(snapshot);
|
|
1537
1688
|
syncSelectionToAppState(
|
|
1538
1689
|
buildSelectionState(getSlideSelectionMode(snapshot, selectionMode), [
|
|
@@ -1540,7 +1691,12 @@ export default function SlideEditor({
|
|
|
1540
1691
|
]),
|
|
1541
1692
|
);
|
|
1542
1693
|
},
|
|
1543
|
-
[
|
|
1694
|
+
[
|
|
1695
|
+
buildSelectionState,
|
|
1696
|
+
getSlideContent,
|
|
1697
|
+
invalidateSelectionOverlayMeasurement,
|
|
1698
|
+
slide.id,
|
|
1699
|
+
],
|
|
1544
1700
|
);
|
|
1545
1701
|
|
|
1546
1702
|
/** Exit edit mode, saving changes to slide.content */
|
|
@@ -1552,18 +1708,26 @@ export default function SlideEditor({
|
|
|
1552
1708
|
el.contentEditable = "false";
|
|
1553
1709
|
el.removeAttribute("data-editing-block");
|
|
1554
1710
|
|
|
1555
|
-
const
|
|
1556
|
-
const
|
|
1711
|
+
const frozen = freezeElementForFreeformSelection(el);
|
|
1712
|
+
const selected = frozen?.element ?? null;
|
|
1713
|
+
const selector = selected ? getBuilderSelector(selected) : null;
|
|
1557
1714
|
|
|
1558
|
-
const html = readCurrentSlideContentHtml();
|
|
1715
|
+
const html = selected ? readCurrentSlideContentHtml() : null;
|
|
1716
|
+
frozen?.restoreMarkdownTree?.();
|
|
1559
1717
|
if (html !== null) {
|
|
1560
1718
|
onUpdateSlideRef.current({ content: html });
|
|
1561
1719
|
}
|
|
1562
1720
|
inlineEditDraftRef.current = null;
|
|
1563
|
-
const
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1721
|
+
const escape = slidesCanvasInteractionCore.escape({
|
|
1722
|
+
editingObjectId: "inline-editing",
|
|
1723
|
+
selectedObjectIds: resolveSelectedElement() ? ["selected"] : [],
|
|
1724
|
+
});
|
|
1725
|
+
setEditingEl(null);
|
|
1726
|
+
if (escape.action === "select-object" && selected && selector) {
|
|
1727
|
+
selectElementForStyling(selected, selector);
|
|
1728
|
+
} else if (escape.action === "clear-selection") {
|
|
1729
|
+
clearSelectedElement();
|
|
1730
|
+
syncSelectionToAppState(null);
|
|
1567
1731
|
} else {
|
|
1568
1732
|
syncSelectionToAppState(null);
|
|
1569
1733
|
}
|
|
@@ -1572,6 +1736,8 @@ export default function SlideEditor({
|
|
|
1572
1736
|
resolveSelectedElement,
|
|
1573
1737
|
selectElementForStyling,
|
|
1574
1738
|
freezeElementForFreeformSelection,
|
|
1739
|
+
clearSelectedElement,
|
|
1740
|
+
syncSelectionToAppState,
|
|
1575
1741
|
]);
|
|
1576
1742
|
|
|
1577
1743
|
/** Enter edit mode on a smart block (text leaf or smart group) */
|
|
@@ -1583,7 +1749,7 @@ export default function SlideEditor({
|
|
|
1583
1749
|
// Keep the inspector selection mounted while text is being edited. The
|
|
1584
1750
|
// inspector is a stable dock, so clearing it here would make the canvas
|
|
1585
1751
|
// resize and auto-fit again on the second click.
|
|
1586
|
-
|
|
1752
|
+
setSelectedElementMeasurement(null);
|
|
1587
1753
|
captureInlineEditDraft(slide.id);
|
|
1588
1754
|
// Mark the deck dirty immediately so SSE/poll refreshes do not replace
|
|
1589
1755
|
// the deck under an active contentEditable edit, even before the user
|
|
@@ -1794,7 +1960,21 @@ export default function SlideEditor({
|
|
|
1794
1960
|
}, [selectedImg]);
|
|
1795
1961
|
|
|
1796
1962
|
useLayoutEffect(() => {
|
|
1797
|
-
if (
|
|
1963
|
+
if (
|
|
1964
|
+
!selectedElementPath ||
|
|
1965
|
+
!selectedElementSelector ||
|
|
1966
|
+
!isSelectionOverlayOnActiveSlide(selectedElementSlideId, slide.id)
|
|
1967
|
+
) {
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
if (
|
|
1971
|
+
!isSelectionOverlayAutofitSettled(settledAutofitKey, canvasAutofitKey)
|
|
1972
|
+
) {
|
|
1973
|
+
// AutoFit changes the element's viewport rect in a rAF. Do not publish
|
|
1974
|
+
// pre-fit coordinates into the portal while the renderer is settling.
|
|
1975
|
+
setSelectedElementMeasurement(null);
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1798
1978
|
let observedElement: HTMLElement | null = null;
|
|
1799
1979
|
const update = () => {
|
|
1800
1980
|
const element = resolveSelectedElement();
|
|
@@ -1815,7 +1995,10 @@ export default function SlideEditor({
|
|
|
1815
1995
|
selectedElementSelector,
|
|
1816
1996
|
inlineTextStyle,
|
|
1817
1997
|
);
|
|
1818
|
-
|
|
1998
|
+
setSelectedElementMeasurement({
|
|
1999
|
+
key: selectionOverlayMeasurementKey,
|
|
2000
|
+
rect: element.getBoundingClientRect(),
|
|
2001
|
+
});
|
|
1819
2002
|
setSelectedStyleSnapshot(snapshot);
|
|
1820
2003
|
syncSelectionToAppState(
|
|
1821
2004
|
buildSelectionState(getSlideSelectionMode(snapshot), [
|
|
@@ -1839,7 +2022,13 @@ export default function SlideEditor({
|
|
|
1839
2022
|
const mutationObserver =
|
|
1840
2023
|
typeof MutationObserver === "undefined" || !positioningLayer
|
|
1841
2024
|
? null
|
|
1842
|
-
: new MutationObserver(
|
|
2025
|
+
: new MutationObserver(() => {
|
|
2026
|
+
// The transform has already changed. Drop the old viewport rect
|
|
2027
|
+
// before publishing a fresh one so portal chrome cannot paint at
|
|
2028
|
+
// the element's pre-AutoFit coordinates.
|
|
2029
|
+
setSelectedElementMeasurement(null);
|
|
2030
|
+
update();
|
|
2031
|
+
});
|
|
1843
2032
|
if (mutationObserver && positioningLayer) {
|
|
1844
2033
|
mutationObserver.observe(positioningLayer, {
|
|
1845
2034
|
attributes: true,
|
|
@@ -1859,7 +2048,11 @@ export default function SlideEditor({
|
|
|
1859
2048
|
clearSelectedElement,
|
|
1860
2049
|
resolveSelectedElement,
|
|
1861
2050
|
selectedElementPath,
|
|
2051
|
+
selectedElementSlideId,
|
|
1862
2052
|
selectedElementSelector,
|
|
2053
|
+
canvasAutofitKey,
|
|
2054
|
+
settledAutofitKey,
|
|
2055
|
+
selectionOverlayMeasurementKey,
|
|
1863
2056
|
slide.content,
|
|
1864
2057
|
]);
|
|
1865
2058
|
|
|
@@ -1944,6 +2137,47 @@ export default function SlideEditor({
|
|
|
1944
2137
|
applyMultiSelection(new Set());
|
|
1945
2138
|
}, [applyMultiSelection, multiSelection.size]);
|
|
1946
2139
|
|
|
2140
|
+
/**
|
|
2141
|
+
* A group move/paste writes new slide content, which replaces the live DOM
|
|
2142
|
+
* (the saved HTML has no data-builder-id attributes — see stripBuilderIds).
|
|
2143
|
+
* `multiSelection` is keyed by those transient ids, so it goes stale the
|
|
2144
|
+
* instant the content commits. Stash the durable data-slide-object-ids here
|
|
2145
|
+
* and re-derive the (freshly stamped) builder ids once the new content has
|
|
2146
|
+
* actually rendered, so the on-canvas selection survives its own commit.
|
|
2147
|
+
*/
|
|
2148
|
+
const pendingMultiSelectionResyncRef = useRef<string[] | null>(null);
|
|
2149
|
+
|
|
2150
|
+
const commitMultiObjectChange = useCallback(
|
|
2151
|
+
(objectIds: string[]) => {
|
|
2152
|
+
pendingMultiSelectionResyncRef.current = objectIds;
|
|
2153
|
+
const html = readCurrentSlideContentHtml();
|
|
2154
|
+
if (html !== null) onUpdateSlideRef.current({ content: html });
|
|
2155
|
+
},
|
|
2156
|
+
[readCurrentSlideContentHtml],
|
|
2157
|
+
);
|
|
2158
|
+
|
|
2159
|
+
useEffect(() => {
|
|
2160
|
+
const objectIds = pendingMultiSelectionResyncRef.current;
|
|
2161
|
+
pendingMultiSelectionResyncRef.current = null;
|
|
2162
|
+
if (!objectIds) {
|
|
2163
|
+
// Undo/redo, agent reconciliation, and external updates replace the DOM
|
|
2164
|
+
// without preserving transient builder ids. Never leave stale ids in a
|
|
2165
|
+
// multi-selection that could later target unrelated newly-stamped nodes.
|
|
2166
|
+
applyMultiSelection(new Set());
|
|
2167
|
+
return;
|
|
2168
|
+
}
|
|
2169
|
+
const slideContent = getSlideContent();
|
|
2170
|
+
if (!slideContent) return;
|
|
2171
|
+
stampBuilderIds(slideContent);
|
|
2172
|
+
const ids = new Set<string>();
|
|
2173
|
+
for (const objectId of objectIds) {
|
|
2174
|
+
const element = findSlideObjectById(slideContent, objectId);
|
|
2175
|
+
const builderId = element?.getAttribute("data-builder-id");
|
|
2176
|
+
if (builderId) ids.add(builderId);
|
|
2177
|
+
}
|
|
2178
|
+
applyMultiSelection(ids);
|
|
2179
|
+
}, [slide.content, getSlideContent, applyMultiSelection]);
|
|
2180
|
+
|
|
1947
2181
|
// One Escape owner for the HTML editor. Radix dialogs/popovers and native
|
|
1948
2182
|
// form controls retain their own Escape behavior before we arbitrate canvas
|
|
1949
2183
|
// state. Gesture cancellation is deliberately ahead of selection clearing.
|
|
@@ -2062,17 +2296,15 @@ export default function SlideEditor({
|
|
|
2062
2296
|
[getPlaceholderTarget],
|
|
2063
2297
|
);
|
|
2064
2298
|
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
if (multiSelection.size === 0) return;
|
|
2068
|
-
const update = () => {
|
|
2299
|
+
const refreshMultiSelectionRects = useCallback(
|
|
2300
|
+
(ids: Set<string>) => {
|
|
2069
2301
|
const slideContent = getSlideContent();
|
|
2070
2302
|
if (!slideContent) return;
|
|
2071
2303
|
const next = new Map<
|
|
2072
2304
|
string,
|
|
2073
2305
|
{ rect: DOMRect; text: string; selector: string }
|
|
2074
2306
|
>();
|
|
2075
|
-
|
|
2307
|
+
ids.forEach((id) => {
|
|
2076
2308
|
const el = slideContent.querySelector(
|
|
2077
2309
|
`[data-builder-id="${id}"]`,
|
|
2078
2310
|
) as HTMLElement | null;
|
|
@@ -2088,6 +2320,17 @@ export default function SlideEditor({
|
|
|
2088
2320
|
".slide-image-clickable",
|
|
2089
2321
|
) as HTMLElement | null;
|
|
2090
2322
|
setChipAnchorRect(canvas?.getBoundingClientRect() || null);
|
|
2323
|
+
},
|
|
2324
|
+
[getSlideContent],
|
|
2325
|
+
);
|
|
2326
|
+
|
|
2327
|
+
// Keep cached rects fresh on scroll/resize so outlines + chip stay aligned.
|
|
2328
|
+
// Group drag calls the same helper every pointer move so its outlines do not
|
|
2329
|
+
// lag behind the objects until the next scroll or resize.
|
|
2330
|
+
useEffect(() => {
|
|
2331
|
+
if (multiSelection.size === 0) return;
|
|
2332
|
+
const update = () => {
|
|
2333
|
+
refreshMultiSelectionRects(multiSelection);
|
|
2091
2334
|
};
|
|
2092
2335
|
window.addEventListener("resize", update);
|
|
2093
2336
|
window.addEventListener("scroll", update, true);
|
|
@@ -2095,10 +2338,10 @@ export default function SlideEditor({
|
|
|
2095
2338
|
window.removeEventListener("resize", update);
|
|
2096
2339
|
window.removeEventListener("scroll", update, true);
|
|
2097
2340
|
};
|
|
2098
|
-
}, [multiSelection,
|
|
2341
|
+
}, [multiSelection, refreshMultiSelectionRects]);
|
|
2099
2342
|
|
|
2100
2343
|
// Clear multi-selection when slide changes (and clear app state too)
|
|
2101
|
-
|
|
2344
|
+
useLayoutEffect(() => {
|
|
2102
2345
|
setMultiSelection(new Set());
|
|
2103
2346
|
setMultiSelectionRects(new Map());
|
|
2104
2347
|
setChipAnchorRect(null);
|
|
@@ -2122,19 +2365,39 @@ export default function SlideEditor({
|
|
|
2122
2365
|
|
|
2123
2366
|
const slideContent = getSlideContent();
|
|
2124
2367
|
if (!slideContent) return;
|
|
2125
|
-
e.preventDefault();
|
|
2126
2368
|
|
|
2127
2369
|
if (multiSelection.size > 0) {
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2370
|
+
const selected = Array.from(multiSelection)
|
|
2371
|
+
.map(
|
|
2372
|
+
(id) =>
|
|
2373
|
+
slideContent.querySelector(
|
|
2374
|
+
`[data-builder-id="${id}"]`,
|
|
2375
|
+
) as HTMLElement | null,
|
|
2376
|
+
)
|
|
2377
|
+
.filter((element): element is HTMLElement => element !== null);
|
|
2378
|
+
const roots = selected.filter(
|
|
2379
|
+
(element) =>
|
|
2380
|
+
!selected.some(
|
|
2381
|
+
(candidate) =>
|
|
2382
|
+
candidate !== element && candidate.contains(element),
|
|
2383
|
+
),
|
|
2384
|
+
);
|
|
2385
|
+
if (
|
|
2386
|
+
roots.length === 0 ||
|
|
2387
|
+
roots.some((element) => !isPersistedFreeformObject(element))
|
|
2388
|
+
) {
|
|
2389
|
+
return;
|
|
2390
|
+
}
|
|
2391
|
+
e.preventDefault();
|
|
2392
|
+
for (const element of roots) {
|
|
2393
|
+
removeSlideObjectAndLayoutSpacer(element);
|
|
2133
2394
|
}
|
|
2134
2395
|
clearMultiSelection();
|
|
2135
2396
|
} else {
|
|
2136
2397
|
const element = resolveSelectedElement();
|
|
2137
|
-
if (element
|
|
2398
|
+
if (!element || !isPersistedFreeformObject(element)) return;
|
|
2399
|
+
e.preventDefault();
|
|
2400
|
+
removeSlideObjectAndLayoutSpacer(element);
|
|
2138
2401
|
clearSelectedElement();
|
|
2139
2402
|
}
|
|
2140
2403
|
|
|
@@ -2203,8 +2466,83 @@ export default function SlideEditor({
|
|
|
2203
2466
|
[],
|
|
2204
2467
|
);
|
|
2205
2468
|
|
|
2206
|
-
|
|
2207
|
-
|
|
2469
|
+
/**
|
|
2470
|
+
* Return the current selection only when every selected root is a persisted
|
|
2471
|
+
* canvas object in one coordinate space. This deliberately refuses mixed
|
|
2472
|
+
* flow/freeform and cross-container selections: pretending their local
|
|
2473
|
+
* left/top values share a coordinate system would silently corrupt layout.
|
|
2474
|
+
*/
|
|
2475
|
+
const getObjectOperationSelection = useCallback(() => {
|
|
2476
|
+
const slideContent = getSlideContent();
|
|
2477
|
+
if (!slideContent) return null;
|
|
2478
|
+
|
|
2479
|
+
const selectedElements =
|
|
2480
|
+
multiSelection.size > 0
|
|
2481
|
+
? Array.from(multiSelection)
|
|
2482
|
+
.map(
|
|
2483
|
+
(id) =>
|
|
2484
|
+
slideContent.querySelector(
|
|
2485
|
+
`[data-builder-id="${id}"]`,
|
|
2486
|
+
) as HTMLElement | null,
|
|
2487
|
+
)
|
|
2488
|
+
.filter((element): element is HTMLElement => element !== null)
|
|
2489
|
+
: (() => {
|
|
2490
|
+
const element = resolveSelectedElement();
|
|
2491
|
+
return element ? [element] : [];
|
|
2492
|
+
})();
|
|
2493
|
+
if (selectedElements.length === 0) return null;
|
|
2494
|
+
|
|
2495
|
+
const roots = selectedElements.filter(
|
|
2496
|
+
(element) =>
|
|
2497
|
+
!selectedElements.some(
|
|
2498
|
+
(candidate) => candidate !== element && candidate.contains(element),
|
|
2499
|
+
),
|
|
2500
|
+
);
|
|
2501
|
+
if (
|
|
2502
|
+
roots.length === 0 ||
|
|
2503
|
+
roots.some((element) => !isPersistedFreeformObject(element))
|
|
2504
|
+
) {
|
|
2505
|
+
return null;
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
const fmdSlide = roots[0].closest(".fmd-slide") as HTMLElement | null;
|
|
2509
|
+
if (
|
|
2510
|
+
!fmdSlide ||
|
|
2511
|
+
roots.some((element) => element.closest(".fmd-slide") !== fmdSlide)
|
|
2512
|
+
) {
|
|
2513
|
+
return null;
|
|
2514
|
+
}
|
|
2515
|
+
const positioningLayer =
|
|
2516
|
+
Array.from(fmdSlide.children).find(
|
|
2517
|
+
(child): child is HTMLElement =>
|
|
2518
|
+
child instanceof HTMLElement &&
|
|
2519
|
+
child.hasAttribute("data-fmd-autofit-content"),
|
|
2520
|
+
) ?? fmdSlide;
|
|
2521
|
+
const containingBlock = resolveSlideObjectContainingBlock(
|
|
2522
|
+
roots[0],
|
|
2523
|
+
positioningLayer,
|
|
2524
|
+
);
|
|
2525
|
+
if (
|
|
2526
|
+
roots.some(
|
|
2527
|
+
(element) =>
|
|
2528
|
+
resolveSlideObjectContainingBlock(element, positioningLayer) !==
|
|
2529
|
+
containingBlock,
|
|
2530
|
+
)
|
|
2531
|
+
) {
|
|
2532
|
+
return null;
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
return { elements: roots, positioningLayer, containingBlock };
|
|
2536
|
+
}, [getSlideContent, multiSelection, resolveSelectedElement]);
|
|
2537
|
+
|
|
2538
|
+
/**
|
|
2539
|
+
* Paste (or duplicate-via-copy+paste) `copied` into the slide, anchored to
|
|
2540
|
+
* the same containing block as `anchorElement` (the object that was
|
|
2541
|
+
* selected when the shortcut fired) rather than always the slide root, so
|
|
2542
|
+
* an object nested inside a positioned group pastes back into that group.
|
|
2543
|
+
*/
|
|
2544
|
+
const pasteSlideObjects = useCallback(
|
|
2545
|
+
(copied: CopiedSlideObjects, anchorElement: HTMLElement) => {
|
|
2208
2546
|
const fmdSlide = containerRef.current?.querySelector(
|
|
2209
2547
|
".fmd-slide",
|
|
2210
2548
|
) as HTMLElement | null;
|
|
@@ -2215,6 +2553,151 @@ export default function SlideEditor({
|
|
|
2215
2553
|
child instanceof HTMLElement &&
|
|
2216
2554
|
child.hasAttribute("data-fmd-autofit-content"),
|
|
2217
2555
|
) ?? fmdSlide;
|
|
2556
|
+
const containingBlock = resolveSlideObjectContainingBlock(
|
|
2557
|
+
anchorElement,
|
|
2558
|
+
positioningLayer,
|
|
2559
|
+
);
|
|
2560
|
+
|
|
2561
|
+
const pasted = buildPastedSlideObjects(copied, document);
|
|
2562
|
+
if (pasted.length === 0) return;
|
|
2563
|
+
|
|
2564
|
+
const objectIds: string[] = [];
|
|
2565
|
+
for (const element of pasted) {
|
|
2566
|
+
containingBlock.appendChild(element);
|
|
2567
|
+
ensureBuilderId(element);
|
|
2568
|
+
stampBuilderIds(element);
|
|
2569
|
+
objectIds.push(ensureSlideObjectId(element));
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
// A single pasted object gets the richer single-selection treatment
|
|
2573
|
+
// (resize handles, style inspector) instead of the multi-select outline.
|
|
2574
|
+
if (pasted.length === 1) {
|
|
2575
|
+
const selector = getBuilderSelector(pasted[0]);
|
|
2576
|
+
if (selector) selectElementForStyling(pasted[0], selector);
|
|
2577
|
+
const html = readCurrentSlideContentHtml();
|
|
2578
|
+
if (html !== null) onUpdateSlideRef.current({ content: html });
|
|
2579
|
+
return;
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
const ids = new Set<string>();
|
|
2583
|
+
for (const element of pasted) {
|
|
2584
|
+
const builderId = element.getAttribute("data-builder-id");
|
|
2585
|
+
if (builderId) ids.add(builderId);
|
|
2586
|
+
}
|
|
2587
|
+
applyMultiSelection(ids);
|
|
2588
|
+
commitMultiObjectChange(objectIds);
|
|
2589
|
+
},
|
|
2590
|
+
[
|
|
2591
|
+
applyMultiSelection,
|
|
2592
|
+
commitMultiObjectChange,
|
|
2593
|
+
readCurrentSlideContentHtml,
|
|
2594
|
+
selectElementForStyling,
|
|
2595
|
+
],
|
|
2596
|
+
);
|
|
2597
|
+
|
|
2598
|
+
// Object clipboard contents are editor-local. It intentionally does not
|
|
2599
|
+
// survive a deck switch, unlike the browser's native text clipboard.
|
|
2600
|
+
useEffect(() => {
|
|
2601
|
+
copiedObjectClipboardRef.current = null;
|
|
2602
|
+
}, [deckId]);
|
|
2603
|
+
|
|
2604
|
+
// One window listener for object copy/paste/duplicate. Native text
|
|
2605
|
+
// copy/paste must always win: bail the instant a text edit is active or
|
|
2606
|
+
// focus is on any form control, BEFORE touching the clipboard or selection,
|
|
2607
|
+
// so ordinary Cmd/Ctrl+C/V/D typing is never hijacked.
|
|
2608
|
+
useEffect(() => {
|
|
2609
|
+
if (readOnly) return;
|
|
2610
|
+
const onKey = (e: KeyboardEvent) => {
|
|
2611
|
+
if (!(e.metaKey || e.ctrlKey)) return;
|
|
2612
|
+
const key = e.key.toLowerCase();
|
|
2613
|
+
if (key !== "c" && key !== "v" && key !== "d") return;
|
|
2614
|
+
|
|
2615
|
+
const active = document.activeElement;
|
|
2616
|
+
const isTextSurface =
|
|
2617
|
+
active instanceof HTMLInputElement ||
|
|
2618
|
+
active instanceof HTMLTextAreaElement ||
|
|
2619
|
+
active instanceof HTMLSelectElement ||
|
|
2620
|
+
(active instanceof HTMLElement && active.isContentEditable);
|
|
2621
|
+
if (editingEl || isTextSurface) return;
|
|
2622
|
+
|
|
2623
|
+
const selection = getObjectOperationSelection();
|
|
2624
|
+
// Only claim the shortcut for persisted freeform roots which share a
|
|
2625
|
+
// containing block. Everything else remains native browser behavior.
|
|
2626
|
+
if (!selection) return;
|
|
2627
|
+
|
|
2628
|
+
const copySelection = () => {
|
|
2629
|
+
const copied = copySlideObjects(selection.elements);
|
|
2630
|
+
const sourceObjectId = selection.elements[0].getAttribute(
|
|
2631
|
+
"data-slide-object-id",
|
|
2632
|
+
);
|
|
2633
|
+
if (!sourceObjectId) return null;
|
|
2634
|
+
copiedObjectClipboardRef.current = {
|
|
2635
|
+
copied,
|
|
2636
|
+
deckId,
|
|
2637
|
+
slideId: slide.id,
|
|
2638
|
+
sourceObjectId,
|
|
2639
|
+
};
|
|
2640
|
+
return copiedObjectClipboardRef.current;
|
|
2641
|
+
};
|
|
2642
|
+
|
|
2643
|
+
if (key === "c") {
|
|
2644
|
+
e.preventDefault();
|
|
2645
|
+
copySelection();
|
|
2646
|
+
return;
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
if (key === "v") {
|
|
2650
|
+
const clipboard = copiedObjectClipboardRef.current;
|
|
2651
|
+
if (
|
|
2652
|
+
!clipboard ||
|
|
2653
|
+
clipboard.deckId !== deckId ||
|
|
2654
|
+
clipboard.slideId !== slide.id
|
|
2655
|
+
) {
|
|
2656
|
+
return;
|
|
2657
|
+
}
|
|
2658
|
+
const slideContent = getSlideContent();
|
|
2659
|
+
const source =
|
|
2660
|
+
slideContent &&
|
|
2661
|
+
findSlideObjectById(slideContent, clipboard.sourceObjectId);
|
|
2662
|
+
if (
|
|
2663
|
+
!source ||
|
|
2664
|
+
resolveSlideObjectContainingBlock(
|
|
2665
|
+
source,
|
|
2666
|
+
selection.positioningLayer,
|
|
2667
|
+
) !== selection.containingBlock
|
|
2668
|
+
) {
|
|
2669
|
+
return;
|
|
2670
|
+
}
|
|
2671
|
+
e.preventDefault();
|
|
2672
|
+
pasteSlideObjects(clipboard.copied, selection.elements[0]);
|
|
2673
|
+
return;
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
// Duplicate re-copies the live selection so it duplicates what's
|
|
2677
|
+
// currently selected regardless of what's on the clipboard.
|
|
2678
|
+
e.preventDefault();
|
|
2679
|
+
const clipboard = copySelection();
|
|
2680
|
+
if (clipboard) pasteSlideObjects(clipboard.copied, selection.elements[0]);
|
|
2681
|
+
};
|
|
2682
|
+
window.addEventListener("keydown", onKey);
|
|
2683
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
2684
|
+
}, [
|
|
2685
|
+
deckId,
|
|
2686
|
+
editingEl,
|
|
2687
|
+
getSlideContent,
|
|
2688
|
+
getObjectOperationSelection,
|
|
2689
|
+
pasteSlideObjects,
|
|
2690
|
+
readOnly,
|
|
2691
|
+
slide.id,
|
|
2692
|
+
]);
|
|
2693
|
+
|
|
2694
|
+
const placeTextBoxAt = useCallback(
|
|
2695
|
+
(clientX: number, clientY: number, target: HTMLElement | null) => {
|
|
2696
|
+
const canvas = containerRef.current
|
|
2697
|
+
? ensureSlideTextBoxCanvas(containerRef.current)
|
|
2698
|
+
: null;
|
|
2699
|
+
if (!canvas) return;
|
|
2700
|
+
const { fmdSlide, positioningLayer } = canvas;
|
|
2218
2701
|
const rect = positioningLayer.getBoundingClientRect();
|
|
2219
2702
|
const { x, y } = clientPointToSlideCoordinates(
|
|
2220
2703
|
clientX,
|
|
@@ -2237,7 +2720,7 @@ export default function SlideEditor({
|
|
|
2237
2720
|
box.style.top = `${y}px`;
|
|
2238
2721
|
box.style.width = "320px";
|
|
2239
2722
|
box.style.fontSize = "24px";
|
|
2240
|
-
box.style.color =
|
|
2723
|
+
box.style.color = getSlideTextBoxDefaultColor(target, positioningLayer);
|
|
2241
2724
|
box.style.fontFamily = "'Poppins', sans-serif";
|
|
2242
2725
|
box.style.lineHeight = "1.3";
|
|
2243
2726
|
box.textContent = ZERO_WIDTH_SPACE;
|
|
@@ -2290,14 +2773,16 @@ export default function SlideEditor({
|
|
|
2290
2773
|
}: { preserveClickWithoutMove?: boolean } = {},
|
|
2291
2774
|
) => {
|
|
2292
2775
|
if (readOnly) return;
|
|
2293
|
-
const
|
|
2294
|
-
|
|
2776
|
+
const slideCanvas = element.closest(
|
|
2777
|
+
".fmd-slide, [data-slide-canvas]",
|
|
2778
|
+
) as HTMLElement | null;
|
|
2779
|
+
if (!slideCanvas || getComputedStyle(element).position !== "absolute") {
|
|
2295
2780
|
return;
|
|
2296
2781
|
}
|
|
2297
2782
|
|
|
2298
|
-
const slideRect =
|
|
2299
|
-
const slideWidth =
|
|
2300
|
-
const slideHeight =
|
|
2783
|
+
const slideRect = slideCanvas.getBoundingClientRect();
|
|
2784
|
+
const slideWidth = slideCanvas.offsetWidth;
|
|
2785
|
+
const slideHeight = slideCanvas.offsetHeight;
|
|
2301
2786
|
if (
|
|
2302
2787
|
!slideRect.width ||
|
|
2303
2788
|
!slideRect.height ||
|
|
@@ -2319,12 +2804,8 @@ export default function SlideEditor({
|
|
|
2319
2804
|
|
|
2320
2805
|
const origin = getObjectGeometry(element);
|
|
2321
2806
|
const originalObjectId = element.getAttribute("data-slide-object-id");
|
|
2322
|
-
const startClientX = e.clientX;
|
|
2323
|
-
const startClientY = e.clientY;
|
|
2324
|
-
const duplicateRequested = e.altKey;
|
|
2325
2807
|
let activeElement = element;
|
|
2326
2808
|
let clone: HTMLElement | null = null;
|
|
2327
|
-
let moved = false;
|
|
2328
2809
|
|
|
2329
2810
|
const stop = () => {
|
|
2330
2811
|
window.removeEventListener("pointermove", onMove);
|
|
@@ -2350,44 +2831,91 @@ export default function SlideEditor({
|
|
|
2350
2831
|
if (selector) selectElementForStyling(element, selector);
|
|
2351
2832
|
};
|
|
2352
2833
|
|
|
2353
|
-
const
|
|
2354
|
-
|
|
2355
|
-
((moveEvent.clientX - startClientX) / slideRect.width) * slideWidth;
|
|
2356
|
-
let dy =
|
|
2357
|
-
((moveEvent.clientY - startClientY) / slideRect.height) * slideHeight;
|
|
2358
|
-
if (!moved && Math.hypot(dx, dy) < 2) return;
|
|
2359
|
-
if (moveEvent.shiftKey) {
|
|
2360
|
-
if (Math.abs(dx) >= Math.abs(dy)) dy = 0;
|
|
2361
|
-
else dx = 0;
|
|
2362
|
-
}
|
|
2363
|
-
if (!moved) {
|
|
2364
|
-
moved = true;
|
|
2834
|
+
const controller = createSlidesCanvasGestureController({
|
|
2835
|
+
preview: (gesture) => {
|
|
2365
2836
|
// React still receives a click after a pointer drag. Suppress that
|
|
2366
2837
|
// trailing click so a moved text box does not immediately reopen
|
|
2367
2838
|
// inline editing.
|
|
2368
2839
|
suppressNextClickRef.current = true;
|
|
2369
2840
|
ensureSlideObjectId(element);
|
|
2370
|
-
if (
|
|
2841
|
+
if (!clone && gesture.duplicate) {
|
|
2371
2842
|
clone = cloneSlideObject(element);
|
|
2372
2843
|
element.after(clone);
|
|
2373
2844
|
ensureBuilderId(clone);
|
|
2374
2845
|
stampBuilderIds(clone);
|
|
2375
2846
|
activeElement = clone;
|
|
2376
2847
|
}
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2848
|
+
applyObjectGeometry(activeElement, {
|
|
2849
|
+
...origin,
|
|
2850
|
+
x: origin.x + gesture.canvasDelta.x,
|
|
2851
|
+
y: origin.y + gesture.canvasDelta.y,
|
|
2852
|
+
});
|
|
2853
|
+
const selector = getBuilderSelector(activeElement);
|
|
2854
|
+
if (selector) selectElementForStyling(activeElement, selector);
|
|
2855
|
+
return { handled: true };
|
|
2856
|
+
},
|
|
2857
|
+
commit: (gesture) => {
|
|
2858
|
+
// Keeping Option pressed is the explicit duplicate commit. If it
|
|
2859
|
+
// was released before drop, turn the gesture back into a normal move.
|
|
2860
|
+
if (clone && !gesture.duplicate) {
|
|
2861
|
+
applyObjectGeometry(element, getObjectGeometry(clone));
|
|
2862
|
+
clone.remove();
|
|
2863
|
+
activeElement = element;
|
|
2864
|
+
}
|
|
2865
|
+
const html = readCurrentSlideContentHtml();
|
|
2866
|
+
if (html !== null) {
|
|
2867
|
+
onUpdateSlideRef.current({ content: html }, undefined, {
|
|
2868
|
+
persistence: "immediate",
|
|
2869
|
+
});
|
|
2870
|
+
}
|
|
2871
|
+
const selector = getBuilderSelector(activeElement);
|
|
2872
|
+
if (selector) selectElementForStyling(activeElement, selector);
|
|
2873
|
+
return { handled: true };
|
|
2874
|
+
},
|
|
2875
|
+
cancel: () => {
|
|
2876
|
+
restore();
|
|
2877
|
+
return { handled: true };
|
|
2878
|
+
},
|
|
2879
|
+
});
|
|
2880
|
+
controller.pointerDown({
|
|
2881
|
+
kind: "move",
|
|
2882
|
+
objectIds: [originalObjectId ?? getBuilderSelector(element) ?? ""],
|
|
2883
|
+
pointer: {
|
|
2884
|
+
x: e.clientX,
|
|
2885
|
+
y: e.clientY,
|
|
2886
|
+
shiftKey: e.shiftKey,
|
|
2887
|
+
altKey: e.altKey,
|
|
2888
|
+
metaKey: e.metaKey,
|
|
2889
|
+
ctrlKey: e.ctrlKey,
|
|
2890
|
+
},
|
|
2891
|
+
viewport: slideRect,
|
|
2892
|
+
canvas: { width: slideWidth, height: slideHeight },
|
|
2893
|
+
});
|
|
2894
|
+
|
|
2895
|
+
const onMove = (moveEvent: PointerEvent) => {
|
|
2896
|
+
const update = controller.pointerMove({
|
|
2897
|
+
x: moveEvent.clientX,
|
|
2898
|
+
y: moveEvent.clientY,
|
|
2899
|
+
shiftKey: moveEvent.shiftKey,
|
|
2900
|
+
altKey: moveEvent.altKey,
|
|
2901
|
+
metaKey: moveEvent.metaKey,
|
|
2902
|
+
ctrlKey: moveEvent.ctrlKey,
|
|
2383
2903
|
});
|
|
2384
|
-
|
|
2385
|
-
|
|
2904
|
+
if (update.phase !== "active") return;
|
|
2905
|
+
moveEvent.preventDefault();
|
|
2386
2906
|
};
|
|
2387
2907
|
|
|
2388
2908
|
const onUp = (upEvent: PointerEvent) => {
|
|
2389
2909
|
stop();
|
|
2390
|
-
|
|
2910
|
+
const result = controller.pointerUp({
|
|
2911
|
+
x: upEvent.clientX,
|
|
2912
|
+
y: upEvent.clientY,
|
|
2913
|
+
shiftKey: upEvent.shiftKey,
|
|
2914
|
+
altKey: upEvent.altKey,
|
|
2915
|
+
metaKey: upEvent.metaKey,
|
|
2916
|
+
ctrlKey: upEvent.ctrlKey,
|
|
2917
|
+
});
|
|
2918
|
+
if (!result.committed) {
|
|
2391
2919
|
if (preserveClickWithoutMove) return;
|
|
2392
2920
|
// Pointer-up can occur outside the canvas, where React will not see
|
|
2393
2921
|
// the click that normally clears this flag.
|
|
@@ -2402,23 +2930,12 @@ export default function SlideEditor({
|
|
|
2402
2930
|
window.setTimeout(function clearDragClickSuppression() {
|
|
2403
2931
|
suppressNextClickRef.current = false;
|
|
2404
2932
|
}, 0);
|
|
2405
|
-
// Keeping Option pressed is the explicit duplicate commit. If it was
|
|
2406
|
-
// released before drop, turn the gesture back into a normal move.
|
|
2407
|
-
if (clone && !upEvent.altKey) {
|
|
2408
|
-
applyObjectGeometry(element, getObjectGeometry(clone));
|
|
2409
|
-
clone.remove();
|
|
2410
|
-
activeElement = element;
|
|
2411
|
-
}
|
|
2412
|
-
const html = readCurrentSlideContentHtml();
|
|
2413
|
-
if (html !== null) onUpdateSlideRef.current({ content: html });
|
|
2414
|
-
const selector = getBuilderSelector(activeElement);
|
|
2415
|
-
if (selector) selectElementForStyling(activeElement, selector);
|
|
2416
2933
|
};
|
|
2417
2934
|
|
|
2418
2935
|
const onCancel = () => {
|
|
2419
2936
|
stop();
|
|
2420
2937
|
suppressNextClickRef.current = false;
|
|
2421
|
-
|
|
2938
|
+
controller.cancel();
|
|
2422
2939
|
};
|
|
2423
2940
|
|
|
2424
2941
|
window.addEventListener("pointermove", onMove);
|
|
@@ -2439,10 +2956,12 @@ export default function SlideEditor({
|
|
|
2439
2956
|
(handle: ResizeHandle, e: React.PointerEvent) => {
|
|
2440
2957
|
if (readOnly) return;
|
|
2441
2958
|
const element = resolveSelectedElement();
|
|
2442
|
-
const
|
|
2959
|
+
const slideCanvas = element?.closest(
|
|
2960
|
+
".fmd-slide, [data-slide-canvas]",
|
|
2961
|
+
) as HTMLElement | null;
|
|
2443
2962
|
if (
|
|
2444
2963
|
!element ||
|
|
2445
|
-
!
|
|
2964
|
+
!slideCanvas ||
|
|
2446
2965
|
getComputedStyle(element).position !== "absolute"
|
|
2447
2966
|
) {
|
|
2448
2967
|
return;
|
|
@@ -2450,9 +2969,9 @@ export default function SlideEditor({
|
|
|
2450
2969
|
e.preventDefault();
|
|
2451
2970
|
e.stopPropagation();
|
|
2452
2971
|
|
|
2453
|
-
const slideRect =
|
|
2454
|
-
const slideWidth =
|
|
2455
|
-
const slideHeight =
|
|
2972
|
+
const slideRect = slideCanvas.getBoundingClientRect();
|
|
2973
|
+
const slideWidth = slideCanvas.offsetWidth;
|
|
2974
|
+
const slideHeight = slideCanvas.offsetHeight;
|
|
2456
2975
|
if (
|
|
2457
2976
|
!slideRect.width ||
|
|
2458
2977
|
!slideRect.height ||
|
|
@@ -2464,9 +2983,6 @@ export default function SlideEditor({
|
|
|
2464
2983
|
|
|
2465
2984
|
const origin = getObjectGeometry(element);
|
|
2466
2985
|
const originalObjectId = element.getAttribute("data-slide-object-id");
|
|
2467
|
-
const startClientX = e.clientX;
|
|
2468
|
-
const startClientY = e.clientY;
|
|
2469
|
-
let resized = false;
|
|
2470
2986
|
const selector = getBuilderSelector(element);
|
|
2471
2987
|
if (selector) selectElementForStyling(element, selector, "resizing");
|
|
2472
2988
|
|
|
@@ -2479,48 +2995,92 @@ export default function SlideEditor({
|
|
|
2479
2995
|
}
|
|
2480
2996
|
};
|
|
2481
2997
|
|
|
2998
|
+
const controller = createSlidesCanvasGestureController({
|
|
2999
|
+
preview: (gesture) => {
|
|
3000
|
+
if (gesture.kind !== "resize")
|
|
3001
|
+
return { handled: false, reason: "unhandled" };
|
|
3002
|
+
ensureSlideObjectId(element);
|
|
3003
|
+
applyObjectGeometry(element, gesture.rect);
|
|
3004
|
+
const currentSelector = getBuilderSelector(element);
|
|
3005
|
+
if (currentSelector) {
|
|
3006
|
+
selectElementForStyling(element, currentSelector, "resizing");
|
|
3007
|
+
}
|
|
3008
|
+
return { handled: true };
|
|
3009
|
+
},
|
|
3010
|
+
commit: () => {
|
|
3011
|
+
const currentSelector = getBuilderSelector(element);
|
|
3012
|
+
if (currentSelector)
|
|
3013
|
+
selectElementForStyling(element, currentSelector);
|
|
3014
|
+
const html = readCurrentSlideContentHtml();
|
|
3015
|
+
if (html !== null) {
|
|
3016
|
+
onUpdateSlideRef.current({ content: html }, undefined, {
|
|
3017
|
+
persistence: "immediate",
|
|
3018
|
+
});
|
|
3019
|
+
}
|
|
3020
|
+
return { handled: true };
|
|
3021
|
+
},
|
|
3022
|
+
cancel: () => {
|
|
3023
|
+
applyObjectGeometry(element, origin);
|
|
3024
|
+
if (originalObjectId) {
|
|
3025
|
+
element.setAttribute("data-slide-object-id", originalObjectId);
|
|
3026
|
+
} else {
|
|
3027
|
+
element.removeAttribute("data-slide-object-id");
|
|
3028
|
+
}
|
|
3029
|
+
const currentSelector = getBuilderSelector(element);
|
|
3030
|
+
if (currentSelector)
|
|
3031
|
+
selectElementForStyling(element, currentSelector);
|
|
3032
|
+
return { handled: true };
|
|
3033
|
+
},
|
|
3034
|
+
});
|
|
3035
|
+
controller.pointerDown({
|
|
3036
|
+
kind: "resize",
|
|
3037
|
+
objectIds: [originalObjectId ?? selector ?? ""],
|
|
3038
|
+
pointer: {
|
|
3039
|
+
x: e.clientX,
|
|
3040
|
+
y: e.clientY,
|
|
3041
|
+
shiftKey: e.shiftKey,
|
|
3042
|
+
altKey: e.altKey,
|
|
3043
|
+
metaKey: e.metaKey,
|
|
3044
|
+
ctrlKey: e.ctrlKey,
|
|
3045
|
+
},
|
|
3046
|
+
viewport: slideRect,
|
|
3047
|
+
canvas: { width: slideWidth, height: slideHeight },
|
|
3048
|
+
handle,
|
|
3049
|
+
rect: origin,
|
|
3050
|
+
});
|
|
3051
|
+
|
|
2482
3052
|
const onMove = (moveEvent: PointerEvent) => {
|
|
2483
|
-
const
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
resizeSlideObject(origin, {
|
|
2493
|
-
handle,
|
|
2494
|
-
dx,
|
|
2495
|
-
dy,
|
|
2496
|
-
preserveAspectRatio: moveEvent.shiftKey,
|
|
2497
|
-
}),
|
|
2498
|
-
);
|
|
2499
|
-
const currentSelector = getBuilderSelector(element);
|
|
2500
|
-
if (currentSelector) {
|
|
2501
|
-
selectElementForStyling(element, currentSelector, "resizing");
|
|
2502
|
-
}
|
|
3053
|
+
const update = controller.pointerMove({
|
|
3054
|
+
x: moveEvent.clientX,
|
|
3055
|
+
y: moveEvent.clientY,
|
|
3056
|
+
shiftKey: moveEvent.shiftKey,
|
|
3057
|
+
altKey: moveEvent.altKey,
|
|
3058
|
+
metaKey: moveEvent.metaKey,
|
|
3059
|
+
ctrlKey: moveEvent.ctrlKey,
|
|
3060
|
+
});
|
|
3061
|
+
if (update.phase === "active") moveEvent.preventDefault();
|
|
2503
3062
|
};
|
|
2504
3063
|
|
|
2505
|
-
const onUp = () => {
|
|
3064
|
+
const onUp = (upEvent: PointerEvent) => {
|
|
2506
3065
|
stop();
|
|
3066
|
+
const result = controller.pointerUp({
|
|
3067
|
+
x: upEvent.clientX,
|
|
3068
|
+
y: upEvent.clientY,
|
|
3069
|
+
shiftKey: upEvent.shiftKey,
|
|
3070
|
+
altKey: upEvent.altKey,
|
|
3071
|
+
metaKey: upEvent.metaKey,
|
|
3072
|
+
ctrlKey: upEvent.ctrlKey,
|
|
3073
|
+
});
|
|
2507
3074
|
const currentSelector = getBuilderSelector(element);
|
|
2508
3075
|
if (currentSelector) selectElementForStyling(element, currentSelector);
|
|
2509
|
-
if (!
|
|
2510
|
-
const html = readCurrentSlideContentHtml();
|
|
2511
|
-
if (html !== null) onUpdateSlideRef.current({ content: html });
|
|
3076
|
+
if (!result.committed) return;
|
|
2512
3077
|
};
|
|
2513
3078
|
|
|
2514
3079
|
const onCancel = () => {
|
|
2515
3080
|
stop();
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
} else {
|
|
2520
|
-
element.removeAttribute("data-slide-object-id");
|
|
2521
|
-
}
|
|
2522
|
-
const selector = getBuilderSelector(element);
|
|
2523
|
-
if (selector) selectElementForStyling(element, selector);
|
|
3081
|
+
controller.cancel();
|
|
3082
|
+
const currentSelector = getBuilderSelector(element);
|
|
3083
|
+
if (currentSelector) selectElementForStyling(element, currentSelector);
|
|
2524
3084
|
};
|
|
2525
3085
|
|
|
2526
3086
|
window.addEventListener("pointermove", onMove);
|
|
@@ -2538,15 +3098,177 @@ export default function SlideEditor({
|
|
|
2538
3098
|
],
|
|
2539
3099
|
);
|
|
2540
3100
|
|
|
3101
|
+
/**
|
|
3102
|
+
* Same gesture shape as startElementDrag (2px threshold, Shift axis lock,
|
|
3103
|
+
* commit-once-on-pointerup, Escape-cancel restore), but moves every
|
|
3104
|
+
* absolutely-positioned member of the multi-selection by one shared delta
|
|
3105
|
+
* instead of a single element.
|
|
3106
|
+
*/
|
|
3107
|
+
const startGroupDrag = useCallback(
|
|
3108
|
+
(e: React.PointerEvent, ids: Set<string>) => {
|
|
3109
|
+
if (readOnly) return;
|
|
3110
|
+
const slideContent = getSlideContent();
|
|
3111
|
+
if (!slideContent) return;
|
|
3112
|
+
const elements = Array.from(ids)
|
|
3113
|
+
.map(
|
|
3114
|
+
(id) =>
|
|
3115
|
+
slideContent.querySelector(
|
|
3116
|
+
`[data-builder-id="${id}"]`,
|
|
3117
|
+
) as HTMLElement | null,
|
|
3118
|
+
)
|
|
3119
|
+
.filter((el): el is HTMLElement => el !== null);
|
|
3120
|
+
if (elements.some((element) => !isPersistedFreeformObject(element))) {
|
|
3121
|
+
return;
|
|
3122
|
+
}
|
|
3123
|
+
const members = collectMovableSlideObjects(elements, getObjectGeometry);
|
|
3124
|
+
if (members.length === 0) return;
|
|
3125
|
+
|
|
3126
|
+
// Resolve the slide from a member, the way startElementDrag does.
|
|
3127
|
+
// `.fmd-slide` is a DESCENDANT of `.slide-content`, so walking up from
|
|
3128
|
+
// the container never finds it and the whole gesture aborts silently.
|
|
3129
|
+
const fmdSlide = members[0].element.closest(
|
|
3130
|
+
".fmd-slide",
|
|
3131
|
+
) as HTMLElement | null;
|
|
3132
|
+
if (!fmdSlide) return;
|
|
3133
|
+
const positioningLayer =
|
|
3134
|
+
Array.from(fmdSlide.children).find(
|
|
3135
|
+
(child): child is HTMLElement =>
|
|
3136
|
+
child instanceof HTMLElement &&
|
|
3137
|
+
child.hasAttribute("data-fmd-autofit-content"),
|
|
3138
|
+
) ?? fmdSlide;
|
|
3139
|
+
const containingBlock = resolveSlideObjectContainingBlock(
|
|
3140
|
+
members[0].element,
|
|
3141
|
+
positioningLayer,
|
|
3142
|
+
);
|
|
3143
|
+
if (
|
|
3144
|
+
members.some(
|
|
3145
|
+
(member) =>
|
|
3146
|
+
member.element.closest(".fmd-slide") !== fmdSlide ||
|
|
3147
|
+
resolveSlideObjectContainingBlock(
|
|
3148
|
+
member.element,
|
|
3149
|
+
positioningLayer,
|
|
3150
|
+
) !== containingBlock,
|
|
3151
|
+
)
|
|
3152
|
+
) {
|
|
3153
|
+
return;
|
|
3154
|
+
}
|
|
3155
|
+
const slideRect = fmdSlide.getBoundingClientRect();
|
|
3156
|
+
const slideWidth = fmdSlide.offsetWidth;
|
|
3157
|
+
const slideHeight = fmdSlide.offsetHeight;
|
|
3158
|
+
if (
|
|
3159
|
+
!slideRect.width ||
|
|
3160
|
+
!slideRect.height ||
|
|
3161
|
+
!slideWidth ||
|
|
3162
|
+
!slideHeight
|
|
3163
|
+
) {
|
|
3164
|
+
return;
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
const stop = () => {
|
|
3168
|
+
window.removeEventListener("pointermove", onMove);
|
|
3169
|
+
window.removeEventListener("pointerup", onUp);
|
|
3170
|
+
window.removeEventListener("pointercancel", onCancel);
|
|
3171
|
+
if (activeGestureCancelRef.current === onCancel) {
|
|
3172
|
+
activeGestureCancelRef.current = null;
|
|
3173
|
+
}
|
|
3174
|
+
};
|
|
3175
|
+
|
|
3176
|
+
const controller = createSlidesCanvasGestureController({
|
|
3177
|
+
preview: (gesture) => {
|
|
3178
|
+
// Claim the click only once a real drag starts, so a click without
|
|
3179
|
+
// movement on a multi-selected object remains normal editor input.
|
|
3180
|
+
suppressNextClickRef.current = true;
|
|
3181
|
+
applySlideObjectMoveDelta(
|
|
3182
|
+
members,
|
|
3183
|
+
gesture.canvasDelta.x,
|
|
3184
|
+
gesture.canvasDelta.y,
|
|
3185
|
+
applyObjectGeometry,
|
|
3186
|
+
);
|
|
3187
|
+
refreshMultiSelectionRects(ids);
|
|
3188
|
+
return { handled: true };
|
|
3189
|
+
},
|
|
3190
|
+
commit: () => {
|
|
3191
|
+
commitMultiObjectChange(members.map((member) => member.objectId));
|
|
3192
|
+
return { handled: true };
|
|
3193
|
+
},
|
|
3194
|
+
cancel: () => {
|
|
3195
|
+
applySlideObjectMoveDelta(members, 0, 0, applyObjectGeometry);
|
|
3196
|
+
refreshMultiSelectionRects(ids);
|
|
3197
|
+
return { handled: true };
|
|
3198
|
+
},
|
|
3199
|
+
});
|
|
3200
|
+
controller.pointerDown({
|
|
3201
|
+
kind: "move",
|
|
3202
|
+
objectIds: members.map((member) => member.objectId),
|
|
3203
|
+
pointer: {
|
|
3204
|
+
x: e.clientX,
|
|
3205
|
+
y: e.clientY,
|
|
3206
|
+
shiftKey: e.shiftKey,
|
|
3207
|
+
altKey: e.altKey,
|
|
3208
|
+
metaKey: e.metaKey,
|
|
3209
|
+
ctrlKey: e.ctrlKey,
|
|
3210
|
+
},
|
|
3211
|
+
viewport: slideRect,
|
|
3212
|
+
canvas: { width: slideWidth, height: slideHeight },
|
|
3213
|
+
});
|
|
3214
|
+
|
|
3215
|
+
const onMove = (moveEvent: PointerEvent) => {
|
|
3216
|
+
const update = controller.pointerMove({
|
|
3217
|
+
x: moveEvent.clientX,
|
|
3218
|
+
y: moveEvent.clientY,
|
|
3219
|
+
shiftKey: moveEvent.shiftKey,
|
|
3220
|
+
altKey: moveEvent.altKey,
|
|
3221
|
+
metaKey: moveEvent.metaKey,
|
|
3222
|
+
ctrlKey: moveEvent.ctrlKey,
|
|
3223
|
+
});
|
|
3224
|
+
if (update.phase === "active") moveEvent.preventDefault();
|
|
3225
|
+
};
|
|
3226
|
+
|
|
3227
|
+
const onUp = (upEvent: PointerEvent) => {
|
|
3228
|
+
stop();
|
|
3229
|
+
const result = controller.pointerUp({
|
|
3230
|
+
x: upEvent.clientX,
|
|
3231
|
+
y: upEvent.clientY,
|
|
3232
|
+
shiftKey: upEvent.shiftKey,
|
|
3233
|
+
altKey: upEvent.altKey,
|
|
3234
|
+
metaKey: upEvent.metaKey,
|
|
3235
|
+
ctrlKey: upEvent.ctrlKey,
|
|
3236
|
+
});
|
|
3237
|
+
if (!result.committed) {
|
|
3238
|
+
window.setTimeout(function clearEdgeClickSuppression() {
|
|
3239
|
+
suppressNextClickRef.current = false;
|
|
3240
|
+
}, 0);
|
|
3241
|
+
return;
|
|
3242
|
+
}
|
|
3243
|
+
window.setTimeout(function clearDragClickSuppression() {
|
|
3244
|
+
suppressNextClickRef.current = false;
|
|
3245
|
+
}, 0);
|
|
3246
|
+
};
|
|
3247
|
+
|
|
3248
|
+
const onCancel = () => {
|
|
3249
|
+
stop();
|
|
3250
|
+
suppressNextClickRef.current = false;
|
|
3251
|
+
controller.cancel();
|
|
3252
|
+
};
|
|
3253
|
+
|
|
3254
|
+
window.addEventListener("pointermove", onMove);
|
|
3255
|
+
window.addEventListener("pointerup", onUp);
|
|
3256
|
+
window.addEventListener("pointercancel", onCancel);
|
|
3257
|
+
activeGestureCancelRef.current = onCancel;
|
|
3258
|
+
},
|
|
3259
|
+
[
|
|
3260
|
+
applyObjectGeometry,
|
|
3261
|
+
commitMultiObjectChange,
|
|
3262
|
+
refreshMultiSelectionRects,
|
|
3263
|
+
getObjectGeometry,
|
|
3264
|
+
getSlideContent,
|
|
3265
|
+
readOnly,
|
|
3266
|
+
],
|
|
3267
|
+
);
|
|
3268
|
+
|
|
2541
3269
|
useEffect(() => {
|
|
2542
|
-
if (
|
|
2543
|
-
|
|
2544
|
-
editingEl ||
|
|
2545
|
-
multiSelection.size > 0 ||
|
|
2546
|
-
!selectedElementSelector
|
|
2547
|
-
) {
|
|
2548
|
-
return;
|
|
2549
|
-
}
|
|
3270
|
+
if (readOnly || editingEl) return;
|
|
3271
|
+
if (multiSelection.size === 0 && !selectedElementSelector) return;
|
|
2550
3272
|
const onKey = (e: KeyboardEvent) => {
|
|
2551
3273
|
if (!e.key.startsWith("Arrow")) return;
|
|
2552
3274
|
const active = document.activeElement;
|
|
@@ -2558,20 +3280,65 @@ export default function SlideEditor({
|
|
|
2558
3280
|
) {
|
|
2559
3281
|
return;
|
|
2560
3282
|
}
|
|
2561
|
-
const
|
|
2562
|
-
if (!
|
|
3283
|
+
const nudge = slidesCanvasInteractionCore.nudge(e);
|
|
3284
|
+
if (!nudge) return;
|
|
3285
|
+
const { x: dx, y: dy } = nudge.delta;
|
|
3286
|
+
|
|
3287
|
+
if (multiSelection.size > 0) {
|
|
3288
|
+
const slideContent = getSlideContent();
|
|
3289
|
+
if (!slideContent) return;
|
|
3290
|
+
const elements = Array.from(multiSelection)
|
|
3291
|
+
.map(
|
|
3292
|
+
(id) =>
|
|
3293
|
+
slideContent.querySelector(
|
|
3294
|
+
`[data-builder-id="${id}"]`,
|
|
3295
|
+
) as HTMLElement | null,
|
|
3296
|
+
)
|
|
3297
|
+
.filter((el): el is HTMLElement => el !== null);
|
|
3298
|
+
if (elements.some((element) => !isPersistedFreeformObject(element))) {
|
|
3299
|
+
return;
|
|
3300
|
+
}
|
|
3301
|
+
const members = collectMovableSlideObjects(elements, getObjectGeometry);
|
|
3302
|
+
if (members.length === 0) return;
|
|
3303
|
+
const fmdSlide = members[0].element.closest(
|
|
3304
|
+
".fmd-slide",
|
|
3305
|
+
) as HTMLElement | null;
|
|
3306
|
+
if (!fmdSlide) return;
|
|
3307
|
+
const positioningLayer =
|
|
3308
|
+
Array.from(fmdSlide.children).find(
|
|
3309
|
+
(child): child is HTMLElement =>
|
|
3310
|
+
child instanceof HTMLElement &&
|
|
3311
|
+
child.hasAttribute("data-fmd-autofit-content"),
|
|
3312
|
+
) ?? fmdSlide;
|
|
3313
|
+
const containingBlock = resolveSlideObjectContainingBlock(
|
|
3314
|
+
members[0].element,
|
|
3315
|
+
positioningLayer,
|
|
3316
|
+
);
|
|
3317
|
+
if (
|
|
3318
|
+
members.some(
|
|
3319
|
+
(member) =>
|
|
3320
|
+
member.element.closest(".fmd-slide") !== fmdSlide ||
|
|
3321
|
+
resolveSlideObjectContainingBlock(
|
|
3322
|
+
member.element,
|
|
3323
|
+
positioningLayer,
|
|
3324
|
+
) !== containingBlock,
|
|
3325
|
+
)
|
|
3326
|
+
) {
|
|
3327
|
+
return;
|
|
3328
|
+
}
|
|
3329
|
+
e.preventDefault();
|
|
3330
|
+
applySlideObjectMoveDelta(members, dx, dy, applyObjectGeometry);
|
|
3331
|
+
refreshMultiSelectionRects(multiSelection);
|
|
3332
|
+
commitMultiObjectChange(members.map((member) => member.objectId));
|
|
2563
3333
|
return;
|
|
2564
3334
|
}
|
|
2565
|
-
const amount = e.shiftKey ? 10 : 1;
|
|
2566
|
-
const geometry = getObjectGeometry(element);
|
|
2567
|
-
if (e.key === "ArrowLeft") geometry.x -= amount;
|
|
2568
|
-
else if (e.key === "ArrowRight") geometry.x += amount;
|
|
2569
|
-
else if (e.key === "ArrowUp") geometry.y -= amount;
|
|
2570
|
-
else if (e.key === "ArrowDown") geometry.y += amount;
|
|
2571
|
-
else return;
|
|
2572
3335
|
|
|
3336
|
+
const element = resolveSelectedElement();
|
|
3337
|
+
if (!element || !isPersistedFreeformObject(element)) return;
|
|
2573
3338
|
e.preventDefault();
|
|
2574
|
-
|
|
3339
|
+
const geometry = getObjectGeometry(element);
|
|
3340
|
+
geometry.x += dx;
|
|
3341
|
+
geometry.y += dy;
|
|
2575
3342
|
applyObjectGeometry(element, geometry);
|
|
2576
3343
|
const html = readCurrentSlideContentHtml();
|
|
2577
3344
|
if (html !== null) onUpdateSlideRef.current({ content: html });
|
|
@@ -2582,11 +3349,14 @@ export default function SlideEditor({
|
|
|
2582
3349
|
return () => window.removeEventListener("keydown", onKey);
|
|
2583
3350
|
}, [
|
|
2584
3351
|
applyObjectGeometry,
|
|
3352
|
+
commitMultiObjectChange,
|
|
2585
3353
|
editingEl,
|
|
2586
3354
|
getObjectGeometry,
|
|
2587
|
-
|
|
3355
|
+
getSlideContent,
|
|
3356
|
+
multiSelection,
|
|
2588
3357
|
readCurrentSlideContentHtml,
|
|
2589
3358
|
readOnly,
|
|
3359
|
+
refreshMultiSelectionRects,
|
|
2590
3360
|
resolveSelectedElement,
|
|
2591
3361
|
selectElementForStyling,
|
|
2592
3362
|
selectedElementSelector,
|
|
@@ -2608,7 +3378,7 @@ export default function SlideEditor({
|
|
|
2608
3378
|
const shouldShowMoveCursor =
|
|
2609
3379
|
selected !== null &&
|
|
2610
3380
|
getComputedStyle(selected).position === "absolute" &&
|
|
2611
|
-
|
|
3381
|
+
isWithinSlidesCanvasEdgeMoveBand(
|
|
2612
3382
|
selected.getBoundingClientRect(),
|
|
2613
3383
|
e.clientX,
|
|
2614
3384
|
e.clientY,
|
|
@@ -2629,7 +3399,7 @@ export default function SlideEditor({
|
|
|
2629
3399
|
if (!slideContent) return;
|
|
2630
3400
|
const target = e.target as HTMLElement;
|
|
2631
3401
|
|
|
2632
|
-
if (textBoxMode
|
|
3402
|
+
if (textBoxMode) {
|
|
2633
3403
|
// Unlike the marquee/select flow below, the text-box tool places a
|
|
2634
3404
|
// box on the very next click no matter what it lands on (mirroring
|
|
2635
3405
|
// Google Slides / PowerPoint). Gating this on "whitespace" left the
|
|
@@ -2639,27 +3409,47 @@ export default function SlideEditor({
|
|
|
2639
3409
|
e.preventDefault();
|
|
2640
3410
|
if (editingEl) exitInlineEdit();
|
|
2641
3411
|
suppressNextClickRef.current = true;
|
|
2642
|
-
placeTextBoxAt(e.clientX, e.clientY);
|
|
3412
|
+
placeTextBoxAt(e.clientX, e.clientY, target);
|
|
2643
3413
|
onExitTextBoxMode?.();
|
|
2644
3414
|
return;
|
|
2645
3415
|
}
|
|
2646
3416
|
if (editingEl) return; // avoid interfering with an active inline edit
|
|
2647
3417
|
|
|
3418
|
+
// Pointer-down on a member of the current multi-selection drags the
|
|
3419
|
+
// whole group instead of the single-object flow below.
|
|
3420
|
+
if (multiSelection.size > 0) {
|
|
3421
|
+
const id = findSelectableId(target, slideContent);
|
|
3422
|
+
if (id && multiSelection.has(id)) {
|
|
3423
|
+
startGroupDrag(e, multiSelection);
|
|
3424
|
+
return;
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3427
|
+
|
|
2648
3428
|
const selected = resolveSelectedElement();
|
|
2649
|
-
const
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
3429
|
+
const pointerIntent = resolveSlidesCanvasPointerIntent({
|
|
3430
|
+
hasSelectedObject:
|
|
3431
|
+
selected !== null &&
|
|
3432
|
+
getComputedStyle(selected).position === "absolute",
|
|
3433
|
+
targetWithinSelectedObject: selected?.contains(target) ?? false,
|
|
3434
|
+
targetContainsSelectedObject: selected
|
|
3435
|
+
? target.contains(selected)
|
|
3436
|
+
: false,
|
|
3437
|
+
pointerWithinMoveBand:
|
|
3438
|
+
selected !== null &&
|
|
3439
|
+
isWithinSlidesCanvasEdgeMoveBand(
|
|
3440
|
+
selected.getBoundingClientRect(),
|
|
3441
|
+
e.clientX,
|
|
3442
|
+
e.clientY,
|
|
3443
|
+
),
|
|
3444
|
+
targetIsEditableText: Boolean(findSmartBlock(target, slideContent)),
|
|
3445
|
+
});
|
|
3446
|
+
if (
|
|
3447
|
+
selected &&
|
|
3448
|
+
(pointerIntent === "move-object-body" ||
|
|
3449
|
+
pointerIntent === "move-object-perimeter")
|
|
3450
|
+
) {
|
|
2661
3451
|
startElementDrag(e, selected, {
|
|
2662
|
-
preserveClickWithoutMove:
|
|
3452
|
+
preserveClickWithoutMove: pointerIntent === "move-object-body",
|
|
2663
3453
|
});
|
|
2664
3454
|
return;
|
|
2665
3455
|
}
|
|
@@ -2687,19 +3477,19 @@ export default function SlideEditor({
|
|
|
2687
3477
|
},
|
|
2688
3478
|
[
|
|
2689
3479
|
editingEl,
|
|
3480
|
+
findSelectableId,
|
|
2690
3481
|
getSlideContent,
|
|
2691
3482
|
isSlideWhitespaceTarget,
|
|
2692
3483
|
multiSelection,
|
|
2693
3484
|
applyMultiSelection,
|
|
2694
3485
|
clearSelectedElement,
|
|
2695
3486
|
textBoxMode,
|
|
2696
|
-
isHtmlSlide,
|
|
2697
3487
|
exitInlineEdit,
|
|
2698
3488
|
placeTextBoxAt,
|
|
2699
3489
|
onExitTextBoxMode,
|
|
2700
|
-
getSelectedObjectDragStart,
|
|
2701
3490
|
resolveSelectedElement,
|
|
2702
3491
|
startElementDrag,
|
|
3492
|
+
startGroupDrag,
|
|
2703
3493
|
],
|
|
2704
3494
|
);
|
|
2705
3495
|
|
|
@@ -2938,9 +3728,15 @@ export default function SlideEditor({
|
|
|
2938
3728
|
// leaf, or an entire bullet list) — not the individual line — so typing,
|
|
2939
3729
|
// highlighting, shortcuts, and Enter-to-add-bullet all work, and the
|
|
2940
3730
|
// style dock targets the same block being edited.
|
|
2941
|
-
if (!readOnly &&
|
|
3731
|
+
if (!readOnly && slideContent) {
|
|
2942
3732
|
const block = findSmartBlock(target, slideContent);
|
|
2943
|
-
if (
|
|
3733
|
+
if (
|
|
3734
|
+
block &&
|
|
3735
|
+
slidesCanvasInteractionCore.textActivation({
|
|
3736
|
+
clickCount: 1,
|
|
3737
|
+
textEditable: true,
|
|
3738
|
+
}) === "edit"
|
|
3739
|
+
) {
|
|
2944
3740
|
const blockSelector = getBuilderSelector(block);
|
|
2945
3741
|
if (blockSelector) {
|
|
2946
3742
|
selectElementForStyling(block, blockSelector);
|
|
@@ -3082,7 +3878,7 @@ export default function SlideEditor({
|
|
|
3082
3878
|
inlineTextStyle,
|
|
3083
3879
|
);
|
|
3084
3880
|
if (!editing) {
|
|
3085
|
-
|
|
3881
|
+
invalidateSelectionOverlayMeasurement();
|
|
3086
3882
|
}
|
|
3087
3883
|
setSelectedStyleSnapshot(snapshot);
|
|
3088
3884
|
if (!editing) {
|
|
@@ -3096,6 +3892,7 @@ export default function SlideEditor({
|
|
|
3096
3892
|
[
|
|
3097
3893
|
buildSelectionState,
|
|
3098
3894
|
captureInlineEditDraft,
|
|
3895
|
+
invalidateSelectionOverlayMeasurement,
|
|
3099
3896
|
readCurrentSlideContentHtml,
|
|
3100
3897
|
resolveSelectedElement,
|
|
3101
3898
|
selectedElementSelector,
|
|
@@ -3103,6 +3900,45 @@ export default function SlideEditor({
|
|
|
3103
3900
|
],
|
|
3104
3901
|
);
|
|
3105
3902
|
|
|
3903
|
+
/** Bring-to-front / send-to-back for the selected freeform object. */
|
|
3904
|
+
const handleArrangeSelected = useCallback(
|
|
3905
|
+
(target: SlideObjectZOrderTarget) => {
|
|
3906
|
+
const element = resolveSelectedElement();
|
|
3907
|
+
if (!element) return;
|
|
3908
|
+
const fmdSlide = element.closest(".fmd-slide") as HTMLElement | null;
|
|
3909
|
+
const positioningLayer = fmdSlide
|
|
3910
|
+
? (Array.from(fmdSlide.children).find(
|
|
3911
|
+
(child): child is HTMLElement =>
|
|
3912
|
+
child instanceof HTMLElement &&
|
|
3913
|
+
child.hasAttribute("data-fmd-autofit-content"),
|
|
3914
|
+
) ?? fmdSlide)
|
|
3915
|
+
: null;
|
|
3916
|
+
if (!positioningLayer) return;
|
|
3917
|
+
|
|
3918
|
+
const containingBlock = resolveSlideObjectContainingBlock(
|
|
3919
|
+
element,
|
|
3920
|
+
positioningLayer,
|
|
3921
|
+
);
|
|
3922
|
+
const change = computeSlideObjectZOrder(element, containingBlock, target);
|
|
3923
|
+
if (!change) return;
|
|
3924
|
+
|
|
3925
|
+
element.style.zIndex = String(change.value);
|
|
3926
|
+
for (const shift of change.shiftPeers) {
|
|
3927
|
+
shift.element.style.zIndex = String(shift.value);
|
|
3928
|
+
}
|
|
3929
|
+
|
|
3930
|
+
const html = readCurrentSlideContentHtml();
|
|
3931
|
+
if (html !== null) onUpdateSlideRef.current({ content: html });
|
|
3932
|
+
const selector = getBuilderSelector(element);
|
|
3933
|
+
if (selector) selectElementForStyling(element, selector);
|
|
3934
|
+
},
|
|
3935
|
+
[
|
|
3936
|
+
readCurrentSlideContentHtml,
|
|
3937
|
+
resolveSelectedElement,
|
|
3938
|
+
selectElementForStyling,
|
|
3939
|
+
],
|
|
3940
|
+
);
|
|
3941
|
+
|
|
3106
3942
|
// --- Pending visual updates ---
|
|
3107
3943
|
const [pendingUpdateCount, setPendingUpdateCount] = useState(0);
|
|
3108
3944
|
|
|
@@ -3167,7 +4003,6 @@ export default function SlideEditor({
|
|
|
3167
4003
|
const isSelectedElementDraggable = selectedForDrag
|
|
3168
4004
|
? getComputedStyle(selectedForDrag).position === "absolute"
|
|
3169
4005
|
: false;
|
|
3170
|
-
|
|
3171
4006
|
return (
|
|
3172
4007
|
<div
|
|
3173
4008
|
className="relative flex h-full min-h-0 flex-1 flex-col overflow-hidden"
|
|
@@ -3272,6 +4107,7 @@ export default function SlideEditor({
|
|
|
3272
4107
|
designSystem={designSystem}
|
|
3273
4108
|
aspectRatio={aspectRatio}
|
|
3274
4109
|
onOverflowChange={handleOverflowChange}
|
|
4110
|
+
onAutofitSettled={handleAutofitSettled}
|
|
3275
4111
|
/>
|
|
3276
4112
|
{/* Fading "AI edited" ring around the canvas when the
|
|
3277
4113
|
agent just edited THIS slide (component handles fade). */}
|
|
@@ -3292,27 +4128,20 @@ export default function SlideEditor({
|
|
|
3292
4128
|
)}
|
|
3293
4129
|
</div>
|
|
3294
4130
|
)}
|
|
3295
|
-
{overflowInfo &&
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
4131
|
+
{overflowInfo &&
|
|
4132
|
+
!readOnly &&
|
|
4133
|
+
!agentActive &&
|
|
4134
|
+
!isOverflowWarningDismissed && (
|
|
4135
|
+
<SlideOverflowWarning
|
|
4136
|
+
verticalOverflow={overflowInfo.verticalOverflow}
|
|
4137
|
+
isAskingAgentToFix={isAskingAgentToFix}
|
|
4138
|
+
dismissLabel={t("deckEditor.dismissLayoutWarning")}
|
|
4139
|
+
onFix={handleAskAgentToFixLayout}
|
|
4140
|
+
onDismiss={() =>
|
|
4141
|
+
setIsOverflowWarningDismissed(true)
|
|
4142
|
+
}
|
|
3300
4143
|
/>
|
|
3301
|
-
|
|
3302
|
-
Layout overflows by {overflowInfo.verticalOverflow}
|
|
3303
|
-
px
|
|
3304
|
-
</span>
|
|
3305
|
-
<Button
|
|
3306
|
-
size="sm"
|
|
3307
|
-
variant="ghost"
|
|
3308
|
-
className="ml-1 h-6 cursor-pointer px-2 text-[11px] font-medium text-amber-100 hover:bg-amber-500/20 hover:text-white"
|
|
3309
|
-
onClick={handleAskAgentToFixLayout}
|
|
3310
|
-
disabled={isAskingAgentToFix}
|
|
3311
|
-
>
|
|
3312
|
-
{isAskingAgentToFix ? "Asking…" : "Fix with AI"}
|
|
3313
|
-
</Button>
|
|
3314
|
-
</div>
|
|
3315
|
-
)}
|
|
4144
|
+
)}
|
|
3316
4145
|
</div>
|
|
3317
4146
|
</div>
|
|
3318
4147
|
</div>
|
|
@@ -3333,6 +4162,7 @@ export default function SlideEditor({
|
|
|
3333
4162
|
designSystem={designSystem}
|
|
3334
4163
|
className="h-full w-full rounded-none border-0 bg-transparent shadow-none"
|
|
3335
4164
|
onChange={applySelectedStylePatch}
|
|
4165
|
+
onArrange={handleArrangeSelected}
|
|
3336
4166
|
onClose={() => {
|
|
3337
4167
|
clearSelectedElement();
|
|
3338
4168
|
syncSelectionToAppState(null);
|
|
@@ -3340,26 +4170,13 @@ export default function SlideEditor({
|
|
|
3340
4170
|
}}
|
|
3341
4171
|
/>
|
|
3342
4172
|
) : (
|
|
3343
|
-
<
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
type="button"
|
|
3351
|
-
variant="ghost"
|
|
3352
|
-
size="icon"
|
|
3353
|
-
className="size-7 cursor-pointer text-muted-foreground hover:text-foreground"
|
|
3354
|
-
onClick={onCloseStylePanel}
|
|
3355
|
-
aria-label={t("styleInspector.close")}
|
|
3356
|
-
>
|
|
3357
|
-
<IconX className="size-3.5" />
|
|
3358
|
-
</Button>
|
|
3359
|
-
</TooltipTrigger>
|
|
3360
|
-
<TooltipContent>{t("styleInspector.close")}</TooltipContent>
|
|
3361
|
-
</Tooltip>
|
|
3362
|
-
</div>
|
|
4173
|
+
<SlideBackgroundInspector
|
|
4174
|
+
background={slide.background}
|
|
4175
|
+
designSystem={designSystem}
|
|
4176
|
+
className="h-full w-full rounded-none border-0 bg-transparent shadow-none"
|
|
4177
|
+
onChange={applySlideBackground}
|
|
4178
|
+
onClose={onCloseStylePanel}
|
|
4179
|
+
/>
|
|
3363
4180
|
)}
|
|
3364
4181
|
</div>
|
|
3365
4182
|
)}
|
|
@@ -3370,6 +4187,7 @@ export default function SlideEditor({
|
|
|
3370
4187
|
onChange={(notes) => onUpdateSlide({ notes })}
|
|
3371
4188
|
slideIndex={slideIndex}
|
|
3372
4189
|
slideCount={slideCount}
|
|
4190
|
+
readOnly={readOnly}
|
|
3373
4191
|
/>
|
|
3374
4192
|
|
|
3375
4193
|
{selectionRect && (
|