auricui-skill 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (362) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +29 -2
  3. package/bin/install.mjs +66 -0
  4. package/package.json +29 -4
  5. package/skill/SKILL.md +64 -0
  6. package/skill/agents/openai.yaml +4 -0
  7. package/skill/assets/examples/ApprovalCard.tsx +8 -0
  8. package/skill/assets/examples/BasicApp.tsx +38 -0
  9. package/skill/assets/examples/BriefingCard.tsx +8 -0
  10. package/skill/assets/examples/ChatControls.tsx +49 -0
  11. package/skill/assets/examples/ChatPanel.tsx +73 -0
  12. package/skill/assets/examples/Checkbox.tsx +13 -0
  13. package/skill/assets/examples/Combobox.tsx +14 -0
  14. package/skill/assets/examples/Drawer.tsx +10 -0
  15. package/skill/assets/examples/MemoryCard.tsx +9 -0
  16. package/skill/assets/examples/MessageList.tsx +8 -0
  17. package/skill/assets/examples/Theme.tsx +14 -0
  18. package/skill/assets/examples/ToolCall.tsx +8 -0
  19. package/skill/assets/examples/Tree.tsx +15 -0
  20. package/skill/assets/examples/examples.css +11 -0
  21. package/skill/assets/library/auricui-0.1.0-preview.1.tgz +0 -0
  22. package/skill/assets/library/auricui-0.1.0-preview.3.tgz +0 -0
  23. package/skill/assets/library/auricui-0.1.0-preview.4.tgz +0 -0
  24. package/skill/assets/upstream/LICENSE +21 -0
  25. package/skill/assets/upstream/apps/desktop/chat/src/App.tsx +1099 -0
  26. package/skill/assets/upstream/apps/desktop/chat/src/app-bridge.ts +154 -0
  27. package/skill/assets/upstream/apps/desktop/chat/src/chat.css +1812 -0
  28. package/skill/assets/upstream/apps/desktop/chat/src/components/AddSkill.tsx +223 -0
  29. package/skill/assets/upstream/apps/desktop/chat/src/components/AgentActivityRow.tsx +716 -0
  30. package/skill/assets/upstream/apps/desktop/chat/src/components/AgentIcons.tsx +46 -0
  31. package/skill/assets/upstream/apps/desktop/chat/src/components/AttachmentCard.tsx +34 -0
  32. package/skill/assets/upstream/apps/desktop/chat/src/components/AttachmentPreview.tsx +331 -0
  33. package/skill/assets/upstream/apps/desktop/chat/src/components/BalanceNotice.tsx +22 -0
  34. package/skill/assets/upstream/apps/desktop/chat/src/components/BrandLogo.tsx +35 -0
  35. package/skill/assets/upstream/apps/desktop/chat/src/components/ComposerCard.tsx +182 -0
  36. package/skill/assets/upstream/apps/desktop/chat/src/components/ConversationSessionBar.tsx +292 -0
  37. package/skill/assets/upstream/apps/desktop/chat/src/components/CopyDiagnostics.tsx +24 -0
  38. package/skill/assets/upstream/apps/desktop/chat/src/components/Divider.tsx +47 -0
  39. package/skill/assets/upstream/apps/desktop/chat/src/components/FileChangesList.tsx +119 -0
  40. package/skill/assets/upstream/apps/desktop/chat/src/components/Markdown.tsx +198 -0
  41. package/skill/assets/upstream/apps/desktop/chat/src/components/ModelPopover.tsx +280 -0
  42. package/skill/assets/upstream/apps/desktop/chat/src/components/NoticeCard.tsx +87 -0
  43. package/skill/assets/upstream/apps/desktop/chat/src/components/Popover.tsx +195 -0
  44. package/skill/assets/upstream/apps/desktop/chat/src/components/PreviewFrame.tsx +129 -0
  45. package/skill/assets/upstream/apps/desktop/chat/src/components/ProjectConversation.tsx +852 -0
  46. package/skill/assets/upstream/apps/desktop/chat/src/components/ProjectStarters.tsx +118 -0
  47. package/skill/assets/upstream/apps/desktop/chat/src/components/Tooltip.tsx +191 -0
  48. package/skill/assets/upstream/apps/desktop/chat/src/components/TurnLog.tsx +583 -0
  49. package/skill/assets/upstream/apps/desktop/chat/src/components/TurnQuestionCard.tsx +133 -0
  50. package/skill/assets/upstream/apps/desktop/chat/src/components/dock-pane-bar.ts +14 -0
  51. package/skill/assets/upstream/apps/desktop/chat/src/components/project-version.ts +37 -0
  52. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/MentionMenuPlugin.tsx +372 -0
  53. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/PromptEditor.tsx +476 -0
  54. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/PromptPill.tsx +399 -0
  55. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/PromptReferenceNode.tsx +164 -0
  56. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/index.ts +31 -0
  57. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/mention-menu.ts +206 -0
  58. package/skill/assets/upstream/apps/desktop/chat/src/components/prompt-editor/prompt-text.ts +171 -0
  59. package/skill/assets/upstream/apps/desktop/chat/src/components/useStickToBottom.ts +320 -0
  60. package/skill/assets/upstream/apps/desktop/chat/src/components/useTicker.ts +55 -0
  61. package/skill/assets/upstream/apps/desktop/chat/src/i18n/index.ts +42 -0
  62. package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/en-US.ts +408 -0
  63. package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/es.ts +407 -0
  64. package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/ja.ts +407 -0
  65. package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/ko.ts +403 -0
  66. package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/words.ts +6 -0
  67. package/skill/assets/upstream/apps/desktop/chat/src/i18n/locales/zh-CN.ts +407 -0
  68. package/skill/assets/upstream/apps/desktop/chat/src/i18n/lookup.ts +12 -0
  69. package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-activity-runs.ts +154 -0
  70. package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-menu-rows.ts +41 -0
  71. package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-refs.ts +105 -0
  72. package/skill/assets/upstream/apps/desktop/chat/src/lib/agent-transcript.ts +440 -0
  73. package/skill/assets/upstream/apps/desktop/chat/src/lib/asset-lists.ts +75 -0
  74. package/skill/assets/upstream/apps/desktop/chat/src/lib/attachment-place.ts +17 -0
  75. package/skill/assets/upstream/apps/desktop/chat/src/lib/balance.ts +16 -0
  76. package/skill/assets/upstream/apps/desktop/chat/src/lib/chat-layer.ts +10 -0
  77. package/skill/assets/upstream/apps/desktop/chat/src/lib/chat-menu.ts +18 -0
  78. package/skill/assets/upstream/apps/desktop/chat/src/lib/chat-store.ts +101 -0
  79. package/skill/assets/upstream/apps/desktop/chat/src/lib/composer-attachments.ts +189 -0
  80. package/skill/assets/upstream/apps/desktop/chat/src/lib/desktop-attachments.ts +178 -0
  81. package/skill/assets/upstream/apps/desktop/chat/src/lib/desktop-bridge.ts +291 -0
  82. package/skill/assets/upstream/apps/desktop/chat/src/lib/developer.ts +39 -0
  83. package/skill/assets/upstream/apps/desktop/chat/src/lib/esc-stop.ts +23 -0
  84. package/skill/assets/upstream/apps/desktop/chat/src/lib/file-changes.ts +32 -0
  85. package/skill/assets/upstream/apps/desktop/chat/src/lib/live-activity.ts +20 -0
  86. package/skill/assets/upstream/apps/desktop/chat/src/lib/local-agent-run.ts +1174 -0
  87. package/skill/assets/upstream/apps/desktop/chat/src/lib/local-sessions.ts +25 -0
  88. package/skill/assets/upstream/apps/desktop/chat/src/lib/model-calls.ts +87 -0
  89. package/skill/assets/upstream/apps/desktop/chat/src/lib/pretty-json.ts +12 -0
  90. package/skill/assets/upstream/apps/desktop/chat/src/lib/preview-top.ts +15 -0
  91. package/skill/assets/upstream/apps/desktop/chat/src/lib/project-resources.ts +66 -0
  92. package/skill/assets/upstream/apps/desktop/chat/src/lib/project-sessions.ts +66 -0
  93. package/skill/assets/upstream/apps/desktop/chat/src/lib/project-versions.ts +116 -0
  94. package/skill/assets/upstream/apps/desktop/chat/src/lib/ref-pictures.ts +119 -0
  95. package/skill/assets/upstream/apps/desktop/chat/src/lib/relative-time.ts +46 -0
  96. package/skill/assets/upstream/apps/desktop/chat/src/lib/skill-pages.ts +29 -0
  97. package/skill/assets/upstream/apps/desktop/chat/src/lib/step-file.ts +23 -0
  98. package/skill/assets/upstream/apps/desktop/chat/src/lib/structural-share.ts +52 -0
  99. package/skill/assets/upstream/apps/desktop/chat/src/lib/studio-film.ts +44 -0
  100. package/skill/assets/upstream/apps/desktop/chat/src/lib/studio-transfer.ts +40 -0
  101. package/skill/assets/upstream/apps/desktop/chat/src/lib/unprompted.ts +27 -0
  102. package/skill/assets/upstream/apps/desktop/chat/src/lib/upload-limits.ts +53 -0
  103. package/skill/assets/upstream/apps/desktop/chat/src/mount.tsx +19 -0
  104. package/skill/assets/upstream/apps/desktop/chat/src/state/local-agent-store.ts +296 -0
  105. package/skill/assets/upstream/apps/desktop/chat/src/studio-refs.ts +307 -0
  106. package/skill/assets/upstream/apps/desktop/package.json +51 -0
  107. package/skill/assets/upstream/apps/desktop/shell/bridge.ts +415 -0
  108. package/skill/assets/upstream/apps/desktop/shell/index.html +27 -0
  109. package/skill/assets/upstream/apps/desktop/shell/main.tsx +17 -0
  110. package/skill/assets/upstream/apps/desktop/shell/shell.css +17 -0
  111. package/skill/assets/upstream/apps/desktop/shell/vite.config.ts +65 -0
  112. package/skill/assets/upstream/apps/desktop/src/agent-definitions.mjs +31 -0
  113. package/skill/assets/upstream/packages/openfilm/LICENSE +21 -0
  114. package/skill/assets/upstream/packages/openfilm/package.json +88 -0
  115. package/skill/assets/upstream/packages/openfilm/src/film-doc.d.mts +125 -0
  116. package/skill/assets/upstream/packages/openfilm/src/film-doc.mjs +986 -0
  117. package/skill/assets/upstream/packages/openfilm/src/overrides.mjs +73 -0
  118. package/skill/assets/upstream/packages/openfilm/src/timeline.mjs +1280 -0
  119. package/skill/assets/upstream/packages/openfilm/studio/server/bridge.js +209 -0
  120. package/skill/assets/upstream/packages/openfilm/studio/server/film-subtitle.mjs +785 -0
  121. package/skill/assets/upstream/packages/openfilm/studio/server/ops.mjs +403 -0
  122. package/skill/assets/upstream/packages/openfilm/studio/server/stage.js +1649 -0
  123. package/skill/assets/upstream/packages/openfilm/studio/server/track-layout.mjs +43 -0
  124. package/skill/assets/upstream/packages/openfilm/studio/ui/src/App.tsx +156 -0
  125. package/skill/assets/upstream/packages/openfilm/studio/ui/src/api.ts +328 -0
  126. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/AudioMeters.tsx +188 -0
  127. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BlockThumbs.tsx +84 -0
  128. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BlockWaveform.tsx +201 -0
  129. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BrandLoadingScreen.tsx +56 -0
  130. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/BrandLogo.tsx +25 -0
  131. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Card.tsx +89 -0
  132. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ColorPicker.tsx +204 -0
  133. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ConfirmDialog.tsx +96 -0
  134. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ContextMenu.tsx +422 -0
  135. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/DesktopDialog.tsx +271 -0
  136. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ExamplesGallery.tsx +372 -0
  137. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FilmFullscreenBar.tsx +147 -0
  138. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FilmInspector.tsx +2310 -0
  139. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FilmStageSelect.tsx +3296 -0
  140. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FocusNav.tsx +28 -0
  141. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FontFamilyPicker.tsx +293 -0
  142. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/FramePicker.tsx +69 -0
  143. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/InspectorPanel.tsx +25 -0
  144. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Launcher.tsx +38 -0
  145. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/LayersPanel.tsx +277 -0
  146. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/LiveTime.tsx +101 -0
  147. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MediaPool.tsx +1413 -0
  148. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MediaPreview.tsx +188 -0
  149. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MenuSelect.tsx +161 -0
  150. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/MgOutline.tsx +24 -0
  151. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/OperationHistory.tsx +184 -0
  152. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PaneDivider.tsx +292 -0
  153. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PasteAttrsDialog.tsx +69 -0
  154. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PlaybackMenu.tsx +110 -0
  155. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Popover.tsx +159 -0
  156. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectCover.tsx +60 -0
  157. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectHistoryControls.tsx +748 -0
  158. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectLayoutMenu.tsx +82 -0
  159. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectMenu.tsx +111 -0
  160. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectSources.tsx +587 -0
  161. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectView.tsx +3222 -0
  162. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectsLibrary.tsx +567 -0
  163. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ProjectsView.tsx +1593 -0
  164. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/PropertiesSection.tsx +418 -0
  165. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ResourceInspector.tsx +100 -0
  166. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SelectionToolbar.tsx +157 -0
  167. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SkillsLibrary.tsx +504 -0
  168. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SourcePreview.tsx +464 -0
  169. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SpendAsks.tsx +88 -0
  170. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/StageMaskOverlay.tsx +299 -0
  171. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/StageTransformFrame.tsx +246 -0
  172. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleInspector.module.css +46 -0
  173. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleInspector.tsx +884 -0
  174. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleOverlay.tsx +148 -0
  175. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/SubtitleRow.tsx +419 -0
  176. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/TabStrip.tsx +188 -0
  177. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Timeline.tsx +4911 -0
  178. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/TimelineMarkers.tsx +329 -0
  179. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Toast.tsx +55 -0
  180. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/Tooltip.tsx +193 -0
  181. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/UpdateButton.tsx +104 -0
  182. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/ViewerBar.tsx +98 -0
  183. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/WhatsNew.tsx +147 -0
  184. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/dock-pane-bar.ts +16 -0
  185. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/export/ExportCenter.tsx +1502 -0
  186. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/export/ExportTray.tsx +284 -0
  187. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/film-stage-hit.ts +18 -0
  188. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/inspector-fields.tsx +863 -0
  189. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/inspector-look.tsx +1357 -0
  190. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/player-controls/controls-bar.tsx +397 -0
  191. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/player-controls/transport.tsx +294 -0
  192. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/replace-drop.ts +20 -0
  193. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/AgentsSection.tsx +613 -0
  194. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/CustomServices.tsx +104 -0
  195. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/DiagnosticsGroup.tsx +202 -0
  196. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ModelsSection.tsx +179 -0
  197. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ProviderMark.tsx +24 -0
  198. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ProvidersSection.tsx +177 -0
  199. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/SettingsModal.tsx +511 -0
  200. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/SettingsSelect.tsx +84 -0
  201. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ShortcutsSection.tsx +291 -0
  202. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/StorageGroup.tsx +119 -0
  203. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/theme.ts +47 -0
  204. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/ui.tsx +139 -0
  205. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/settings/use-settings.ts +10 -0
  206. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/stage-menu.tsx +283 -0
  207. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/timeline-head-extras.tsx +91 -0
  208. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/typing-target.ts +45 -0
  209. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/use-editor-tools.tsx +408 -0
  210. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/use-page-changes.ts +48 -0
  211. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/use-project-history.ts +113 -0
  212. package/skill/assets/upstream/packages/openfilm/studio/ui/src/components/useBlockDrag.ts +639 -0
  213. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/Preview.tsx +426 -0
  214. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/player.ts +558 -0
  215. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/preview-frames.ts +36 -0
  216. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/steps.ts +36 -0
  217. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/undo.ts +156 -0
  218. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/use-film.ts +212 -0
  219. package/skill/assets/upstream/packages/openfilm/studio/ui/src/editor/use-preview-renders.ts +89 -0
  220. package/skill/assets/upstream/packages/openfilm/studio/ui/src/globals.css +559 -0
  221. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/en.ts +1901 -0
  222. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/es.ts +1900 -0
  223. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/index.ts +130 -0
  224. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/ja.ts +1900 -0
  225. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/ko.ts +1896 -0
  226. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/words.ts +6 -0
  227. package/skill/assets/upstream/packages/openfilm/studio/ui/src/i18n/zh-CN.ts +1901 -0
  228. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/agent-row.ts +21 -0
  229. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/asset-timeline.ts +469 -0
  230. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/audio-levels.ts +60 -0
  231. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/batch-map.ts +28 -0
  232. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/browser.ts +10 -0
  233. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/canvas-fit.ts +116 -0
  234. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/chat-marks.ts +114 -0
  235. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-attrs.ts +102 -0
  236. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-fade.ts +70 -0
  237. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-look.ts +520 -0
  238. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-mask.ts +536 -0
  239. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/clip-replace.ts +60 -0
  240. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/component-props.ts +128 -0
  241. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/context-length.ts +12 -0
  242. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/desktop-build.ts +44 -0
  243. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/diagnostics.ts +68 -0
  244. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/double-press.ts +22 -0
  245. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/dropped-overrides.ts +139 -0
  246. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/export-jobs.ts +191 -0
  247. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/export-spec.ts +261 -0
  248. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/field-input.ts +155 -0
  249. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-clip-edits.ts +82 -0
  250. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-frame.ts +134 -0
  251. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-overrides.ts +107 -0
  252. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film-shape.ts +172 -0
  253. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/film.ts +80 -0
  254. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/font-menu.ts +236 -0
  255. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/font-styles.ts +129 -0
  256. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/freeze-frame.ts +49 -0
  257. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/host.ts +438 -0
  258. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/inspect-scrub.ts +26 -0
  259. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/keymap-store.ts +74 -0
  260. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/keymap.ts +529 -0
  261. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/layers-tree.ts +133 -0
  262. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/look.ts +83 -0
  263. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/loudness.ts +58 -0
  264. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/markers.ts +152 -0
  265. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-bar.ts +58 -0
  266. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-folders.ts +45 -0
  267. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-place.ts +102 -0
  268. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-queue.ts +107 -0
  269. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/media-selection.ts +64 -0
  270. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/menu-position.ts +77 -0
  271. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/mg-transform.ts +111 -0
  272. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/mixed.ts +3 -0
  273. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/page-changes.ts +45 -0
  274. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/picture-files.ts +19 -0
  275. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/preview-prefs.ts +46 -0
  276. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/preview-renders.ts +71 -0
  277. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-delete.ts +20 -0
  278. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-folders.ts +144 -0
  279. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-history.ts +188 -0
  280. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/project-sources.ts +283 -0
  281. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/projects-page.ts +40 -0
  282. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/provider-marks.ts +35 -0
  283. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/providers.ts +163 -0
  284. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/resource-drag.ts +131 -0
  285. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/retry-image.ts +17 -0
  286. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/saved-insert.ts +40 -0
  287. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/saved.ts +125 -0
  288. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/selection-toolbar.ts +158 -0
  289. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/shortcut-hint.ts +21 -0
  290. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/similar-layers.ts +46 -0
  291. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/solo.ts +19 -0
  292. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-clip.ts +60 -0
  293. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-crop.ts +232 -0
  294. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-gesture.ts +277 -0
  295. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-layers.ts +71 -0
  296. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-mask.ts +176 -0
  297. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-measure.ts +53 -0
  298. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/stage-types.ts +155 -0
  299. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/studio-refs.ts +248 -0
  300. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/studio-version.ts +41 -0
  301. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/subtitle-cues.ts +238 -0
  302. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/subtitles.ts +263 -0
  303. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/tab-menu.ts +6 -0
  304. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/template-controls.ts +131 -0
  305. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/theme-changes.ts +31 -0
  306. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-cache.ts +63 -0
  307. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-cell.ts +8 -0
  308. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-frame.ts +76 -0
  309. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/thumb-pick.ts +42 -0
  310. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timecode.ts +161 -0
  311. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-clipboard.ts +279 -0
  312. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-drag.ts +589 -0
  313. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-edit.ts +1138 -0
  314. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-keys.ts +63 -0
  315. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-layout.ts +1038 -0
  316. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-nav.ts +160 -0
  317. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-prefs.ts +73 -0
  318. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-split.ts +40 -0
  319. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/timeline-view.ts +105 -0
  320. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/title.ts +39 -0
  321. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/track-heights.ts +55 -0
  322. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/track-lock.ts +27 -0
  323. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/transitions.ts +459 -0
  324. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/type-metrics.ts +83 -0
  325. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-film-subtitles.ts +111 -0
  326. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-fonts.ts +181 -0
  327. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-loaded-thumbs.ts +91 -0
  328. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-subtitle-edits.ts +134 -0
  329. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-subtitle-font.ts +51 -0
  330. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/use-subtitle-style.ts +68 -0
  331. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/waveform-columns.ts +48 -0
  332. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/web-update.ts +41 -0
  333. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/whats-new.ts +401 -0
  334. package/skill/assets/upstream/packages/openfilm/studio/ui/src/lib/workspace-resources.ts +373 -0
  335. package/skill/assets/upstream/packages/openfilm/studio/ui/src/main.tsx +10 -0
  336. package/skill/assets/upstream/packages/openfilm/studio/ui/vite.config.ts +12 -0
  337. package/skill/assets/upstream/packages/shared/package.json +45 -0
  338. package/skill/assets/upstream/packages/shared/src/agent-activity.ts +263 -0
  339. package/skill/assets/upstream/packages/shared/src/contract.ts +285 -0
  340. package/skill/assets/upstream/packages/shared/src/index.ts +17 -0
  341. package/skill/assets/upstream/packages/shared/src/markdown-blocks.ts +252 -0
  342. package/skill/assets/upstream/packages/shared/src/modality.ts +4 -0
  343. package/skill/assets/upstream/packages/shared/src/prompt-reference.ts +113 -0
  344. package/skill/assets/upstream/packages/shared/src/title.ts +76 -0
  345. package/skill/assets/upstream/packages/shared/src/turn.ts +68 -0
  346. package/skill/assets/upstream/packages/shared/src/video.ts +26 -0
  347. package/skill/references/agent-components.md +33 -0
  348. package/skill/references/api-index.json +2693 -0
  349. package/skill/references/chat-components.md +65 -0
  350. package/skill/references/components.md +23 -0
  351. package/skill/references/core-components.md +44 -0
  352. package/skill/references/dependencies.md +22 -0
  353. package/skill/references/esm-package.md +48 -0
  354. package/skill/references/integration.md +43 -0
  355. package/skill/references/library-manifest.json +12 -0
  356. package/skill/references/source-manifest.json +325 -0
  357. package/skill/references/studio-components.md +24 -0
  358. package/skill/references/theme-motion.md +49 -0
  359. package/skill/references/troubleshooting.md +27 -0
  360. package/skill/references/verification.md +13 -0
  361. package/skill/scripts/check_dependencies.py +109 -0
  362. package/skill/scripts/inspect_api.py +60 -0
@@ -0,0 +1,3296 @@
1
+ /**
2
+ * Pointing at things in the picture: hover outlines, click and marquee selection, the transform handles, nudging,
3
+ * typing a line in place, and the context menus.
4
+ *
5
+ * Two kinds of selection, each with its own way in:
6
+ *
7
+ * A **whole clip** (a frame with handles: move, scale, turn) is selected on the **timeline**: one block selected there
8
+ * is in hand here. Clicking the picture does not pick a clip up, so pressing and dragging on the picture is never
9
+ * "drag a clip away" and the marquee has somewhere to happen.
10
+ *
11
+ * A **layer inside a clip** is selected on the **picture**: with no clip in hand, the layer under the pointer (a line
12
+ * of text, a picture, an SVG shape, a box that paints) is outlined; a click selects it and the inspector shows its
13
+ * sections. Where several are stacked the smallest wins (the word, not the card); Alt-click again goes one down.
14
+ * Empty shells that only place things are passed over.
15
+ *
16
+ * With nothing in hand, pressing and dragging draws a marquee; a click and a marquee part only when the pointer is let
17
+ * go (further than the threshold: a marquee). Esc drops the selection.
18
+ *
19
+ * With ⌘ held (Ctrl on Windows), when the app around Studio has a chat (`onRegion`), a box drawn on the picture points
20
+ * at that part of it in the chat instead: nothing is selected, the box flashes and is handed on with what is in it.
21
+ *
22
+ * A whole clip's frame, its handles and its snapping go by the part of it that shows (what its crop leaves). A video or
23
+ * still in hand is cropped in place (C, a double-click on it, or the inspector's Crop): the whole picture shows with
24
+ * what is cut away darkened, the crop's handles move its edges, a drag inside pans; Enter keeps it (written once, as
25
+ * the clip's own `clip-path: inset(…)` and `object-position`), Esc gives it up.
26
+ *
27
+ * With something in hand, holding Alt over another clip or layer measures the distances to it (over nothing, or over
28
+ * itself, to the stage's edges).
29
+ *
30
+ * The film runs on its own origin: everything about its DOM (what is under a point, where a layer is drawn, showing
31
+ * a change before it is kept, the in-place text box) is asked of the film's stage through the picture's handle
32
+ * (Preview `stage`, studio/server/stage.js). Hover asks at most once a frame, one question at a time, the latest
33
+ * point winning. What is written is always film.html: a clip's `box`, a page's `overrides`, through the callbacks.
34
+ */
35
+ import * as React from 'react';
36
+ import { currentKeymap, keyAction } from '@/lib/keymap-store';
37
+ import { hintOf } from '@/lib/keymap';
38
+ import type { KeyPress } from '@/lib/timeline-keys';
39
+ import { doubleClickAim, keepPress, type Press } from '@/lib/double-press';
40
+ import {
41
+ AlignCenterHorizontal, AlignCenterVertical, AlignEndHorizontal, AlignEndVertical, AlignHorizontalDistributeCenter,
42
+ AlignStartHorizontal, AlignStartVertical, AlignVerticalDistributeCenter, Crop as CropIcon, MessageSquarePlus,
43
+ MoreHorizontal, SquareDashed,
44
+ } from 'lucide-react';
45
+
46
+ import { ContextMenu, type ContextMenuEntry, type ContextMenuItem } from '@/components/ContextMenu';
47
+ import { SelectionToolbar, type SelectionToolEntry } from '@/components/SelectionToolbar';
48
+ import { MgOutline } from '@/components/MgOutline';
49
+ import { FRAME_BLUE, GestureHint, StageTransformFrame, type FrameGeometry } from '@/components/StageTransformFrame';
50
+ import { StageMaskOverlay } from '@/components/StageMaskOverlay';
51
+ import { linkedOutlines } from '@/components/film-stage-hit';
52
+ import { inHostPanel } from '@/components/typing-target';
53
+ import type { StageArrange, StageArrangeResult, StageMenuContext } from '@/components/stage-menu';
54
+ import type { PreviewHandle, StageEvent } from '@/editor/Preview';
55
+ import { useT } from '@/i18n';
56
+ import { cropOf, declared, pxOf, type ClipLook, type Crop } from '@/lib/clip-look';
57
+ import { filmClipKind, filmDocLoc, filmSrcKind, parseFilmDocLoc, type FilmBox, type FilmDoc } from '@/lib/film';
58
+ import type { OverrideGeometry } from '@/lib/film-overrides';
59
+ import { spanShows, type ClipSpan } from '@/lib/film-shape';
60
+ import {
61
+ mgSize, moveTransform, rotateTransform, sameMgTransform, snapMove, type MgBox, type MgGuide, type MgTransform,
62
+ } from '@/lib/mg-transform';
63
+ import { compactBox, transformOf } from '@/lib/stage-clip';
64
+ import {
65
+ aspectRatio, CROP_ASPECTS, cropOfRect, cropPreviewCss, croppedCss, cropRect, draggedCrop, fitAspect, frameAabb, pannedCrop,
66
+ positionOf, roundOf, transformOfVisible, visibleFrame, type CropAspect, type Rect,
67
+ } from '@/lib/stage-crop';
68
+ import {
69
+ alignShift, groupDragged, type GroupDrag, type GroupPull, HANDLE_AT, insideBox, layerInGroup, pullGroup, insideFrame, movedOffset, pivotCenter, resizedFrame, sameClientRect, sameGuides,
70
+ sameLayerGeometry, scaledLayer, turnedLayer, unionRect, type AlignEdge, type ClientBox, type FramePivot,
71
+ type LayerGeometry, type StageFrame,
72
+ } from '@/lib/stage-gesture';
73
+ import { measureGaps, type MeasureLine } from '@/lib/stage-measure';
74
+ import type { MaskSession } from '@/lib/stage-mask';
75
+ import { MASK_SHAPES, maskOf, type MaskShape } from '@/lib/clip-mask';
76
+ import { MaskGlyph } from '@/components/inspector-look';
77
+ import { layerTarget } from '@/lib/stage-layers';
78
+ import { regionBox, regionClipIds, regionTexts } from '@/lib/studio-refs';
79
+ import type { StageBox } from '@/lib/host';
80
+ import { samePictureMarks, type PictureMarks } from '@/lib/chat-marks';
81
+ import { distributeShifts } from '@/lib/selection-toolbar';
82
+ import { trackLockedAt } from '@/lib/track-lock';
83
+ import { peekResourceDrag, readResourceDragData, RESOURCE_DRAG_TYPE } from '@/lib/resource-drag';
84
+ import {
85
+ compactScale, type NodeOverride, type StageElement, type StageElementStyle, type StageGeometry, type StageNodeKind,
86
+ type StageNodeParent, type StagePicture,
87
+ } from '@/lib/stage-types';
88
+
89
+ export type { LayerGeometry } from '@/lib/stage-gesture';
90
+
91
+ /** Pulled open this far (stage px, both ways) it is a marquee; less is a click (a shaky hand keeps its click). */
92
+ const MARQUEE_MIN_PX = 8;
93
+ const PICK_BLUE = FRAME_BLUE;
94
+ /** Snap this close (screen px: the feel must not change with the picture's scale). */
95
+ const SNAP_PX = 6;
96
+ const NO_GUIDES: readonly MgGuide[] = [];
97
+ /**
98
+ * A key pressed in the inspector (a menu gone through with the arrows, a field): its own, not a nudge. Heard first by
99
+ * the nudges (they listen in the capture phase), the arrows moved the layer while a menu was gone through, and the
100
+ * write took back what the menu showed.
101
+ */
102
+ const inspectorKey = (target: EventTarget | null): boolean => Boolean((target as Element | null)?.closest?.('[data-film-inspector], [role="listbox"], [role="menu"]'));
103
+ /** A selected layer lost in a redraw is let go after this long without finding it again. */
104
+ const LOST_PICK_GRACE_MS = 1200;
105
+ /** The selection is measured again each time the playhead enters another half second. */
106
+ const STALE_BUCKET_MS = 500;
107
+ const staleBucket = (timeMs: number): number => Math.round(timeMs / STALE_BUCKET_MS);
108
+ /* the key a region is drawn with: ⌘ on a Mac (its Ctrl-click is a right click), Ctrl elsewhere */
109
+ const MAC = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
110
+ const REGION_KEY = MAC ? 'Meta' : 'Control';
111
+ /** What a press is read for: the overlay's own, or one on the dark around the picture. */
112
+ type PressEvent = Pick<PointerEvent, 'button' | 'timeStamp' | 'pointerId' | 'target' | 'clientX' | 'clientY' | 'shiftKey' | 'altKey' | 'metaKey' | 'ctrlKey' | 'stopPropagation'>;
113
+ /** Controls in the viewer's picture area: a press on one is theirs. */
114
+ const CONTROLS = 'button, a, input, textarea, select, [role="button"], [role="slider"], [role="menu"], [contenteditable="true"]';
115
+ const regionKeyHeld = (e: { metaKey: boolean; ctrlKey: boolean }) => (MAC ? e.metaKey : e.ctrlKey);
116
+ /** ⌘A (Ctrl+A elsewhere; lib/keymap): every layer on the picture selected. */
117
+ const isSelectAll = (e: KeyPress) => keyAction(e) === 'select-all';
118
+ /** Arrow keys write what they moved this long after the last one (held down they come thirty a second). */
119
+ const NUDGE_WRITE_MS = 350;
120
+ const NO_CROP: Crop = [0, 0, 0, 0];
121
+ /** The measuring red: distances held apart from the selection's blue. */
122
+ const MEASURE_RED = '#f24822';
123
+
124
+ /** A layer as the film's stage describes it (studio/server/stage.js `describe`); boxes in stage px. */
125
+ export interface StageLayer {
126
+ handle: number;
127
+ clip: string;
128
+ at: string;
129
+ n?: number;
130
+ loc: string;
131
+ instance?: number;
132
+ instances?: number;
133
+ kind: StageNodeKind;
134
+ tag: string;
135
+ /** layers like it in any page: its tag and classes (none: it has no class) */
136
+ similar?: string;
137
+ label: string;
138
+ text?: { value: string; shape: 'children' | 'prop' | 'value' };
139
+ /** the picture it draws, when a file can be put in its place (studio/server/stage.js pictureOf) */
140
+ picture?: StagePicture;
141
+ style: StageElementStyle;
142
+ parents: StageNodeParent[];
143
+ override: NodeOverride | null;
144
+ group: boolean;
145
+ locked: boolean;
146
+ svg: boolean;
147
+ rect: ClientBox;
148
+ frame: StageFrame;
149
+ geo: {
150
+ /** The override geometry painted now. */
151
+ g: LayerGeometry;
152
+ /** Stage px per override px. */
153
+ k: number;
154
+ /** How far the layers around it are turned (the clip included), degrees. */
155
+ parentR: number;
156
+ /** Its turn besides its own override's (its own transform, its parents', the clip's), degrees. */
157
+ around: number;
158
+ /** z-index that restacks it to the front / back; null when it is not positioned. */
159
+ };
160
+ }
161
+
162
+ /** A whole clip dragged with others: where it was, and its picture's own size. */
163
+ interface ClipsDragItem { loc: string; id: string; t0: MgTransform; box: MgBox }
164
+
165
+
166
+ /** A clip as the film's stage tells it: where its picture is drawn (stage px), and whether it shows now. */
167
+ interface StageClipBox { id: string; visible: boolean; x: number; y: number; w: number; h: number }
168
+
169
+ /** Just where a hovered layer is; a whole clip hovered (a page's empty part, a video, a still) has its id. */
170
+ interface StageOutline {
171
+ handle: number;
172
+ rect: ClientBox;
173
+ frame?: StageFrame;
174
+ clip?: string;
175
+ }
176
+
177
+ /** A clip's edit from the stage: its film.html `box`, or its own CSS (`style`: a crop). */
178
+ export interface StageClipEdit {
179
+ loc: string;
180
+ prop: 'box' | 'style';
181
+ value: FilmBox | string | null;
182
+ }
183
+
184
+ /** What the inspector needs of the stage while the editor wires it (see FilmStageSelect's `controlRef`). */
185
+ export interface FilmStageControl {
186
+ /** Show style keys on a layer before they are kept (`null` takes a key back to the page's own). */
187
+ previewStyle(element: StageElement, patch: StageElementStyle | Record<string, string | number | null>): void;
188
+ /**
189
+ * What a layer needs besides `style` so that no other layer moves when it is kept (its place in the page held, as
190
+ * a restyled layer never moves the others): style to write with it, {} when nothing else moves.
191
+ */
192
+ holdStyle(element: StageElement, style: Record<string, string | number | null>, text?: string): Promise<Record<string, string | null>>;
193
+ /** Show a layer's words before they are kept. */
194
+ previewText(element: StageElement, value: string): void;
195
+ /**
196
+ * Show a layer's move / scale / turn before it is kept. With `pivot` the offset is worked out so that point of its
197
+ * box stays put (CSS scales and turns about its unmoved box), and returned with the patch, to be written so.
198
+ */
199
+ previewOverride(element: StageElement, patch: OverrideGeometry, pivot?: FramePivot): Promise<OverrideGeometry>;
200
+ /**
201
+ * What is written with an edit of a layer (`patch` null: its override taken away), besides it: for a line with blocks
202
+ * written inside it (a subtitle inside its title), the overrides that keep those where they were and as they looked.
203
+ */
204
+ riders(element: StageElement, patch: OverrideGeometry | null): Promise<{ at: string; n?: number; patch: OverrideGeometry }[]>;
205
+ /** Stop showing what was not kept on the element's clip (an edit was refused). */
206
+ release(element: StageElement): void;
207
+ /** The layer read again (its style after an override was taken away). */
208
+ describe(element: StageElement): Promise<StageElement | null>;
209
+ /** Measure the selection again (something else changed the picture). */
210
+ refresh(): void;
211
+ /** Crop the video or still in hand in place (the inspector's Crop); false when nothing there can be. */
212
+ startCrop(): boolean;
213
+ /** The inspector's Mask › Edit on picture: the mask's handles over the thing it is for, while that is in hand. */
214
+ editMask(session: MaskSession | null): void;
215
+ /** What is in hand on the picture (each of several layers, a layer, a whole clip), where it is drawn (stage px). */
216
+ picked(): StagePick[];
217
+ /** Write now what the arrow keys moved and is not written yet: before an undo, or any other edit. */
218
+ flushPending(): void;
219
+ /**
220
+ * Several layers in hand: their bounds put at `x` / `y` (all moved alike), or made `w` / `h` wide (all scaled alike,
221
+ * in proportion, about the bounds' top left), as the inspector's fields move and scale a selection. Stage px.
222
+ */
223
+ placeLayers(to: { x?: number; y?: number; w?: number; h?: number }): void;
224
+ }
225
+
226
+ /** One thing in hand on the picture, for a reference: what it is, and its box (stage px) when it is a layer. */
227
+ export interface StagePick { element: StageElement; box: StageBox | null }
228
+
229
+ interface Hit {
230
+ /** Stage px. */
231
+ rect: ClientBox;
232
+ label?: string;
233
+ /** None for a marquee that boxed no layer. */
234
+ element?: StageElement;
235
+ /** The film's description of a selected layer (none for a clip). */
236
+ layer?: StageLayer;
237
+ }
238
+
239
+ interface MultiItem { layer: StageLayer; element: StageElement }
240
+
241
+ interface LinkedMark { loc: string; t: MgTransform; box: MgBox }
242
+ const NO_MARKS: readonly LinkedMark[] = [];
243
+ /** What the app's chat points at, on the picture: the timeline's accent, apart from the selection's blue. */
244
+ const CHAT_TONE = 'var(--tl-accent)';
245
+
246
+ function sameLinkedMarks(a: readonly LinkedMark[], b: readonly LinkedMark[]): boolean {
247
+ return a.length === b.length && a.every((m, i) => {
248
+ const other = b[i]!;
249
+ return m.loc === other.loc && m.box.w === other.box.w && m.box.h === other.box.h
250
+ && (m.box.x ?? 0) === (other.box.x ?? 0) && (m.box.y ?? 0) === (other.box.y ?? 0) && sameMgTransform(m.t, other.t);
251
+ });
252
+ }
253
+
254
+ const scaleBox = (b: ClientBox, k: number): ClientBox => ({ left: b.left * k, top: b.top * k, width: b.width * k, height: b.height * k });
255
+ const scaleFrame = (f: StageFrame, k: number): StageFrame => ({ cx: f.cx * k, cy: f.cy * k, w: f.w * k, h: f.h * k, r: f.r });
256
+
257
+ /** Whether `r` is wholly inside `area` (a px to spare for rounding). */
258
+ const rectInside = (r: Rect, area: Rect) => r.x >= area.x - 1 && r.y >= area.y - 1 && r.x + r.w <= area.x + area.w + 1 && r.y + r.h <= area.y + area.h + 1;
259
+ const clientOf = (r: Rect): ClientBox => ({ left: r.x, top: r.y, width: r.w, height: r.h });
260
+
261
+ /**
262
+ * A picture clip as film.html and the preview's spans have it: `box` is its picture's own size, once known; `crop`
263
+ * what its clip-path leaves showing.
264
+ */
265
+ interface ClipInfo { loc: string; id: string; kind: 'mg' | 'video'; t: MgTransform; box: MgBox | null; crop: Crop }
266
+
267
+ /** Where a clip is before its picture's size is known: nowhere to hold it by. */
268
+ const UNMEASURED: MgTransform = { x: 0, y: 0, scaleX: 1, scaleY: 1, rotate: 0 };
269
+
270
+ /** `live`: CSS the inspector shows on it, not kept yet, while film.html's style is still the one it was shown over. */
271
+ function clipInfo(
272
+ doc: FilmDoc, spans: readonly ClipSpan[], stage: { w: number; h: number }, loc: string, live?: { css: string; written: string } | null,
273
+ ): ClipInfo | null {
274
+ const at = parseFilmDocLoc(loc);
275
+ const clip = at ? doc.tracks[at.track]?.clips[at.clip] : undefined;
276
+ if (!clip) return null;
277
+ const kind = filmClipKind(clip);
278
+ if (kind === 'audio') return null;
279
+ const span = spans.find((s) => s.id === clip.id);
280
+ const own = span?.w && span.h ? { w: span.w, h: span.h } : null;
281
+ const style = live && live.written === (clip.style ?? '') ? live.css : clip.style ?? '';
282
+ return {
283
+ loc, id: clip.id, kind, t: own ? transformOf(filmSrcKind(clip.src), own, stage, clip.box) : UNMEASURED, box: own, crop: cropOfStyle(style, span?.look),
284
+ };
285
+ }
286
+
287
+ /** What a clip's crop leaves (clip-look): its own `clip-path`, else the film's CSS's; another shape is no crop here. */
288
+ function cropOfStyle(style: string, look: ClipLook | undefined): Crop {
289
+ return cropOf(declared(style, 'clip-path') ?? look?.clip) ?? NO_CROP;
290
+ }
291
+
292
+ /** A clip's written box as JSON ('null' without one), to tell when film.html's place for it changed. */
293
+ function writtenBoxOf(doc: FilmDoc, loc: string): string {
294
+ const at = parseFilmDocLoc(loc);
295
+ return JSON.stringify((at ? doc.tracks[at.track]?.clips[at.clip]?.box : undefined) ?? null);
296
+ }
297
+
298
+ function clipLocOf(doc: FilmDoc, id: string): string | undefined {
299
+ for (const [ti, track] of doc.tracks.entries()) {
300
+ const ci = track.clips.findIndex((c) => c.id === id);
301
+ if (ci >= 0) return filmDocLoc(ti, ci);
302
+ }
303
+ return undefined;
304
+ }
305
+
306
+ /** A whole clip in hand: its box is the part that shows. */
307
+ function describeClip(info: ClipInfo, stage: { w: number; h: number }): Hit {
308
+ const aabb = info.box ? frameAabb(visibleFrame(info.t, info.box, info.crop)) : { x: 0, y: 0, w: stage.w, h: stage.h };
309
+ const element: StageElement = {
310
+ kind: 'clip',
311
+ loc: null,
312
+ label: info.id,
313
+ clipLoc: info.loc,
314
+ clipId: info.id,
315
+ clipKind: info.kind,
316
+ isMgOuter: true,
317
+ transform: info.t,
318
+ ...(info.box ? { mgBox: info.box } : {}),
319
+ };
320
+ return { rect: { left: aabb.x, top: aabb.y, width: aabb.w, height: aabb.h }, label: element.label, element };
321
+ }
322
+
323
+ /**
324
+ * One question at a time, the latest waiting one next (the ones it replaced are dropped): hover, drag painting and
325
+ * re-measuring ask the film often, and must never queue up behind each other.
326
+ */
327
+ class Latest<A> {
328
+ private busy = false;
329
+ private next: { a: A } | null = null;
330
+ private waiting: (() => void)[] = [];
331
+ constructor(private readonly run: (a: A) => Promise<void>) {}
332
+ push(a: A): void { this.next = { a }; void this.pump(); }
333
+ /** Resolves once nothing is running or waiting. */
334
+ idle(): Promise<void> { return !this.busy && !this.next ? Promise.resolve() : new Promise((done) => { this.waiting.push(done); }); }
335
+ private async pump(): Promise<void> {
336
+ if (this.busy) return;
337
+ while (this.next) {
338
+ const { a } = this.next;
339
+ this.next = null;
340
+ this.busy = true;
341
+ try { await this.run(a); } catch { /* a question that failed is not asked again */ }
342
+ this.busy = false;
343
+ }
344
+ const waiting = this.waiting;
345
+ this.waiting = [];
346
+ for (const done of waiting) done();
347
+ }
348
+ }
349
+
350
+ interface FilmStageSelectProps {
351
+ /** The picture: the stage's questions go through its handle. */
352
+ previewRef: React.RefObject<PreviewHandle | null>;
353
+ /** The film's own size: everything asked of the film is in its px. */
354
+ stage: { w: number; h: number };
355
+ /** film.html now: clips' boxes and places. */
356
+ doc: FilmDoc;
357
+ /** Where each clip sits and its source's size, as the preview reports (a clip's box needs it). */
358
+ spans: readonly ClipSpan[];
359
+ /** Now (ms). Only its half-second bucket is looked at (see the memo below): a change re-measures the selection. */
360
+ timeMs: number;
361
+ playing?: boolean;
362
+ /** Pressing the picture pauses: selecting something that moves is a contradiction. */
363
+ onPause?: () => void;
364
+ /** The selection changed: a layer clicked, the clip from the timeline in hand, or nothing (null). */
365
+ onSelectElement?: (element: StageElement | null) => void;
366
+ /** A whole clip moved / scaled / turned on the stage: its box written. Refused: an error message. */
367
+ onEditClip?: (edits: StageClipEdit[]) => Promise<string | null>;
368
+ /** The clips selected elsewhere (`film.html#<track>.<clip>`): one is in hand here, several are outlined. */
369
+ linkedLocs?: readonly string[];
370
+ /** Select these clips whole (a marquee around several): the timeline selects them, and they are outlined here. */
371
+ onSelectClips?: (locs: readonly string[]) => void;
372
+ /**
373
+ * Select the layer at `loc` in the clip at `clipLoc` (the inspector's breadcrumb, the menu's Select parent). `seq`
374
+ * goes up each time, so the same layer can be asked for twice.
375
+ */
376
+ selectRequest?: { loc: string; clipLoc?: string; seq: number } | null;
377
+ /**
378
+ * Set here: shows the clip in hand with a transform patch while the inspector drags its X / Y / scale / turn (null:
379
+ * its own again). Only the clip in hand can be shown.
380
+ */
381
+ clipPreviewRef?: React.MutableRefObject<((clipLoc: string, patch: Partial<MgTransform> | null) => void) | null>;
382
+ /** Filled here: shows a clip in CSS of its own not kept yet (null: its own again), as the inspector drags a value. */
383
+ lookPreviewRef?: React.MutableRefObject<((clipLoc: string, css: string | null) => void) | null>;
384
+ /** Set here: what the inspector needs of the stage (see FilmStageControl). */
385
+ controlRef?: React.MutableRefObject<FilmStageControl | null>;
386
+ /** The layer in hand was dragged / nudged / scaled / turned: its override `{ t, s, r }` written. Refused: a message. */
387
+ onTransformLayer?: (element: StageElement, next: LayerGeometry) => Promise<string | null>;
388
+ /** Several layers moved or aligned together: written in one go (one undo step). */
389
+ onTransformLayers?: (list: ReadonlyArray<{ element: StageElement; g: LayerGeometry }>) => void;
390
+ /** A line typed in place: its words written. Refused: a message (the words go back). */
391
+ /** `hold`: what kept the other layers in place as the words were typed, written with them. */
392
+ onEditTextInline?: (element: StageElement, value: string, hold?: Record<string, string | null>) => Promise<string | null>;
393
+ /** Where the thing in hand is now (stage px), for the inspector's X / Y / W / H / turn; reported when it changes. */
394
+ onGeometry?: (geometry: StageGeometry | null) => void;
395
+ /** The context menu's entries for what was right-clicked (see components/stage-menu). */
396
+ menuFor?: (element: StageElement, ctx: StageMenuContext) => readonly ContextMenuEntry[];
397
+ /** Show… / Unlock… for layers that cannot be clicked, after every menu. */
398
+ recoveryMenu?: () => readonly ContextMenuEntry[];
399
+ /**
400
+ * A box drawn with ⌘ held (stage px): the picture clips showing there and the words of the layers wholly inside it.
401
+ * Without it ⌘ does nothing of its own here.
402
+ */
403
+ onRegion?: (region: { box: StageBox; clipIds: string[]; texts: string[] }) => void;
404
+ /** Said beside the pointer while ⌘ is held over the picture (with `onRegion`). */
405
+ regionHint?: string;
406
+ /** A box to flash on the picture (the app showing a region it was pointed at); `seq` goes up each time. */
407
+ flashBox?: { box: StageBox; seq: number } | null;
408
+ /**
409
+ * What the app's chat points at on the picture shown (lib/chat-marks): the clip or the box of the pill hovered
410
+ * there, outlined, and the boxes of the message being written, each with its number in a corner.
411
+ */
412
+ chat?: PictureMarks | null;
413
+ /** The pointer came onto a layer of a page (where it is drawn, stage px), or left it (null). */
414
+ onHoverLayer?: (hit: { element: StageElement; box: StageBox } | null) => void;
415
+ /** The bar beside what is in hand (SelectionToolbar) is the picture's: the person is working here, not on the timeline. */
416
+ selectionBar?: boolean;
417
+ /** The bar's first button, "Add to chat": what is in hand. Without it the bar has no such button. */
418
+ onRefer?: (picks: StagePick[]) => void;
419
+ /** The bar's Mask, a menu of shapes: the one picked is put on the clip in hand, its handles on the picture. */
420
+ onStartMask?: (shape: MaskShape | 'none', clipLoc: string) => void;
421
+ /** A layer pointed at from elsewhere (a row of the Layers panel), outlined as the one under the pointer is. */
422
+ pointLayer?: { clipId: string; loc: string } | null;
423
+ /** A picture of the project dropped on a picture inside a page (an <img>, a box painted with one): put in its place. */
424
+ onDropPicture?: (element: StageElement, path: string) => void;
425
+ /**
426
+ * The picture has the keyboard: the arrow keys move what is in hand (1 px, ⇧ 10). Without it they are the
427
+ * playhead's, whatever is in hand.
428
+ */
429
+ arrowsMove?: boolean;
430
+ }
431
+
432
+ function FilmStageSelectImpl({
433
+ previewRef, stage, doc, spans, timeMs, playing, onPause, onSelectElement, onEditClip, linkedLocs, onSelectClips, selectRequest,
434
+ clipPreviewRef, lookPreviewRef, controlRef, onTransformLayer, onTransformLayers, onEditTextInline, onGeometry, menuFor, recoveryMenu,
435
+ onRegion, regionHint, flashBox, chat, onHoverLayer, selectionBar = false, onRefer, onStartMask, pointLayer, arrowsMove = true, onDropPicture,
436
+ }: FilmStageSelectProps) {
437
+ const arrowsMoveRef = React.useRef(arrowsMove);
438
+ arrowsMoveRef.current = arrowsMove;
439
+ const flushPendingRef = React.useRef<() => void>(() => {});
440
+ const host = React.useRef<HTMLDivElement | null>(null);
441
+ const [picked, setPickedState] = React.useState<Hit | null>(null);
442
+ const pickedRef = React.useRef<Hit | null>(null);
443
+ /** Kept in step at once (a handler running right after a set must see it), drawn on the next render. */
444
+ const setPicked = React.useCallback((next: Hit | null | ((cur: Hit | null) => Hit | null)) => {
445
+ const value = typeof next === 'function' ? next(pickedRef.current) : next;
446
+ pickedRef.current = value;
447
+ setPickedState(value);
448
+ }, []);
449
+ /** The layer under the pointer (only with no clip in hand). Stage px. */
450
+ const [hover, setHover] = React.useState<StageOutline | null>(null);
451
+ const [draft, setDraft] = React.useState<{ x1: number; y1: number; x2: number; y2: number } | null>(null);
452
+ /** The marquee's own copy: a press and a release can land in one batch of updates, so decisions never read state. */
453
+ const draftRef = React.useRef<{ x1: number; y1: number; x2: number; y2: number } | null>(null);
454
+ /** Layers (by loc) whose override was just written and is not in film.html yet. */
455
+ const committing = React.useRef(new Set<string>());
456
+ /** Since when the selected layer could not be found (see syncPick). */
457
+ const lostSince = React.useRef<number | null>(null);
458
+ const gestureRef = React.useRef<{
459
+ kind: 'move' | 'scale' | 'rotate';
460
+ /** A scale's handle: its place in the frame (see HANDLE_AT); an edge's has one axis. */
461
+ handle?: FramePivot;
462
+ /** A video or still: its box takes any proportions (its CSS fits the picture in it); a page's never does. */
463
+ stretchable: boolean;
464
+ pendingClick?: boolean;
465
+ start: { x: number; y: number };
466
+ origin: MgTransform;
467
+ /* the picture's own size: the numbers in hand are its multiples */
468
+ box: MgBox;
469
+ /** What its crop leaves: the frame it is held by. */
470
+ crop: Crop;
471
+ clipLoc: string;
472
+ clipId: string;
473
+ hit?: { x: number; y: number };
474
+ /** What a move or a scale can snap to (stage px), counted once at the press. */
475
+ snapTargets: readonly { x: number; y: number; w: number; h: number }[];
476
+ } | null>(null);
477
+ const stageLayer = React.useRef<HTMLDivElement | null>(null);
478
+ const [filmOffset, setFilmOffset] = React.useState({ left: 0, top: 0, width: 0, height: 0 });
479
+ const liveTRef = React.useRef<MgTransform | null>(null);
480
+ /** For an inspector preview in `liveT`: film.html's box of the clip when it began, as JSON. */
481
+ const liveBoxRef = React.useRef<string | null>(null);
482
+ const [liveT, setLiveT] = React.useState<MgTransform | null>(null);
483
+ const [liveGuides, setLiveGuides] = React.useState<readonly MgGuide[]>(NO_GUIDES);
484
+ /**
485
+ * Something is being dragged: the hover outline and linked outlines are put away. Set once the drag passes its
486
+ * threshold, not at the press (a click must not flash anything).
487
+ */
488
+ const [dragging, setDragging] = React.useState(false);
489
+ /** Several layers selected together (marquee, Shift-click): `picked` is their union box. */
490
+ const [multi, setMultiState] = React.useState<readonly MultiItem[] | null>(null);
491
+ const multiRef = React.useRef<readonly MultiItem[] | null>(null);
492
+ const setMulti = React.useCallback((next: readonly MultiItem[] | null) => {
493
+ multiRef.current = next;
494
+ setMultiState(next);
495
+ }, []);
496
+ /** Each press's number: an answer that comes after a later press is not acted on. */
497
+ const pressSeq = React.useRef(0);
498
+ /** The box being drawn with ⌘ held (screen px from the film's top left), and its own copy for the release. */
499
+ const [regionDraft, setRegionDraft] = React.useState<{ x1: number; y1: number; x2: number; y2: number } | null>(null);
500
+ const regionDraftRef = React.useRef<{ x1: number; y1: number; x2: number; y2: number } | null>(null);
501
+ /** ⌘ held with the pointer over the picture: where the pointer is, for the hint (the cursor is a crosshair then). */
502
+ const [modAt, setModAt] = React.useState<{ x: number; y: number } | null>(null);
503
+ const pointerAt = React.useRef<{ x: number; y: number } | null>(null);
504
+ /* ⌘ pressed or let go with the pointer resting on the picture sends no pointer move */
505
+ React.useEffect(() => {
506
+ if (!onRegion) return undefined;
507
+ const onKey = (e: KeyboardEvent) => {
508
+ if (e.key === REGION_KEY) setModAt(e.type === 'keydown' ? pointerAt.current : null);
509
+ };
510
+ const off = () => setModAt(null);
511
+ window.addEventListener('keydown', onKey);
512
+ window.addEventListener('keyup', onKey);
513
+ window.addEventListener('blur', off);
514
+ return () => {
515
+ window.removeEventListener('keydown', onKey);
516
+ window.removeEventListener('keyup', onKey);
517
+ window.removeEventListener('blur', off);
518
+ };
519
+ }, [onRegion]);
520
+ /** A region shown for a moment (stage px), then faded out. */
521
+ const [flash, setFlash] = React.useState<{ box: StageBox; fading: boolean } | null>(null);
522
+ const flashTimers = React.useRef<number[]>([]);
523
+ const showFlash = React.useCallback((box: StageBox) => {
524
+ for (const id of flashTimers.current) window.clearTimeout(id);
525
+ setFlash({ box, fading: false });
526
+ flashTimers.current = [
527
+ window.setTimeout(() => setFlash((f) => (f ? { ...f, fading: true } : f)), 500),
528
+ window.setTimeout(() => setFlash(null), 850),
529
+ ];
530
+ }, []);
531
+ React.useEffect(() => () => { for (const id of flashTimers.current) window.clearTimeout(id); }, []);
532
+ React.useEffect(() => {
533
+ if (flashBox) showFlash(flashBox.box);
534
+ // eslint-disable-next-line react-hooks/exhaustive-deps
535
+ }, [flashBox?.seq, showFlash]);
536
+
537
+ /* bring forward / send back, worked out in the page when asked (stage.js arrange) */
538
+ const arrangeOf = (handle: number): StageArrange => (to) => ask<StageArrangeResult>('arrange', { handle, to });
539
+ const ask = React.useCallback(<T,>(op: string, args: object = {}): Promise<T | null> => (
540
+ previewRef.current?.stage?.<T>(op, args) ?? Promise.resolve(null)
541
+ ), [previewRef]);
542
+
543
+ const filmRect = (): DOMRect | null => previewRef.current?.frame?.()?.getBoundingClientRect() ?? host.current?.getBoundingClientRect() ?? null;
544
+ const viewScaleNow = (): number => {
545
+ const width = filmRect()?.width ?? stage.w;
546
+ return width > 0 ? width / stage.w : 1;
547
+ };
548
+ /** Pointer → the film's top left in overlay px (the overlay may cover the letterbox; the origin is the film's). */
549
+ const local = (e: { clientX: number; clientY: number }) => {
550
+ const r = filmRect();
551
+ return { x: e.clientX - (r?.left ?? 0), y: e.clientY - (r?.top ?? 0) };
552
+ };
553
+
554
+ const docRef = React.useRef(doc);
555
+ docRef.current = doc;
556
+ /** A clip on a locked track: nothing of it is moved, scaled, turned, nudged, cropped or typed into here. */
557
+ const lockedClip = (loc: string | null | undefined): boolean => trackLockedAt(docRef.current, loc);
558
+ const spansRef = React.useRef(spans);
559
+ spansRef.current = spans;
560
+ /**
561
+ * The inspector's look preview (a clip's CSS not kept yet) and film.html's style it was shown over: the frame follows
562
+ * a crop being dragged there, and stops once film.html's style is another (kept, undone).
563
+ */
564
+ const [lookLive, setLookLive] = React.useState<{ clipLoc: string; css: string; written: string } | null>(null);
565
+ const lookLiveRef = React.useRef(lookLive);
566
+ lookLiveRef.current = lookLive;
567
+ const infoOf = (loc: string) => {
568
+ const live = lookLiveRef.current;
569
+ return clipInfo(docRef.current, spansRef.current, stage, loc, live?.clipLoc === loc ? live : null);
570
+ };
571
+ /**
572
+ * The clips a marquee `area` goes around (stage px), by the part of each on the canvas (a part past its edge cannot
573
+ * be boxed): a clip is taken when the box goes around it.
574
+ */
575
+ const clipsInside = (list: readonly StageClipBox[], area: Rect): { id: string; loc: string }[] => {
576
+ const onCanvas = (r: Rect): Rect => {
577
+ const x = Math.max(0, r.x), y = Math.max(0, r.y);
578
+ return { x, y, w: Math.min(stage.w, r.x + r.w) - x, h: Math.min(stage.h, r.y + r.h) - y };
579
+ };
580
+ return list.flatMap((c) => {
581
+ const shown = onCanvas(shownBoxOf(c));
582
+ const loc = c.visible && shown.w > 0 && shown.h > 0 && rectInside(shown, area) ? clipLocOf(docRef.current, c.id) : undefined;
583
+ return loc ? [{ id: c.id, loc }] : [];
584
+ });
585
+ };
586
+ const clipsInsideRef = React.useRef(clipsInside);
587
+ clipsInsideRef.current = clipsInside;
588
+ /** A whole clip's outline for the hover: the part of it that shows, turned with it. */
589
+ const clipOutline = (id: string): StageOutline | null => {
590
+ const loc = clipLocOf(docRef.current, id);
591
+ const info = loc ? infoOf(loc) : null;
592
+ if (!info?.box) return null;
593
+ const frame = visibleFrame(info.t, info.box, info.crop);
594
+ const r = frameAabb(frame);
595
+ return { handle: -1, clip: id, rect: clientOf(r), ...(frame.r ? { frame } : {}) };
596
+ };
597
+ const clipOutlineRef = React.useRef(clipOutline);
598
+ clipOutlineRef.current = clipOutline;
599
+ /** Another clip as a snap or measuring target: the part its crop leaves (the film's stage tells whole boxes). */
600
+ const shownBoxOf = (c: { id: string; x: number; y: number; w: number; h: number }): Rect => {
601
+ const loc = clipLocOf(docRef.current, c.id);
602
+ const info = loc ? infoOf(loc) : null;
603
+ return info?.box && info.crop.some((n) => n > 0) ? frameAabb(visibleFrame(info.t, info.box, info.crop)) : { x: c.x, y: c.y, w: c.w, h: c.h };
604
+ };
605
+
606
+ const elementOf = React.useCallback((layer: StageLayer): StageElement => ({
607
+ kind: layer.kind,
608
+ loc: layer.loc,
609
+ label: layer.label,
610
+ clipId: layer.clip,
611
+ ...(clipLocOf(docRef.current, layer.clip) ? { clipLoc: clipLocOf(docRef.current, layer.clip) } : {}),
612
+ clipKind: 'mg',
613
+ ...(layer.text ? { text: { loc: layer.loc, value: layer.text.value, shape: layer.text.shape } } : {}),
614
+ ...(layer.picture ? { picture: layer.picture } : {}),
615
+ tag: layer.tag,
616
+ ...(layer.similar ? { similar: layer.similar } : {}),
617
+ style: layer.style,
618
+ declaredStyle: [],
619
+ parents: layer.parents,
620
+ override: layer.override,
621
+ ...(layer.instance != null ? { instance: layer.instance, instances: layer.instances } : {}),
622
+ }), []);
623
+ const hitOf = React.useCallback((layer: StageLayer): Hit => ({ rect: layer.rect, label: layer.label, element: elementOf(layer), layer }), [elementOf]);
624
+
625
+ /* ── in-place text editing state (the film runs the text box; it says when it ends) ── */
626
+ const [editingText, setEditingTextState] = React.useState<string | null>(null);
627
+ const textEdit = React.useRef<{ element: StageElement; layer: StageLayer } | null>(null);
628
+ /** The last presses on the picture, in stage px: a double-click is aimed by its first click (see onStageDoubleClick). */
629
+ const pressesRef = React.useRef<Press[]>([]);
630
+ /* while a line is typed in place the picture takes the pointer (move the caret, select words, click away to blur) */
631
+ const setEditingText = React.useCallback((loc: string | null) => {
632
+ const frame = previewRef.current?.frame?.();
633
+ if (frame) frame.style.pointerEvents = loc == null ? '' : 'auto';
634
+ setEditingTextState(loc);
635
+ }, [previewRef]);
636
+
637
+ const dropPick = React.useCallback(() => {
638
+ setMulti(null);
639
+ gestureRef.current = null;
640
+ liveTRef.current = null;
641
+ liveBoxRef.current = null;
642
+ setLiveT(null);
643
+ setLiveGuides(NO_GUIDES);
644
+ setDragging(false);
645
+ setPicked(null);
646
+ }, [setMulti, setPicked]);
647
+
648
+ const commitHit = React.useCallback((hit: Hit | null) => {
649
+ setMulti(null);
650
+ setPicked(hit);
651
+ onSelectElement?.(hit?.element ?? null);
652
+ }, [onSelectElement, setMulti, setPicked]);
653
+
654
+ /** Select these layers: one is one; several are their union box and each of them (see multi). */
655
+ const selectMany = React.useCallback((layers: readonly StageLayer[]) => {
656
+ if (!layers.length) { commitHit(null); return; }
657
+ if (layers.length === 1) { commitHit(hitOf(layers[0]!)); return; }
658
+ commitHit({ rect: unionRect(layers.map((l) => l.rect)) });
659
+ setMulti(layers.map((layer) => ({ layer, element: elementOf(layer) })));
660
+ }, [commitHit, elementOf, hitOf, setMulti]);
661
+ /** The layers in hand now (one counts too): Shift-click adds to / takes from them. */
662
+ const selectedLayers = (): StageLayer[] => {
663
+ if (multiRef.current) return multiRef.current.map((i) => i.layer);
664
+ const held = pickedRef.current;
665
+ return held?.layer && held.element && held.element.kind !== 'clip' ? [held.layer] : [];
666
+ };
667
+
668
+ /* ── several layers moved together ── */
669
+ type MultiDragItem = MultiItem & { base: LayerGeometry; scale: number; now: LayerGeometry };
670
+ const multiDrag = React.useRef<{ x: number; y: number; moved: boolean; rect0: ClientBox; items: MultiDragItem[] } | null>(null);
671
+ /* several layers scaled or turned by their box's handles: each one's center at the press (stage px) */
672
+ const layersPull = React.useRef<(GroupPull & { x: number; y: number; moved: boolean; box0: ClientBox; items: MultiDragItem[]; centers: { x: number; y: number }[] }) | null>(null);
673
+ /** The box around several layers while they are turned: the box they had, turned as far. */
674
+ const [layersTurn, setLayersTurn] = React.useState<{ box: ClientBox; turn: number } | null>(null);
675
+ /* several whole clips selected (on the timeline, or by a marquee): a press on one of them drags them all */
676
+ const clipsDrag = React.useRef<(GroupDrag & {
677
+ x: number; y: number; moved: boolean; items: ClipsDragItem[];
678
+ /* scaling: the corner or edge pulled ('n', 'se'…) and where it was (stage px); turning: the pointer's first angle */
679
+ handle: string | null; from: { x: number; y: number }; a0: number; box0: ClientBox;
680
+ }) | null>(null);
681
+ /** The frame around several clips while they are turned: the frame they had, turned as far. */
682
+ const [groupTurn, setGroupTurn] = React.useState<{ box: ClientBox; turn: number } | null>(null);
683
+ /** Where the clips being dragged are drawn now (their outlines follow the hand; film.html is written at the release). */
684
+ const [clipsMoved, setClipsMoved] = React.useState<readonly LinkedMark[] | null>(null);
685
+ const multiItemsNow = (): MultiDragItem[] => {
686
+ const vs = viewScaleNow();
687
+ return (multiRef.current ?? []).map((it) => ({ ...it, base: it.layer.geo.g, now: it.layer.geo.g, scale: it.layer.geo.k * vs }));
688
+ };
689
+ const multiPaint = React.useMemo(() => new Latest<MultiDragItem[]>(async (items) => {
690
+ const placed = await ask<({ rect: ClientBox; frame: StageFrame } | null)[]>('paintMany', {
691
+ items: items.map((it) => ({ handle: it.layer.handle, g: it.now })),
692
+ });
693
+ if (!placed) return;
694
+ const many = multiRef.current;
695
+ if (!many) return;
696
+ const next = many.map((it) => {
697
+ const i = items.findIndex((x) => x.layer.handle === it.layer.handle);
698
+ const p = i >= 0 ? placed[i] : null;
699
+ return p ? { ...it, layer: { ...it.layer, rect: p.rect, frame: p.frame, geo: { ...it.layer.geo, g: items[i]!.now } } } : it;
700
+ });
701
+ setMulti(next);
702
+ if (!multiDrag.current && !layersPull.current?.moved) setPicked((cur) => (cur ? { ...cur, rect: unionRect(next.map((i) => i.layer.rect)) } : cur));
703
+ }), [ask, setMulti, setPicked]);
704
+ /** Each layer moved by a screen offset (turned back by each one's parents' turn, at each one's scale), shown. */
705
+ const shiftLayers = (items: MultiDragItem[], dxOf: (i: number) => number, dyOf: (i: number) => number) => {
706
+ items.forEach((it, i) => { it.now = { ...it.base, t: movedOffset(it.base, dxOf(i), dyOf(i), it.layer.geo.parentR, it.scale) }; });
707
+ multiPaint.push(items.map((it) => ({ ...it })));
708
+ };
709
+ /** Several layers' bounds moved or scaled as the inspector's typed numbers say (FilmStageControl.placeLayers). */
710
+ const placeLayers = async (to: { x?: number; y?: number; w?: number; h?: number }) => {
711
+ const items = multiItemsNow();
712
+ const held = pickedRef.current;
713
+ if (items.length < 2 || !held || !onTransformLayers) return;
714
+ const b = held.rect;
715
+ if (to.x != null || to.y != null) {
716
+ const vs = viewScaleNow();
717
+ const dx = ((to.x ?? b.left) - b.left) * vs, dy = ((to.y ?? b.top) - b.top) * vs;
718
+ shiftLayers(items, () => dx, () => dy);
719
+ onTransformLayers(items.map((it) => ({ element: it.element, g: it.now })));
720
+ return;
721
+ }
722
+ const k = to.w != null ? to.w / b.width : to.h != null ? to.h / b.height : 1;
723
+ if (!(k > 0) || !Number.isFinite(k) || k === 1) return;
724
+ /* each one scaled by k about its own center, that center then moved as the bounds scale about their top left */
725
+ const scaled = await Promise.all(items.map(async (it) => {
726
+ const r = it.layer.rect;
727
+ const center = { x: b.left + (r.left + r.width / 2 - b.left) * k, y: b.top + (r.top + r.height / 2 - b.top) * k };
728
+ const g: LayerGeometry = { ...it.base, s: [it.base.s[0] * k, it.base.s[1] * k] };
729
+ const out = await ask<{ g: LayerGeometry }>('paint', { handle: it.layer.handle, g, center });
730
+ return { element: it.element, g: out?.g ?? g };
731
+ }));
732
+ onTransformLayers(scaled);
733
+ };
734
+ const placeLayersRef = React.useRef<(to: { x?: number; y?: number; w?: number; h?: number }) => void>(() => {});
735
+ placeLayersRef.current = (to) => { void placeLayers(to); };
736
+ /** Line several layers up with an edge / middle of their union box. */
737
+ const alignMany = (edge: AlignEdge) => {
738
+ const items = multiItemsNow();
739
+ if (items.length < 2 || !onTransformLayers) return;
740
+ const vs = viewScaleNow();
741
+ const rects = items.map((it) => scaleBox(it.layer.rect, vs));
742
+ const u = unionRect(rects);
743
+ const shifts = rects.map((r) => alignShift(edge, r, u));
744
+ shiftLayers(items, (i) => shifts[i]!.dx, (i) => shifts[i]!.dy);
745
+ onTransformLayers(items.map((it) => ({ element: it.element, g: it.now })));
746
+ };
747
+
748
+ /* ── measuring the selection again ── */
749
+ const syncPick = React.useCallback(async () => {
750
+ const many = multiRef.current;
751
+ if (many) {
752
+ const fresh = await ask<(StageLayer | null)[]>('layers', {
753
+ refs: many.map((it) => ({ handle: it.layer.handle, clip: it.layer.clip, at: it.layer.at, n: it.layer.n })),
754
+ });
755
+ if (!fresh || multiRef.current !== many || multiDrag.current || layersPull.current) return;
756
+ const next = many.flatMap((it, i) => {
757
+ const layer = fresh[i];
758
+ if (!layer) return [];
759
+ const element = elementOf(layer);
760
+ return [{ layer, element: JSON.stringify(element) === JSON.stringify(it.element) ? it.element : element }];
761
+ });
762
+ if (next.length < 2) { dropPick(); onSelectElement?.(null); return; }
763
+ setMulti(next);
764
+ const rect = unionRect(next.map((i) => i.layer.rect));
765
+ setPicked((cur) => (cur && !sameClientRect(cur.rect, rect) ? { ...cur, rect } : cur));
766
+ return;
767
+ }
768
+ const held = pickedRef.current;
769
+ const layer = held?.layer;
770
+ if (held?.element && held.element.kind !== 'clip' && layer) {
771
+ const el = held.element;
772
+ const fresh = await ask<StageLayer>('layer', { handle: layer.handle, clip: layer.clip, at: layer.at, n: layer.n });
773
+ if (pickedRef.current?.layer?.handle !== layer.handle || layerDrag.current || nudge.current) return;
774
+ /* found again only if it is the same kind of thing: an insert above shifts what a selector finds, and the next
775
+ edit would land on another layer */
776
+ const same = fresh && fresh.kind === el.kind && fresh.tag === el.tag;
777
+ if (!fresh || !same) {
778
+ if (fresh && !same) {
779
+ lostSince.current = null;
780
+ dropPick();
781
+ onSelectElement?.(null);
782
+ return;
783
+ }
784
+ /* the page may still be redrawing: let go only when it stays lost */
785
+ const now = performance.now();
786
+ if (lostSince.current == null) lostSince.current = now;
787
+ if (now - lostSince.current > LOST_PICK_GRACE_MS) {
788
+ lostSince.current = null;
789
+ dropPick();
790
+ onSelectElement?.(null);
791
+ } else {
792
+ window.setTimeout(() => { if (pickedRef.current?.layer?.handle === layer.handle) syncQueue.push(0); }, LOST_PICK_GRACE_MS);
793
+ }
794
+ return;
795
+ }
796
+ lostSince.current = null;
797
+ const next = elementOf(fresh);
798
+ /* a change just written: the film shows it, film.html has it a moment later; until then the numbers in hand stand */
799
+ if (el.loc && committing.current.has(el.loc)) next.override = el.override ?? null;
800
+ const changed = next.loc !== el.loc || next.clipLoc !== el.clipLoc || next.clipId !== el.clipId
801
+ || next.text?.value !== el.text?.value || next.text?.loc !== el.text?.loc
802
+ || JSON.stringify(next.style) !== JSON.stringify(el.style)
803
+ || JSON.stringify(next.override) !== JSON.stringify(el.override);
804
+ setPicked({ ...held, rect: fresh.rect, layer: fresh, element: changed ? next : el });
805
+ if (changed) onSelectElement?.(next);
806
+ return;
807
+ }
808
+ if (held?.element?.isMgOuter && held.element.clipLoc && !gestureRef.current && !clipNudge.current) {
809
+ const info = infoOf(held.element.clipLoc);
810
+ if (!info?.box) return;
811
+ const rect = clientOf(frameAabb(visibleFrame(info.t, info.box, info.crop)));
812
+ /* the inspector's preview has been written: the clip is drawn by film.html again. Told by film.html's box
813
+ having changed since the preview began, not only by the numbers matching: a typed width moves the place by
814
+ float noise (y 60.00000000000003) and the written box is rounded, so they may never match, and the frame and
815
+ the inspector would keep the preview's place through every later write (a Reset among them) */
816
+ if (liveTRef.current && (sameMgTransform(liveTRef.current, info.t)
817
+ || (liveBoxRef.current != null && liveBoxRef.current !== writtenBoxOf(docRef.current, held.element.clipLoc)))) {
818
+ liveTRef.current = null;
819
+ liveBoxRef.current = null;
820
+ setLiveT(null);
821
+ }
822
+ if (sameClientRect(held.rect, rect) && sameMgTransform(held.element.transform, info.t)) return;
823
+ setPicked({ ...held, rect, element: { ...held.element, transform: info.t, mgBox: info.box } });
824
+ }
825
+ // eslint-disable-next-line react-hooks/exhaustive-deps
826
+ }, [ask, dropPick, elementOf, onSelectElement, setMulti, setPicked, stage]);
827
+ const syncPickRef = React.useRef(syncPick);
828
+ syncPickRef.current = syncPick;
829
+ const syncQueue = React.useMemo(() => new Latest<number>(() => syncPickRef.current()), []);
830
+
831
+ /** Re-measure once a frame at most (and once more the frame after: a page just drawn may still be laying out). */
832
+ const geomRaf = React.useRef(0);
833
+ const syncGeomSoon = React.useCallback(() => {
834
+ if (gestureRef.current || layerDrag.current) return;
835
+ if (geomRaf.current) return;
836
+ geomRaf.current = requestAnimationFrame(() => {
837
+ geomRaf.current = 0;
838
+ if (gestureRef.current || layerDrag.current) return;
839
+ if (pickedRef.current) syncQueue.push(0);
840
+ requestAnimationFrame(() => { if (!gestureRef.current && pickedRef.current) syncQueue.push(0); });
841
+ });
842
+ }, [syncQueue]);
843
+ React.useEffect(() => () => { if (geomRaf.current) cancelAnimationFrame(geomRaf.current); geomRaf.current = 0; }, []);
844
+
845
+ /* the playhead moved a bucket on: what is selected stays selected, only where it is changed. A layer whose clip is
846
+ not on screen any more lets go: its box would stand over a picture it is not in */
847
+ const stale = staleBucket(timeMs);
848
+ /** Whether the clip `id` is on screen at the playhead (unknown yet: taken as on). */
849
+ const shownNow = (id: string) => {
850
+ const span = spans.find((s) => s.id === id);
851
+ return !span || spanShows(span, timeMs);
852
+ };
853
+ React.useEffect(() => {
854
+ const held = pickedRef.current;
855
+ if (!held) return;
856
+ /* several layers: one whose clip went lets them all go, as one does */
857
+ const clips = multiRef.current ? multiRef.current.map((i) => i.layer.clip) : [held.element?.kind !== 'clip' ? held.layer?.clip : undefined];
858
+ const gone = clips.some((clip) => {
859
+ const span = clip ? spans.find((s) => s.id === clip) : undefined;
860
+ return span && !spanShows(span, timeMs);
861
+ });
862
+ if (gone) {
863
+ dropPick();
864
+ onSelectElement?.(null);
865
+ return;
866
+ }
867
+ syncGeomSoon();
868
+ // eslint-disable-next-line react-hooks/exhaustive-deps
869
+ }, [stale, syncGeomSoon]);
870
+ /* film.html changed (an edit, an undo, the agent): the picture follows it a moment later */
871
+ React.useEffect(() => {
872
+ syncGeomSoon();
873
+ const later = window.setTimeout(syncGeomSoon, 150);
874
+ return () => window.clearTimeout(later);
875
+ }, [doc, spans, syncGeomSoon]);
876
+
877
+ /* ── the layer in hand: moved, scaled, turned by its own handles ── */
878
+ const layerRaf = React.useRef(0);
879
+ const layerMoveAt = React.useRef<{ p: { x: number; y: number }; mods: { shift: boolean; alt: boolean } } | null>(null);
880
+ type LayerDrag = {
881
+ kind: 'move' | 'scale' | 'stretch' | 'rotate';
882
+ /** Which handle: its place in the frame (see HANDLE_AT); not used to move or turn. */
883
+ hx: -1 | 0 | 1;
884
+ hy: -1 | 0 | 1;
885
+ /** Where the press was (overlay px). */
886
+ x: number;
887
+ y: number;
888
+ base: LayerGeometry;
889
+ /** The frame at the press (overlay px): scaling and turning go about it. */
890
+ frame0: FrameGeometry;
891
+ /** The box at the press (overlay px): a move snaps by it. */
892
+ aabb0: ClientBox;
893
+ /** Overlay px per override px. */
894
+ scale: number;
895
+ layer: StageLayer;
896
+ element: StageElement;
897
+ moved: boolean;
898
+ now: LayerGeometry;
899
+ /** What a move or a scale can snap to (overlay px), measured at the press. */
900
+ targets: readonly { x: number; y: number; w: number; h: number }[];
901
+ };
902
+ const layerDrag = React.useRef<LayerDrag | null>(null);
903
+ const [hint, setHint] = React.useState<{ x: number; y: number; text: string } | null>(null);
904
+
905
+ /** The film's answer to a paint: the layer in hand is where it is drawn now. */
906
+ const placeHeld = React.useCallback((handle: number, g: LayerGeometry, placed: { rect: ClientBox; frame: StageFrame }) => {
907
+ setPicked((cur) => (cur?.layer?.handle === handle
908
+ ? { ...cur, rect: placed.rect, layer: { ...cur.layer, rect: placed.rect, frame: placed.frame, geo: { ...cur.layer.geo, g } } }
909
+ : cur));
910
+ }, [setPicked]);
911
+ const layerPaint = React.useMemo(() => new Latest<{ d: { now: LayerGeometry }; handle: number; g: LayerGeometry; center?: { x: number; y: number } }>(async ({ d, handle, g, center }) => {
912
+ const r = await ask<{ g: LayerGeometry; rect: ClientBox; frame: StageFrame }>('paint', { handle, g, ...(center ? { center } : {}) });
913
+ if (!r) return;
914
+ d.now = r.g;
915
+ placeHeld(handle, r.g, r);
916
+ }), [ask, placeHeld]);
917
+
918
+ /** A layer that can be taken in hand and moved: a layer (not a whole clip), not locked. */
919
+ const layerMovable = (layer: StageLayer | undefined, element: StageElement | undefined): layer is StageLayer => (
920
+ Boolean(layer && element && element.kind !== 'clip' && onTransformLayer && !layer.locked && !lockedClip(element.clipLoc))
921
+ );
922
+
923
+ /** One step of a layer gesture: the new geometry by kind, shown, and the value tag. */
924
+ const stepLayerGesture = (p: { x: number; y: number }, mods: { shift: boolean; alt: boolean }) => {
925
+ const d = layerDrag.current;
926
+ if (!d) return;
927
+ let dx = p.x - d.x;
928
+ let dy = p.y - d.y;
929
+ if (!d.moved) {
930
+ if (d.kind === 'move' && Math.hypot(dx, dy) < 4) return;
931
+ d.moved = true;
932
+ setDragging(true);
933
+ setHover(null);
934
+ }
935
+ const vs = viewScaleNow();
936
+ let next = d.base;
937
+ let text = '';
938
+ let guides: MgGuide[] = [];
939
+ let center: { x: number; y: number } | undefined;
940
+ if (d.kind === 'move') {
941
+ /* Shift keeps one axis */
942
+ if (mods.shift) {
943
+ if (Math.abs(dx) >= Math.abs(dy)) dy = 0;
944
+ else dx = 0;
945
+ }
946
+ /* snaps to the stage's edges and middles and to other layers'; Alt turns it off for a moment */
947
+ if (!mods.alt) {
948
+ const hit = snapMove({ x: d.aabb0.left + dx, y: d.aabb0.top + dy, w: d.aabb0.width, h: d.aabb0.height }, d.targets, SNAP_PX);
949
+ dx += hit.dx;
950
+ dy += hit.dy;
951
+ guides = hit.guides.map((g) => ({ ...g, at: g.at / vs }));
952
+ }
953
+ next = { ...d.base, t: movedOffset(d.base, dx, dy, d.layer.geo.parentR, d.scale) };
954
+ d.now = next;
955
+ /* the inspector's X / Y: the unturned frame's top left, not the turned box's */
956
+ text = `X ${Math.round((d.frame0.cx + dx - d.frame0.w / 2) / vs)} Y ${Math.round((d.frame0.cy + dy - d.frame0.h / 2) / vs)}`;
957
+ } else if (d.kind === 'scale' || d.kind === 'stretch') {
958
+ /* the edges it moves snap as a move does (Alt scales about the center, unsnapped) */
959
+ const r = scaledLayer(d.frame0, d.base, { hx: d.hx, hy: d.hy }, { x: p.x - d.x, y: p.y - d.y }, { ...mods, proportional: d.kind === 'scale' },
960
+ mods.alt ? undefined : { targets: d.targets, tolerance: SNAP_PX });
961
+ next = r.g;
962
+ guides = r.guides.map((g) => ({ ...g, at: g.at / vs }));
963
+ center = { x: r.center.x / vs, y: r.center.y / vs };
964
+ text = `${Math.round(r.size.w / vs)} × ${Math.round(r.size.h / vs)}`;
965
+ } else {
966
+ next = turnedLayer(d.frame0, d.base, { x: d.x, y: d.y }, p, mods.shift);
967
+ /* about its own center, held where it was at the press */
968
+ center = { x: d.frame0.cx / vs, y: d.frame0.cy / vs };
969
+ text = `${Math.round(next.r)}°`;
970
+ }
971
+ layerPaint.push({ d, handle: d.layer.handle, g: next, ...(center ? { center } : {}) });
972
+ setLiveGuides((cur) => (sameGuides(cur, guides) ? cur : guides));
973
+ setHint({ x: p.x, y: p.y, text });
974
+ };
975
+
976
+ /* ── what the geometry callbacks hand out ── */
977
+ const commitLayer = (element: StageElement, layer: StageLayer, base: LayerGeometry, next: LayerGeometry) => {
978
+ if (!onTransformLayer || sameLayerGeometry(base, next)) return;
979
+ const override: NodeOverride = { ...(element.override ?? {}), t: next.t, s: compactScale(next.s[0], next.s[1]), r: next.r };
980
+ /* the numbers in hand are the new ones at once: the inspector and the next drag start from them */
981
+ setPicked((cur) => (cur?.element?.loc === element.loc && cur.element ? { ...cur, element: { ...cur.element, override } } : cur));
982
+ const loc = element.loc;
983
+ if (loc) committing.current.add(loc);
984
+ void onTransformLayer(element, next).then((failed) => {
985
+ if (loc) window.setTimeout(() => { committing.current.delete(loc); syncGeomSoon(); }, failed ? 0 : 400);
986
+ if (!failed) return;
987
+ void ask('release', { clip: layer.clip });
988
+ setPicked((cur) => (cur?.element?.loc === element.loc && cur.element ? { ...cur, element: { ...cur.element, override: element.override ?? null } } : cur));
989
+ syncGeomSoon();
990
+ });
991
+ };
992
+
993
+ /**
994
+ * Arrow keys nudge the layer in hand 1 stage px (Shift 10), shown at once and written 350ms after the last key: held
995
+ * down they come thirty a second, and a write per key would leave the picture behind the hand. Not while typing.
996
+ */
997
+ const nudge = React.useRef<{ element: StageElement; layer: StageLayer; base: LayerGeometry; now: LayerGeometry; timer: ReturnType<typeof setTimeout> | null } | null>(null);
998
+ const flushNudge = () => {
999
+ const n = nudge.current;
1000
+ nudge.current = null;
1001
+ if (!n) return;
1002
+ if (n.timer) clearTimeout(n.timer);
1003
+ commitLayer(n.element, n.layer, n.base, n.now);
1004
+ };
1005
+ const nudgeLayer = (e: KeyboardEvent) => {
1006
+ const held = pickedRef.current;
1007
+ const layer = held?.layer;
1008
+ if (!layerMovable(layer, held?.element)) return;
1009
+ e.preventDefault();
1010
+ const step = e.shiftKey ? 10 : 1;
1011
+ const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0;
1012
+ const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0;
1013
+ if (nudge.current && nudge.current.layer.handle !== layer.handle) flushNudge();
1014
+ const unit = 1 / (layer.geo.k || 1);
1015
+ const cur = nudge.current ?? { element: held!.element!, layer, base: layer.geo.g, now: layer.geo.g, timer: null };
1016
+ cur.now = { ...cur.now, t: [Math.round((cur.now.t[0] + dx * unit) * 10) / 10, Math.round((cur.now.t[1] + dy * unit) * 10) / 10] };
1017
+ layerPaint.push({ d: { now: cur.now }, handle: layer.handle, g: cur.now });
1018
+ if (cur.timer) clearTimeout(cur.timer);
1019
+ cur.timer = setTimeout(flushNudge, NUDGE_WRITE_MS);
1020
+ nudge.current = cur;
1021
+ };
1022
+ /* what was nudged and not written yet is written before the selection changes (only on that: every key renders) */
1023
+ const heldLoc = picked?.element?.loc ?? null;
1024
+ React.useEffect(() => () => { if (nudge.current) flushNudge(); }, [heldLoc]);
1025
+
1026
+ /* ── typing a line in place ── */
1027
+ /**
1028
+ * Double-click a line (or select it and press Enter): the line itself becomes typeable in the picture, all of it
1029
+ * selected (an SVG text, or a line with inline parts, gets a box of its own type over it). Esc cancels; a click
1030
+ * elsewhere (blur) or Enter commits (⌘Enter where Enter breaks a line).
1031
+ */
1032
+ const finishTextEdit = (commit: boolean) => {
1033
+ if (!textEdit.current) return;
1034
+ void ask('textFinish', { commit });
1035
+ };
1036
+ const finishTextEditRef = React.useRef(finishTextEdit);
1037
+ finishTextEditRef.current = finishTextEdit;
1038
+ /* the words as they are typed in the picture: the inspector shows them (its box and the layer's name) as they come */
1039
+ const onTyping = (e: Extract<StageEvent, { event: 'typing' }>) => {
1040
+ const edit = textEdit.current;
1041
+ const element = edit?.element;
1042
+ if (!edit || edit.layer.handle !== e.handle || !element?.text) return;
1043
+ /* a layer named by its words is named by the new ones */
1044
+ const label = element.label === element.text.value && e.value ? e.value : element.label;
1045
+ const next = { ...element, label, text: { ...element.text, value: e.value } };
1046
+ setPicked((cur) => (cur?.element?.loc === element.loc ? { ...cur, element: next } : cur));
1047
+ onSelectElement?.(next);
1048
+ };
1049
+ const onTextDone = (e: Extract<StageEvent, { event: 'text' }>) => {
1050
+ const edit = textEdit.current;
1051
+ if (!edit || edit.layer.handle !== e.handle) return;
1052
+ textEdit.current = null;
1053
+ setEditingText(null);
1054
+ syncGeomSoon();
1055
+ const { element, layer } = edit;
1056
+ if (!e.commit || !e.value || !onEditTextInline) {
1057
+ /* given up (or nothing new): the inspector goes back to the words it had */
1058
+ setPicked((cur) => (cur?.element?.loc === element.loc ? { ...cur, element } : cur));
1059
+ if (pickedRef.current?.element?.loc === element.loc) onSelectElement?.(element);
1060
+ return;
1061
+ }
1062
+ const value = e.value;
1063
+ const nextElement = { ...element, ...(element.text ? { text: { ...element.text, value } } : {}) };
1064
+ setPicked((cur) => (cur?.element?.loc === element.loc ? { ...cur, element: nextElement } : cur));
1065
+ void onEditTextInline(element, value, e.hold).then((failed) => {
1066
+ if (!failed) return;
1067
+ void ask('release', { clip: layer.clip });
1068
+ setPicked((cur) => (cur?.element?.loc === element.loc ? { ...cur, element } : cur));
1069
+ syncGeomSoon();
1070
+ });
1071
+ };
1072
+ const startTextEdit = (): boolean => {
1073
+ const held = pickedRef.current;
1074
+ const element = held?.element;
1075
+ const layer = held?.layer;
1076
+ if (!onEditTextInline || !layer || !element?.text || textEdit.current || layer.locked || lockedClip(element.clipLoc)) return false;
1077
+ if (nudge.current) flushNudge();
1078
+ textEdit.current = { element, layer };
1079
+ setEditingText(element.loc);
1080
+ setHover(null);
1081
+ previewRef.current?.focus?.();
1082
+ void ask<boolean>('textStart', { handle: layer.handle }).then((started) => {
1083
+ if (started || textEdit.current?.layer.handle !== layer.handle) return;
1084
+ textEdit.current = null;
1085
+ setEditingText(null);
1086
+ });
1087
+ return true;
1088
+ };
1089
+ const startTextEditRef = React.useRef(startTextEdit);
1090
+ startTextEditRef.current = startTextEdit;
1091
+ /* the selection changed, or the layer went: what was typed is committed (like a click elsewhere); not a box just
1092
+ opened on the new selection (a double-click selects and types at once) */
1093
+ React.useEffect(() => () => {
1094
+ if (textEdit.current && textEdit.current.element.loc !== pickedRef.current?.element?.loc) finishTextEditRef.current(true);
1095
+ }, [heldLoc]);
1096
+
1097
+ /* ── the keyboard (the window's, and the picture's when it has focus: the film passes those on) ── */
1098
+ const keyHandler = React.useRef<((e: KeyboardEvent) => void) | null>(null);
1099
+ React.useEffect(() => {
1100
+ if (!picked) { keyHandler.current = null; return undefined; }
1101
+ const onKey = (e: KeyboardEvent) => {
1102
+ /* keys pressed in an app's own panel are the app's (typing-target.ts) */
1103
+ if (inHostPanel(e.target)) return;
1104
+ const typingNow = () => {
1105
+ const focus = document.activeElement as HTMLElement | null;
1106
+ return Boolean(focus && (/^(INPUT|TEXTAREA|SELECT)$/.test(focus.tagName) || focus.isContentEditable));
1107
+ };
1108
+ /* cropping: Enter keeps the crop, Esc gives it up (before anything else hears them: Esc would let the clip go);
1109
+ the arrows do nothing (they would move the clip, or the playhead, under the crop). A button of the crop's bar
1110
+ reached with Tab presses itself. */
1111
+ if (cropRef.current) {
1112
+ if (typingNow() || (document.activeElement as HTMLElement | null)?.closest?.('[data-crop-bar]')) return;
1113
+ /* a panel open over the inspector closes on its own Esc */
1114
+ if (e.key === 'Escape' && document.querySelector('[data-popover-panel], [role="menu"], [role="listbox"], [role="dialog"]')) return;
1115
+ if (e.key === 'Enter' || e.key === 'Escape') {
1116
+ e.preventDefault();
1117
+ e.stopPropagation();
1118
+ finishCrop(e.key === 'Enter');
1119
+ } else if (e.key.startsWith('Arrow')) e.preventDefault();
1120
+ return;
1121
+ }
1122
+ /* the arrows are the playhead's unless the picture has the keyboard (arrowsMove) */
1123
+ if (e.key.startsWith('Arrow') && !arrowsMoveRef.current) return;
1124
+ if (e.key.startsWith('Arrow') && inspectorKey(e.target)) return;
1125
+ if (e.key.startsWith('Arrow') && multiRef.current && onTransformLayers && !multiRef.current.some((it) => lockedClip(it.element.clipLoc)) && !e.metaKey && !e.ctrlKey && !e.altKey) {
1126
+ if (!typingNow()) {
1127
+ e.preventDefault();
1128
+ const step = (e.shiftKey ? 10 : 1) * viewScaleNow();
1129
+ const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0;
1130
+ const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0;
1131
+ const items = multiItemsNow();
1132
+ shiftLayers(items, () => dx, () => dy);
1133
+ onTransformLayers(items.map((it) => ({ element: it.element, g: it.now })));
1134
+ const vs = viewScaleNow();
1135
+ setPicked((cur) => (cur ? { ...cur, rect: { ...cur.rect, left: cur.rect.left + dx / vs, top: cur.rect.top + dy / vs } } : cur));
1136
+ return;
1137
+ }
1138
+ }
1139
+ if (e.key.startsWith('Arrow')) {
1140
+ /* with a modifier, or from the timeline: not a nudge */
1141
+ if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return;
1142
+ if ((e.target as Element | null)?.closest?.('[data-timeline]')) return;
1143
+ if (typingNow()) return;
1144
+ if (!nudgeClip(e)) nudgeLayer(e);
1145
+ return;
1146
+ }
1147
+ /* the shortcuts are the menu's items: ⇧⌘H hide, ⇧⌘L lock, ] / [ to the front / back; grayed ones
1148
+ do nothing. A whole clip's delete / copy / paste are the timeline's keys. */
1149
+ const mod = e.metaKey || e.ctrlKey;
1150
+ const k = e.key.toLowerCase();
1151
+ const menuId = mod && e.shiftKey && k === 'h' ? 'hide'
1152
+ : mod && e.shiftKey && k === 'l' ? 'lock'
1153
+ : !mod && !e.altKey && e.key === ']' ? 'front'
1154
+ : !mod && !e.altKey && e.key === '[' ? 'back'
1155
+ : null;
1156
+ /* C crops the video or still in hand */
1157
+ if (!mod && !e.altKey && !e.shiftKey && k === 'c' && !textEdit.current && !typingNow()) {
1158
+ if (startCrop()) e.preventDefault();
1159
+ return;
1160
+ }
1161
+ if (menuId && menuFor && !textEdit.current && !typingNow()) {
1162
+ const many = multiRef.current;
1163
+ const held = pickedRef.current;
1164
+ const el = many ? many[0]!.element : held?.element;
1165
+ if (el && (many || el.kind !== 'clip')) {
1166
+ const item = menuFor(el, {
1167
+ arrange: many || !held?.layer ? null : arrangeOf(held.layer.handle),
1168
+ at: { x: 0, y: 0 },
1169
+ ...(many ? { multi: many.map((i) => i.element), align: alignMany } : {}),
1170
+ locked: Boolean(!many && held?.layer?.locked),
1171
+ }).find((entry) => 'label' in entry && entry.id === menuId);
1172
+ if (item && 'label' in item && !item.disabled && item.onSelect) {
1173
+ e.preventDefault();
1174
+ e.stopPropagation();
1175
+ item.onSelect();
1176
+ return;
1177
+ }
1178
+ }
1179
+ }
1180
+ if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
1181
+ const focus = document.activeElement as HTMLElement | null;
1182
+ if (focus && (/^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(focus.tagName) || focus.isContentEditable)) return;
1183
+ if (startTextEdit()) e.preventDefault();
1184
+ return;
1185
+ }
1186
+ if (e.key !== 'Escape') return;
1187
+ /* a number being typed in the inspector: the field gives it up itself (its own Esc runs after this one, which
1188
+ listens first), then it is blurred; the selection stays. Blurred here at once, the field
1189
+ would take the blur for a commit and keep what was typed. */
1190
+ const focus = document.activeElement as HTMLElement | null;
1191
+ if (focus?.closest('[data-film-inspector]') && /^(INPUT|TEXTAREA|SELECT)$/.test(focus.tagName)) {
1192
+ requestAnimationFrame(() => { if (document.activeElement === focus) focus.blur(); });
1193
+ return;
1194
+ }
1195
+ /* a panel open over the inspector (the color picker, the font list, a menu) closes on its own Esc: the
1196
+ selection stays */
1197
+ if (document.querySelector('[data-popover-panel], [role="menu"], [role="listbox"], [role="dialog"]')) return;
1198
+ dropPick();
1199
+ onSelectElement?.(null);
1200
+ };
1201
+ keyHandler.current = onKey;
1202
+ /* a press elsewhere lets go: selecting is "I mean this one", and the hand moving away ends it. Not the stage, its
1203
+ menus, the inspector (and its floating panels), the dividers or list boxes, nor an app's own panel (its chat
1204
+ points at what is selected). On the timeline only layers let go: a whole clip in hand is the timeline's own
1205
+ selection, which it keeps or clears itself. */
1206
+ const onDown = (e: PointerEvent) => {
1207
+ const el = e.target as HTMLElement | null;
1208
+ if (
1209
+ inHostPanel(el)
1210
+ || el?.closest('[data-film-select]')
1211
+ || el?.closest('[data-selection-toolbar]')
1212
+ || el?.closest('[role="menu"]')
1213
+ || el?.closest('[data-film-inspector]')
1214
+ || el?.closest('[role="separator"]')
1215
+ || el?.closest('[role="listbox"]')
1216
+ || el?.closest('[data-popover-panel]')
1217
+ ) return;
1218
+ if (el?.closest('[data-timeline]') && !multiRef.current && (!pickedRef.current?.element || pickedRef.current.element.kind === 'clip')) return;
1219
+ dropPick();
1220
+ onSelectElement?.(null);
1221
+ };
1222
+ /* capture: the arrow keys are the layer's first (it nudges and prevents default; the frame-step listener steps
1223
+ aside on defaultPrevented) */
1224
+ window.addEventListener('keydown', onKey, true);
1225
+ window.addEventListener('pointerdown', onDown, true);
1226
+ return () => {
1227
+ window.removeEventListener('keydown', onKey, true);
1228
+ window.removeEventListener('pointerdown', onDown, true);
1229
+ };
1230
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1231
+ }, [dropPick, onSelectElement, picked]);
1232
+
1233
+ /* ⌘A: the outermost layers on the picture, as a marquee around the whole canvas; from the window when the keyboard
1234
+ is not in a field, the timeline or another panel (the picture's own keys come from the film, below) */
1235
+ const selectAll = async () => {
1236
+ const all = await ask<StageLayer[]>('box', { x: 0, y: 0, w: stage.w, h: stage.h }) ?? [];
1237
+ if (all.length) selectMany(all);
1238
+ };
1239
+ const selectAllRef = React.useRef(selectAll);
1240
+ selectAllRef.current = selectAll;
1241
+ React.useEffect(() => {
1242
+ const onKey = (e: KeyboardEvent) => {
1243
+ if (!isSelectAll(e) || textEdit.current || cropRef.current) return;
1244
+ const el = e.target as HTMLElement | null;
1245
+ if (el && el !== document.body && !el.closest('[data-viewer-pane]') && !el.closest('[data-film-select]')) return;
1246
+ if (el?.closest('input, textarea, select, [contenteditable="true"]')) return;
1247
+ e.preventDefault();
1248
+ void selectAllRef.current();
1249
+ };
1250
+ window.addEventListener('keydown', onKey);
1251
+ return () => window.removeEventListener('keydown', onKey);
1252
+ }, []);
1253
+
1254
+ /* the film's own events: an in-place edit ended, a key pressed in the picture, another load of the film shown */
1255
+ React.useEffect(() => {
1256
+ const preview = previewRef.current;
1257
+ if (!preview?.onStage) return undefined;
1258
+ return preview.onStage((e) => {
1259
+ if (e.event === 'text') onTextDoneRef.current(e);
1260
+ else if (e.event === 'typing') onTypingRef.current(e);
1261
+ else if (e.event === 'key') {
1262
+ const k = e.key;
1263
+ if (isSelectAll(k)) { if (!textEdit.current && !cropRef.current) void selectAllRef.current(); }
1264
+ /* several clips selected: the picture's arrow keys move them all */
1265
+ else if (k.key.startsWith('Arrow') && arrowsMoveRef.current && !k.metaKey && !k.ctrlKey && !k.altKey && nudgeGroupRef.current(k)) return;
1266
+ else keyHandler.current?.(k);
1267
+ }
1268
+ else {
1269
+ if (textEdit.current) { textEdit.current = null; setEditingText(null); }
1270
+ syncFilmOffsetRef.current();
1271
+ syncGeomSoon();
1272
+ }
1273
+ });
1274
+ }, [previewRef, syncGeomSoon]);
1275
+ const onTextDoneRef = React.useRef(onTextDone);
1276
+ onTextDoneRef.current = onTextDone;
1277
+ const onTypingRef = React.useRef(onTyping);
1278
+ onTypingRef.current = onTyping;
1279
+
1280
+ /* ── the clips selected on the timeline ── */
1281
+ const [linkedMarks, setLinkedMarks] = React.useState<readonly LinkedMark[]>(NO_MARKS);
1282
+ React.useEffect(() => {
1283
+ const next: LinkedMark[] = [];
1284
+ for (const loc of linkedLocs ?? []) {
1285
+ const info = clipInfo(doc, spans, stage, loc);
1286
+ /* only a clip on screen now: one selected elsewhere in time has nothing here to outline */
1287
+ if (info?.box && shownNow(info.id)) next.push({ loc, t: info.t, box: info.box });
1288
+ }
1289
+ setLinkedMarks((cur) => (sameLinkedMarks(cur, next) ? cur : next));
1290
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1291
+ }, [doc, spans, stage, linkedLocs, stale]);
1292
+
1293
+ /**
1294
+ * One block selected on the timeline is in hand here at once (a frame with handles, not an outline): selecting it
1295
+ * there means "I want to move this one". Several stay outlines (taking one of them in hand would decide for the
1296
+ * person); when there is no longer exactly one, the handles go.
1297
+ */
1298
+ React.useEffect(() => {
1299
+ const locs = linkedLocs ?? [];
1300
+ if (locs.length !== 1) {
1301
+ /* only a clip in hand: a layer clicked on the picture was not selected on the timeline */
1302
+ if (pickedRef.current?.element?.isMgOuter) dropPick();
1303
+ return;
1304
+ }
1305
+ const only = locs[0]!;
1306
+ const held = pickedRef.current?.element;
1307
+ const info = clipInfo(doc, spans, stage, only);
1308
+ /* not on screen now (the playhead elsewhere): no frame over a picture it is not in; it comes back with it */
1309
+ if (info && !shownNow(info.id)) { if (held?.isMgOuter) dropPick(); return; }
1310
+ if (held?.isMgOuter && held.clipLoc === only) return;
1311
+ /* a sound has no picture: the handles of the clip held before go */
1312
+ if (!info) { if (pickedRef.current?.element?.isMgOuter) dropPick(); return; }
1313
+ /* the inspector's preview was of the clip held before */
1314
+ liveTRef.current = null;
1315
+ liveBoxRef.current = null;
1316
+ setLiveT(null);
1317
+ commitHit(describeClip(info, stage));
1318
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1319
+ }, [linkedLocs, stale, commitHit, dropPick, spans]);
1320
+
1321
+ /* the inspector's live clip preview: only the clip in hand */
1322
+ React.useEffect(() => {
1323
+ if (!clipPreviewRef) return undefined;
1324
+ clipPreviewRef.current = (clipLoc, patch) => {
1325
+ const outer = pickedRef.current?.element;
1326
+ if (!outer?.isMgOuter || outer.clipLoc !== clipLoc || !outer.mgBox || !outer.transform || !outer.clipId) return;
1327
+ const next = patch ? { ...outer.transform, ...patch } : outer.transform;
1328
+ void ask('place', { clip: outer.clipId, box: patch ? compactBox(next, outer.mgBox) : null });
1329
+ /* film.html's box as the preview began (kept through the preview's every step): once it changes, the preview
1330
+ is over (see syncPick) */
1331
+ if (patch && !liveBoxRef.current) liveBoxRef.current = writtenBoxOf(docRef.current, clipLoc);
1332
+ if (!patch) liveBoxRef.current = null;
1333
+ liveTRef.current = patch ? next : null;
1334
+ setLiveT(patch ? next : null);
1335
+ };
1336
+ return () => { clipPreviewRef.current = null; };
1337
+ }, [ask, clipPreviewRef]);
1338
+
1339
+ /* the inspector's live look preview: a clip in CSS not kept yet */
1340
+ React.useEffect(() => {
1341
+ if (!lookPreviewRef) return undefined;
1342
+ lookPreviewRef.current = (clipLoc, css) => {
1343
+ const at = parseFilmDocLoc(clipLoc);
1344
+ const clip = at ? docRef.current?.tracks[at.track]?.clips[at.clip] : undefined;
1345
+ if (!clip?.id) return;
1346
+ void ask('look', { clip: clip.id, css });
1347
+ setLookLive(css == null ? null : { clipLoc, css, written: clip.style ?? '' });
1348
+ };
1349
+ return () => { lookPreviewRef.current = null; };
1350
+ }, [ask, lookPreviewRef]);
1351
+
1352
+ /* the inspector's live layer previews */
1353
+ React.useEffect(() => {
1354
+ if (!controlRef) return undefined;
1355
+ const clean = <V,>(o: Record<string, V | undefined>) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
1356
+ const named = (element: StageElement) => {
1357
+ const target = layerTarget(element);
1358
+ return target && element.clipId ? { clip: element.clipId, ...target } : null;
1359
+ };
1360
+ controlRef.current = {
1361
+ previewStyle(element, patch) {
1362
+ const at = named(element);
1363
+ if (at) void ask('tweak', { ...at, patch: { style: clean(patch as Record<string, string | number | null | undefined>) } }).then(syncGeomSoon);
1364
+ },
1365
+ async holdStyle(element, style, text) {
1366
+ const at = named(element);
1367
+ return (at ? await ask<Record<string, string | null>>('hold', { ...at, style: clean(style), ...(text != null ? { text } : {}) }) : null) ?? {};
1368
+ },
1369
+ previewText(element, value) {
1370
+ const at = named(element);
1371
+ if (at) void ask('tweak', { ...at, patch: { text: value } }).then(syncGeomSoon);
1372
+ },
1373
+ async previewOverride(element, patch, pivot) {
1374
+ const at = named(element);
1375
+ if (!at) return patch;
1376
+ const geometry = clean({ t: patch.t, s: patch.s, r: patch.r });
1377
+ if (pivot && (patch.s != null || patch.r != null)) {
1378
+ const base = element.override ?? {};
1379
+ const t = await ask<[number, number]>('pivot', { ...at, base: clean({ t: base.t, s: base.s, r: base.r }), patch: geometry, pivot });
1380
+ syncGeomSoon();
1381
+ return t ? { ...patch, t } : patch;
1382
+ }
1383
+ await ask('tweak', { ...at, patch: geometry });
1384
+ syncGeomSoon();
1385
+ return patch;
1386
+ },
1387
+ async riders(element, patch) {
1388
+ const at = named(element);
1389
+ return (at ? await ask<{ at: string; n?: number; patch: OverrideGeometry }[]>('riders', { ...at, patch }) : null) ?? [];
1390
+ },
1391
+ release(element) {
1392
+ if (element.clipId) void ask('release', { clip: element.clipId }).then(syncGeomSoon);
1393
+ },
1394
+ async describe(element) {
1395
+ const at = named(element);
1396
+ const layer = at ? await ask<StageLayer>('layer', at) : null;
1397
+ return layer ? elementOf(layer) : null;
1398
+ },
1399
+ refresh: syncGeomSoon,
1400
+ startCrop: () => startCropRef.current(),
1401
+ editMask: setMaskSession,
1402
+ picked: () => pickedItemsRef.current(),
1403
+ flushPending: () => flushPendingRef.current(),
1404
+ placeLayers: (to) => placeLayersRef.current(to),
1405
+ };
1406
+ return () => { controlRef.current = null; };
1407
+ }, [ask, controlRef, elementOf, syncGeomSoon]);
1408
+
1409
+ /* the inspector's breadcrumb (and Select parent): select the layer at that place; not found, nothing changes */
1410
+ const requestSeq = selectRequest?.seq;
1411
+ React.useEffect(() => {
1412
+ if (!selectRequest) return;
1413
+ const clipLoc = selectRequest.clipLoc ?? pickedRef.current?.element?.clipLoc;
1414
+ const at = clipLoc ? parseFilmDocLoc(clipLoc) : null;
1415
+ const clipId = at ? doc.tracks[at.track]?.clips[at.clip]?.id : pickedRef.current?.layer?.clip;
1416
+ if (!clipId) return;
1417
+ const token = ++pressSeq.current;
1418
+ void ask<StageLayer>('layer', { clip: clipId, loc: selectRequest.loc, any: true }).then((layer) => {
1419
+ if (!layer || token !== pressSeq.current) return;
1420
+ setHover(null);
1421
+ commitHit(hitOf(layer));
1422
+ });
1423
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1424
+ }, [requestSeq]);
1425
+
1426
+ /* ── the hover outline: asked once a frame, one question at a time ── */
1427
+ const moveRaf = React.useRef(0);
1428
+ const moveAt = React.useRef<{ cx: number; cy: number; shift?: boolean; alt?: boolean } | null>(null);
1429
+ React.useEffect(() => () => { if (moveRaf.current) cancelAnimationFrame(moveRaf.current); }, []);
1430
+ /* every thing a click can take is outlined under the pointer: a page's layer, else the clip there (a page by its
1431
+ empty part, as its layers are: several pages on the picture are each found) */
1432
+ const hoverQueue = React.useMemo(() => new Latest<{ x: number; y: number; from: number | null }>(async (p) => {
1433
+ let found = await ask<StageOutline>('hit', p);
1434
+ if (!found) {
1435
+ const id = await ask<string>('clipAt', { x: p.x, y: p.y });
1436
+ found = id ? clipOutlineRef.current(id) : null;
1437
+ }
1438
+ if (!moveAt.current) return;
1439
+ /* the same thing in the same place: keep the object (sixty questions a second must not redraw the outline) */
1440
+ setHover((cur) => (found
1441
+ ? (cur && cur.handle === found.handle && cur.clip === found.clip && sameClientRect(cur.rect, found.rect) ? cur : found)
1442
+ : (cur ? null : cur)));
1443
+ }), [ask]);
1444
+ /* while a marquee is drawn: what letting go would select, outlined as it goes — the clips it goes around,
1445
+ else the layers wholly inside it. The clips are asked once, at the press */
1446
+ const marqueeClips = React.useRef<Promise<StageClipBox[] | null> | null>(null);
1447
+ const [marqueeHits, setMarqueeHits] = React.useState<readonly StageOutline[]>([]);
1448
+ const marqueeQueue = React.useMemo(() => new Latest<Rect | null>(async (area) => {
1449
+ if (!area) { setMarqueeHits([]); return; }
1450
+ const clips = clipsInsideRef.current((await marqueeClips.current) ?? [], area);
1451
+ if (!draftRef.current) return;
1452
+ if (clips.length) {
1453
+ setMarqueeHits(clips.flatMap((c) => clipOutlineRef.current(c.id) ?? []));
1454
+ return;
1455
+ }
1456
+ const layers = await ask<StageLayer[]>('box', area) ?? [];
1457
+ if (!draftRef.current) return;
1458
+ setMarqueeHits(layers.map((l) => ({ handle: l.handle, rect: l.rect, ...(l.frame.r ? { frame: l.frame } : {}) })));
1459
+ }), [ask]);
1460
+ React.useEffect(() => { if (!draft) { marqueeQueue.push(null); marqueeClips.current = null; } }, [draft, marqueeQueue]);
1461
+ /* a layer pointed at from the Layers panel: outlined where it is drawn, until the pointer leaves the row */
1462
+ const pointClip = pointLayer?.clipId;
1463
+ const pointLoc = pointLayer?.loc;
1464
+ const pointed = React.useRef(false);
1465
+ React.useEffect(() => {
1466
+ if (!pointClip || !pointLoc) {
1467
+ if (pointed.current) { pointed.current = false; setHover(null); }
1468
+ return undefined;
1469
+ }
1470
+ let live = true;
1471
+ void ask<StageLayer>('layer', { clip: pointClip, loc: pointLoc, any: true }).then((layer) => {
1472
+ if (!live) return;
1473
+ pointed.current = Boolean(layer);
1474
+ setHover(layer ? { handle: layer.handle, rect: layer.rect, ...(layer.frame.r ? { frame: layer.frame } : {}) } : null);
1475
+ });
1476
+ return () => { live = false; };
1477
+ }, [pointClip, pointLoc, ask]);
1478
+ /* ── a picture of the media pane dragged over the picture: the picture inside a page under it takes its place ── */
1479
+ const [dropOn, setDropOn] = React.useState<StageLayer | null>(null);
1480
+ const dropOnRef = React.useRef(dropOn);
1481
+ dropOnRef.current = dropOn;
1482
+ const dropQueue = React.useMemo(() => new Latest<{ x: number; y: number } | null>(async (p) => {
1483
+ if (!p) { setDropOn(null); return; }
1484
+ const layer = await ask<StageLayer>('hit', { ...p, detail: true });
1485
+ const fits = layer?.picture && !layer.locked && !lockedClip(clipLocOf(docRef.current, layer.clip)) ? layer : null;
1486
+ setDropOn((cur) => (fits && cur && cur.handle === fits.handle ? cur : fits));
1487
+ }), [ask]); // eslint-disable-line react-hooks/exhaustive-deps
1488
+ /** A picture of the project is being dragged (not a thing of Saved: it is brought in only where it lands as a clip). */
1489
+ const draggingPicture = (e: React.DragEvent) => {
1490
+ if (!onDropPicture || !e.dataTransfer.types.includes(RESOURCE_DRAG_TYPE)) return false;
1491
+ const item = peekResourceDrag();
1492
+ return !item || (item.kind === 'image' && !item.saved);
1493
+ };
1494
+ const pictureDrag = {
1495
+ onDragOver: (e: React.DragEvent) => {
1496
+ if (!draggingPicture(e)) return;
1497
+ e.preventDefault();
1498
+ e.dataTransfer.dropEffect = dropOnRef.current ? 'copy' : 'none';
1499
+ const p = local(e);
1500
+ const vs = viewScaleNow();
1501
+ dropQueue.push({ x: p.x / vs, y: p.y / vs });
1502
+ },
1503
+ onDragLeave: (e: React.DragEvent) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) dropQueue.push(null); },
1504
+ onDrop: (e: React.DragEvent) => {
1505
+ const target = dropOnRef.current;
1506
+ dropQueue.push(null);
1507
+ if (!draggingPicture(e)) return;
1508
+ e.preventDefault();
1509
+ const file = readResourceDragData(e.dataTransfer);
1510
+ if (!target || !file || file.saved || file.kind !== 'image') return;
1511
+ onDropPicture?.(elementOf(target), file.path);
1512
+ },
1513
+ };
1514
+ /* the layer under the pointer, told to the app: asked of the stage once each time it is another one */
1515
+ const hoverHandle = hover && !hover.clip ? hover.handle : null;
1516
+ React.useEffect(() => {
1517
+ if (!onHoverLayer) return undefined;
1518
+ if (hoverHandle == null) { onHoverLayer(null); return undefined; }
1519
+ let live = true;
1520
+ void ask<StageLayer>('layer', { handle: hoverHandle }).then((layer) => {
1521
+ if (!live) return;
1522
+ onHoverLayer(layer ? { element: elementOf(layer), box: { x: layer.rect.left, y: layer.rect.top, w: layer.rect.width, h: layer.rect.height } } : null);
1523
+ });
1524
+ return () => { live = false; };
1525
+ }, [hoverHandle, onHoverLayer, ask, elementOf]);
1526
+
1527
+ /* ── a whole clip in hand: moved, scaled, turned ── */
1528
+ const clipPlace = React.useMemo(() => new Latest<{ clip: string; box: FilmBox | null }>(async (a) => { await ask('place', a); }), [ask]);
1529
+ const clipsPlace = React.useMemo(() => new Latest<{ clip: string; box: FilmBox | null }[]>(async (list) => {
1530
+ await Promise.all(list.map((a) => ask('place', a)));
1531
+ }), [ask]);
1532
+ const applyMgGesture = (stagePt: { x: number; y: number }, shift: boolean, alt: boolean) => {
1533
+ const g = gestureRef.current;
1534
+ if (!g) return;
1535
+ if (g.pendingClick) {
1536
+ if (Math.hypot(stagePt.x - g.start.x, stagePt.y - g.start.y) < MARQUEE_MIN_PX) return;
1537
+ g.pendingClick = false;
1538
+ setDragging(true);
1539
+ }
1540
+ const tolerance = SNAP_PX / Math.max(viewScaleNow(), 0.01);
1541
+ let next = g.origin;
1542
+ let guides: MgGuide[] = [];
1543
+ let shown = visibleFrame(g.origin, g.box, g.crop);
1544
+ if (g.kind === 'move') {
1545
+ const dx = stagePt.x - g.start.x;
1546
+ const dy = stagePt.y - g.start.y;
1547
+ next = shift
1548
+ ? (Math.abs(dx) > Math.abs(dy) ? moveTransform(g.origin, dx, 0) : moveTransform(g.origin, 0, dy))
1549
+ : moveTransform(g.origin, dx, dy);
1550
+ /* snaps to the stage and the other clips; Alt turns it off; a turned clip never snaps (its box is not its shape) */
1551
+ if (!alt && !g.origin.rotate) {
1552
+ const hit = snapMove(frameAabb(visibleFrame(next, g.box, g.crop)), g.snapTargets, tolerance);
1553
+ if (hit.dx || hit.dy) next = moveTransform(next, hit.dx, hit.dy);
1554
+ guides = hit.guides;
1555
+ }
1556
+ } else if (g.kind === 'rotate') {
1557
+ next = rotateTransform(g.origin, g.box, g.start, stagePt, shift ? 15 : 0);
1558
+ } else if (g.kind === 'scale' && g.handle) {
1559
+ const f0 = shown;
1560
+ /* a corner keeps the proportions and an edge stretches its side, Shift the other way round; a page's
1561
+ box is never stretched. Alt: about the center, unsnapped (it moves both sides) */
1562
+ const proportional = !g.stretchable || (g.handle.hx && g.handle.hy ? !shift : shift);
1563
+ const r = resizedFrame(f0, g.handle, { x: stagePt.x - g.start.x, y: stagePt.y - g.start.y }, { proportional, fromCenter: alt },
1564
+ alt ? undefined : { targets: g.snapTargets, tolerance });
1565
+ /* the picture 0.01–8 times its own size; in proportion, both bounded alike */
1566
+ const bound = (k: number, scale: number) => Math.min(8, Math.max(0.01, scale * k)) / scale;
1567
+ let kx = bound(r.w / f0.w, g.origin.scaleX);
1568
+ let ky = bound(r.h / f0.h, g.origin.scaleY);
1569
+ if (proportional) kx = ky = r.w >= f0.w ? Math.min(kx, ky) : Math.max(kx, ky);
1570
+ const size = { w: f0.w * kx, h: f0.h * ky };
1571
+ const c = pivotCenter(f0, size, r.pivot);
1572
+ next = transformOfVisible(g.origin, g.box, g.crop, { cx: c.x, cy: c.y, w: size.w, h: size.h, r: f0.r });
1573
+ guides = r.guides;
1574
+ }
1575
+ shown = visibleFrame(next, g.box, g.crop);
1576
+ clipPlace.push({ clip: g.clipId, box: compactBox(next, g.box) });
1577
+ liveTRef.current = next;
1578
+ setLiveT(next);
1579
+ setLiveGuides((cur) => (sameGuides(cur, guides) ? cur : guides));
1580
+ const vs = viewScaleNow();
1581
+ setHint({
1582
+ x: stagePt.x * vs,
1583
+ y: stagePt.y * vs,
1584
+ text: g.kind === 'rotate' ? `${Math.round(next.rotate)}°`
1585
+ : g.kind === 'move' ? `X ${Math.round((g.box.x ?? 0) + next.x)} Y ${Math.round((g.box.y ?? 0) + next.y)}`
1586
+ : `${Math.round(shown.w)} × ${Math.round(shown.h)}`,
1587
+ });
1588
+ };
1589
+
1590
+ /** A whole clip's new numbers: in hand at once (the inspector and the next drag start from them), its outline too. */
1591
+ const holdClipAt = (g: { clipLoc: string; box: MgBox; crop: Crop }, at: MgTransform) => {
1592
+ const rect = clientOf(frameAabb(visibleFrame(at, g.box, g.crop)));
1593
+ const cur = pickedRef.current;
1594
+ if (cur?.element && cur.element.clipLoc === g.clipLoc) {
1595
+ const element = { ...cur.element, transform: at, mgBox: g.box };
1596
+ setPicked((now) => (now?.element?.clipLoc === g.clipLoc ? { ...now, rect, element } : now));
1597
+ onSelectElement?.(element);
1598
+ }
1599
+ /* the linked outline moves with it at once (film.html follows a moment later) */
1600
+ setLinkedMarks((marks) => (marks.some((m) => m.loc === g.clipLoc) ? marks.map((m) => (m.loc === g.clipLoc ? { ...m, t: at } : m)) : marks));
1601
+ };
1602
+ /** Written as film.html's `box`; refused, it goes back where it started (the caller says why). */
1603
+ const commitClipBox = (g: { clipLoc: string; clipId: string; box: MgBox; crop: Crop; origin: MgTransform }, t: MgTransform) => {
1604
+ holdClipAt(g, t);
1605
+ if (!onEditClip) return;
1606
+ void onEditClip([{ loc: g.clipLoc, prop: 'box', value: compactBox(t, g.box) }]).then((failed) => {
1607
+ if (!failed) return;
1608
+ clipPlace.push({ clip: g.clipId, box: null });
1609
+ holdClipAt(g, g.origin);
1610
+ });
1611
+ };
1612
+
1613
+ const endMgGesture = () => {
1614
+ const g = gestureRef.current;
1615
+ const t = liveTRef.current;
1616
+ gestureRef.current = null;
1617
+ liveTRef.current = null;
1618
+ liveBoxRef.current = null;
1619
+ setLiveT(null);
1620
+ setLiveGuides(NO_GUIDES);
1621
+ setDragging(false);
1622
+ setHint(null);
1623
+ if (g && t) commitClipBox(g, t);
1624
+ };
1625
+
1626
+ /** The arrow keys move the clip in hand as they do a layer (see nudge): shown at once, written after the last key. */
1627
+ const clipNudge = React.useRef<{
1628
+ clipLoc: string; clipId: string; box: MgBox; crop: Crop; origin: MgTransform; now: MgTransform; timer: ReturnType<typeof setTimeout> | null;
1629
+ } | null>(null);
1630
+ const flushClipNudge = () => {
1631
+ const n = clipNudge.current;
1632
+ clipNudge.current = null;
1633
+ if (!n) return;
1634
+ if (n.timer) clearTimeout(n.timer);
1635
+ liveTRef.current = null;
1636
+ setLiveT(null);
1637
+ if (!sameMgTransform(n.origin, n.now)) commitClipBox(n, n.now);
1638
+ };
1639
+ const nudgeClip = (e: KeyboardEvent): boolean => {
1640
+ const outer = pickedRef.current?.element;
1641
+ const origin = liveTRef.current ?? outer?.transform;
1642
+ if (!onEditClip || !outer?.isMgOuter || !outer.clipLoc || !outer.clipId || !outer.mgBox || !origin || gestureRef.current || cropRef.current || lockedClip(outer.clipLoc)) return false;
1643
+ e.preventDefault();
1644
+ const step = e.shiftKey ? 10 : 1;
1645
+ const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0;
1646
+ const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0;
1647
+ if (clipNudge.current && clipNudge.current.clipLoc !== outer.clipLoc) flushClipNudge();
1648
+ const cur = clipNudge.current ?? {
1649
+ clipLoc: outer.clipLoc, clipId: outer.clipId, box: outer.mgBox, crop: infoOf(outer.clipLoc)?.crop ?? NO_CROP, origin, now: origin, timer: null,
1650
+ };
1651
+ cur.now = moveTransform(cur.now, dx, dy);
1652
+ clipPlace.push({ clip: cur.clipId, box: compactBox(cur.now, cur.box) });
1653
+ liveTRef.current = cur.now;
1654
+ setLiveT(cur.now);
1655
+ if (cur.timer) clearTimeout(cur.timer);
1656
+ cur.timer = setTimeout(flushClipNudge, NUDGE_WRITE_MS);
1657
+ clipNudge.current = cur;
1658
+ return true;
1659
+ };
1660
+
1661
+ /* several clips selected: the arrow keys move them all (Shift: 10 px), shown at once and written once they stop */
1662
+ const groupNudge = React.useRef<{ items: ClipsDragItem[]; dx: number; dy: number; timer: ReturnType<typeof setTimeout> | null } | null>(null);
1663
+ const flushGroupNudge = () => {
1664
+ const n = groupNudge.current;
1665
+ groupNudge.current = null;
1666
+ if (!n) return;
1667
+ if (n.timer) clearTimeout(n.timer);
1668
+ const moved = n.items.map((it) => ({ loc: it.loc, box: it.box, t: { ...it.t0, x: it.t0.x + n.dx, y: it.t0.y + n.dy } }));
1669
+ setLinkedMarks((marks) => marks.map((m) => moved.find((it) => it.loc === m.loc) ? { ...m, t: moved.find((it) => it.loc === m.loc)!.t } : m));
1670
+ setClipsMoved(null);
1671
+ void clipsPlace.idle().then(() => onEditClip?.(moved.map((it) => ({ loc: it.loc, prop: 'box' as const, value: compactBox(it.t, it.box) }))));
1672
+ };
1673
+ const nudgeGroup = (e: KeyboardEvent): boolean => {
1674
+ const locs = linkedLocs ?? [];
1675
+ if (!onEditClip || locs.length < 2 || clipsDrag.current || cropRef.current || locs.some((loc) => lockedClip(loc))) return false;
1676
+ e.preventDefault();
1677
+ const step = e.shiftKey ? 10 : 1;
1678
+ const n = groupNudge.current ?? {
1679
+ items: locs.flatMap((loc) => { const info = infoOf(loc); return info?.box ? [{ loc, id: info.id, t0: info.t, box: info.box }] : []; }),
1680
+ dx: 0, dy: 0, timer: null,
1681
+ };
1682
+ n.dx += e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0;
1683
+ n.dy += e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0;
1684
+ const moved = n.items.map((it) => ({ loc: it.loc, box: it.box, t: { ...it.t0, x: it.t0.x + n.dx, y: it.t0.y + n.dy } }));
1685
+ setClipsMoved(moved);
1686
+ clipsPlace.push(n.items.map((it, i) => ({ clip: it.id, box: compactBox(moved[i]!.t, it.box) })));
1687
+ if (n.timer) clearTimeout(n.timer);
1688
+ n.timer = setTimeout(flushGroupNudge, NUDGE_WRITE_MS);
1689
+ groupNudge.current = n;
1690
+ return true;
1691
+ };
1692
+ const nudgeGroupRef = React.useRef(nudgeGroup);
1693
+ nudgeGroupRef.current = nudgeGroup;
1694
+ React.useEffect(() => {
1695
+ const onKey = (e: KeyboardEvent) => {
1696
+ if (!e.key.startsWith('Arrow') || !arrowsMoveRef.current || e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return;
1697
+ const el = e.target as HTMLElement | null;
1698
+ if (inHostPanel(el) || inspectorKey(el) || el?.closest('input, textarea, select, [contenteditable="true"], [data-timeline]')) return;
1699
+ nudgeGroupRef.current(e);
1700
+ };
1701
+ window.addEventListener('keydown', onKey, true);
1702
+ return () => window.removeEventListener('keydown', onKey, true);
1703
+ }, []);
1704
+
1705
+ /* what the arrow keys moved and is not written yet, written now (the editor asks before an undo or another edit) */
1706
+ flushPendingRef.current = () => {
1707
+ if (nudge.current) flushNudge();
1708
+ if (clipNudge.current) flushClipNudge();
1709
+ if (groupNudge.current) flushGroupNudge();
1710
+ };
1711
+
1712
+ /* ── crop mode: a video or still in hand, cropped in place ── */
1713
+ /** The crop being drawn: in the clip's box, its own px from its top left (unturned); the CSS it started from. */
1714
+ type CropSession = {
1715
+ clipLoc: string;
1716
+ clipId: string;
1717
+ t: MgTransform;
1718
+ box: MgBox;
1719
+ /** The box's size on the stage. */
1720
+ size: { w: number; h: number };
1721
+ css0: string;
1722
+ fit: string;
1723
+ radius: number;
1724
+ crop0: Crop;
1725
+ pos0: [number, number];
1726
+ rect: Rect;
1727
+ pos: [number, number];
1728
+ aspect: CropAspect;
1729
+ };
1730
+ const [cropping, setCroppingState] = React.useState<CropSession | null>(null);
1731
+ /* ── the inspector's mask, edited on the picture (StageMaskOverlay): drawn over the thing in hand it is for ── */
1732
+ const [maskSession, setMaskSession] = React.useState<MaskSession | null>(null);
1733
+ const cropRef = React.useRef<CropSession | null>(null);
1734
+ const cropPaint = React.useMemo(() => new Latest<{ clip: string; css: string | null }>(async (a) => { await ask('look', a); }), [ask]);
1735
+ const setCrop = (next: CropSession | null) => {
1736
+ cropRef.current = next;
1737
+ setCroppingState(next);
1738
+ if (next) cropPaint.push({ clip: next.clipId, css: cropPreviewCss(next.css0, next.pos0, next.pos) });
1739
+ };
1740
+ const cropDrag = React.useRef<{ kind: 'move' | 'scale' | 'stretch'; handle: FramePivot; start: { x: number; y: number }; rect0: Rect; pos0: [number, number] } | null>(null);
1741
+
1742
+ const startCrop = (): boolean => {
1743
+ const held = pickedRef.current?.element;
1744
+ const t = liveTRef.current ?? held?.transform;
1745
+ if (!onEditClip || cropRef.current || gestureRef.current || !held?.isMgOuter || held.clipKind !== 'video' || !held.clipLoc || !held.clipId || !held.mgBox || !t || lockedClip(held.clipLoc)) return false;
1746
+ const at = parseFilmDocLoc(held.clipLoc);
1747
+ const clip = at ? docRef.current.tracks[at.track]?.clips[at.clip] : undefined;
1748
+ if (!clip) return false;
1749
+ const look = spansRef.current.find((sp) => sp.id === clip.id)?.look;
1750
+ const css0 = clip.style ?? '';
1751
+ const clipPath = declared(css0, 'clip-path') ?? look?.clip;
1752
+ const crop0 = cropOf(clipPath);
1753
+ /* a clip-path of another shape is the clip's own CSS (the inspector's Custom CSS): an inset is not put over it */
1754
+ if (!crop0) return false;
1755
+ if (clipNudge.current) flushClipNudge();
1756
+ const size = mgSize(t, held.mgBox);
1757
+ const pos0 = positionOf(declared(css0, 'object-position') ?? look?.position) ?? [50, 50];
1758
+ setHover(null);
1759
+ setMeasure(null);
1760
+ setCrop({
1761
+ clipLoc: held.clipLoc,
1762
+ clipId: held.clipId,
1763
+ t,
1764
+ box: held.mgBox,
1765
+ size,
1766
+ css0,
1767
+ fit: declared(css0, 'object-fit') ?? look?.fit ?? 'contain',
1768
+ /* the crop's corners: the clip's own radius, else its inset's, else the film's CSS's */
1769
+ radius: pxOf(declared(css0, 'border-radius')) ?? roundOf(clipPath) ?? pxOf(look?.radius) ?? 0,
1770
+ crop0,
1771
+ pos0,
1772
+ rect: cropRect(crop0, size.w, size.h),
1773
+ pos: pos0,
1774
+ aspect: 'free',
1775
+ });
1776
+ return true;
1777
+ };
1778
+ /** Kept (Enter, Done, a press elsewhere): written once, one undo step; or given up (Esc, Cancel). */
1779
+ const finishCrop = (keep: boolean) => {
1780
+ const s = cropRef.current;
1781
+ if (!s) return;
1782
+ cropRef.current = null;
1783
+ setCroppingState(null);
1784
+ cropDrag.current = null;
1785
+ setDragging(false);
1786
+ setHint(null);
1787
+ const css = keep && onEditClip
1788
+ ? croppedCss(s.css0, { crop: s.crop0, position: s.pos0 }, { crop: cropOfRect(s.rect, s.size.w, s.size.h), position: s.pos }, s.radius)
1789
+ : null;
1790
+ if (css == null || !onEditClip) { cropPaint.push({ clip: s.clipId, css: null }); return; }
1791
+ /* the preview stays until film.html has the crop (the film then wears it): put back first, the old one would flash */
1792
+ void cropPaint.idle()
1793
+ .then(() => onEditClip([{ loc: s.clipLoc, prop: 'style', value: css || null }]))
1794
+ .then((failed) => { if (failed) cropPaint.push({ clip: s.clipId, css: null }); });
1795
+ };
1796
+ const setCropAspect = (aspect: CropAspect) => {
1797
+ const s = cropRef.current;
1798
+ if (!s) return;
1799
+ const ratio = aspectRatio(aspect, s.box);
1800
+ setCrop({ ...s, aspect, rect: ratio ? fitAspect(s.rect, ratio, s.size.w, s.size.h) : s.rect });
1801
+ };
1802
+ /** One step of a crop drag (`p` overlay px): the hand's way turned back into the box's own axes. */
1803
+ const stepCrop = (p: { x: number; y: number }, shift: boolean) => {
1804
+ const s = cropRef.current;
1805
+ const d = cropDrag.current;
1806
+ if (!s || !d) return;
1807
+ const vs = viewScaleNow();
1808
+ const dx = p.x / vs - d.start.x;
1809
+ const dy = p.y / vs - d.start.y;
1810
+ const rad = (-(s.t.rotate || 0) * Math.PI) / 180;
1811
+ const way = { x: dx * Math.cos(rad) - dy * Math.sin(rad), y: dx * Math.sin(rad) + dy * Math.cos(rad) };
1812
+ if (d.kind === 'move') {
1813
+ const panned = pannedCrop(d.rect0, d.pos0, way, s.size, s.box, s.fit);
1814
+ setCrop({ ...s, rect: panned.rect, pos: panned.position });
1815
+ return;
1816
+ }
1817
+ /* a ratio picked holds; with Free, Shift keeps the crop's proportions at the press */
1818
+ const ratio = aspectRatio(s.aspect, s.box) ?? (shift ? d.rect0.w / d.rect0.h : null);
1819
+ const rect = draggedCrop(d.rect0, d.handle, way, s.size, ratio);
1820
+ setCrop({ ...s, rect });
1821
+ setHint({ x: p.x, y: p.y, text: `${Math.round(rect.w)} × ${Math.round(rect.h)}` });
1822
+ };
1823
+ /* the clip let go (another selected, or none): a crop being drawn is kept, as a click elsewhere keeps it; and a nudge
1824
+ not written yet is written */
1825
+ const heldClipLoc = picked?.element?.isMgOuter ? picked.element.clipLoc ?? null : null;
1826
+ React.useEffect(() => () => {
1827
+ if (clipNudge.current) flushClipNudge();
1828
+ if (cropRef.current) finishCrop(true);
1829
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1830
+ }, [heldClipLoc]);
1831
+
1832
+ const startCropRef = React.useRef(startCrop);
1833
+ startCropRef.current = startCrop;
1834
+
1835
+ /* ── Alt held: distances from the thing in hand to what the pointer is over ── */
1836
+ const [measure, setMeasure] = React.useState<{ lines: MeasureLine[]; other: Rect | null } | null>(null);
1837
+ const altHeld = React.useRef(false);
1838
+ /** The thing in hand's box (stage px): a whole clip's shown part, a layer's drawn box, several layers' union. */
1839
+ const selectionBox = (): Rect | null => {
1840
+ const held = pickedRef.current;
1841
+ if (!held) return null;
1842
+ const el = held.element;
1843
+ const t = liveTRef.current ?? el?.transform;
1844
+ if (el?.isMgOuter && el.mgBox && el.clipLoc && t) return frameAabb(visibleFrame(t, el.mgBox, infoOf(el.clipLoc)?.crop ?? NO_CROP));
1845
+ const r = held.layer?.rect ?? held.rect;
1846
+ return { x: r.left, y: r.top, w: r.width, h: r.height };
1847
+ };
1848
+ const selectionBoxRef = React.useRef(selectionBox);
1849
+ selectionBoxRef.current = selectionBox;
1850
+ const shownBoxOfRef = React.useRef(shownBoxOf);
1851
+ shownBoxOfRef.current = shownBoxOf;
1852
+ const measureQueue = React.useMemo(() => new Latest<{ x: number; y: number }>(async (p) => {
1853
+ const held = pickedRef.current;
1854
+ const mine = selectionBoxRef.current();
1855
+ if (!held || !mine) { setMeasure(null); return; }
1856
+ /* the layer under the pointer, else the clip; the thing in hand itself (or its own clip) measures to the stage */
1857
+ const own = new Set([held.element?.clipId, ...(multiRef.current ?? []).map((i) => i.element.clipId)]);
1858
+ const ownLayers = new Set([held.layer?.handle, ...(multiRef.current ?? []).map((i) => i.layer.handle)]);
1859
+ const layer = await ask<StageOutline>('hit', { ...p, from: null });
1860
+ let other: Rect | null = null;
1861
+ if (layer) {
1862
+ if (!ownLayers.has(layer.handle)) other = { x: layer.rect.left, y: layer.rect.top, w: layer.rect.width, h: layer.rect.height };
1863
+ } else {
1864
+ const id = await ask<string>('clipAt', p);
1865
+ const list = id && !own.has(id) ? await ask<{ id: string; x: number; y: number; w: number; h: number }[]>('clips') : null;
1866
+ const clip = list?.find((c) => c.id === id);
1867
+ if (clip) other = shownBoxOfRef.current(clip);
1868
+ }
1869
+ if (!altHeld.current || pickedRef.current !== held) return;
1870
+ setMeasure({ lines: measureGaps(mine, other ?? { x: 0, y: 0, w: stage.w, h: stage.h }), other });
1871
+ }), [ask, stage]);
1872
+ /** Measured only with nothing being dragged, drawn or cropped. */
1873
+ const measureAt = (p: { x: number; y: number } | null) => {
1874
+ const busy = gestureRef.current || layerDrag.current || multiDrag.current || layersPull.current || draftRef.current || cropRef.current || textEdit.current;
1875
+ if (!p || !pickedRef.current || busy) {
1876
+ altHeld.current = false;
1877
+ setMeasure(null);
1878
+ return;
1879
+ }
1880
+ altHeld.current = true;
1881
+ const vs = viewScaleNow();
1882
+ measureQueue.push({ x: p.x / vs, y: p.y / vs });
1883
+ };
1884
+ const measureAtRef = React.useRef(measureAt);
1885
+ measureAtRef.current = measureAt;
1886
+ /* Alt pressed or let go with the pointer resting on the picture sends no pointer move */
1887
+ React.useEffect(() => {
1888
+ const onKey = (e: KeyboardEvent) => {
1889
+ if (e.key === 'Alt') measureAtRef.current(e.type === 'keydown' ? pointerAt.current : null);
1890
+ };
1891
+ const off = () => measureAtRef.current(null);
1892
+ window.addEventListener('keydown', onKey);
1893
+ window.addEventListener('keyup', onKey);
1894
+ window.addEventListener('blur', off);
1895
+ return () => {
1896
+ window.removeEventListener('keydown', onKey);
1897
+ window.removeEventListener('keyup', onKey);
1898
+ window.removeEventListener('blur', off);
1899
+ };
1900
+ }, []);
1901
+
1902
+ /* ── geometry for the inspector ── */
1903
+ const lastGeometry = React.useRef('');
1904
+ const reportGeometry = (g: StageGeometry | null) => {
1905
+ const key = g ? JSON.stringify(g) : '';
1906
+ if (key === lastGeometry.current) return;
1907
+ lastGeometry.current = key;
1908
+ onGeometry?.(g);
1909
+ };
1910
+ /* unmounted (full screen, read only): the inspector must not keep showing the last numbers */
1911
+ const onGeometryRef = React.useRef(onGeometry);
1912
+ onGeometryRef.current = onGeometry;
1913
+ React.useEffect(() => () => { onGeometryRef.current?.(null); }, []);
1914
+
1915
+ /* ── the film's box inside this overlay, and re-measuring when it changes size ── */
1916
+ const syncFilmOffset = React.useCallback(() => {
1917
+ const hostR = host.current?.getBoundingClientRect();
1918
+ const frameR = previewRef.current?.frame?.()?.getBoundingClientRect();
1919
+ if (!hostR || !frameR || !(frameR.width > 0)) return;
1920
+ const next = { left: frameR.left - hostR.left, top: frameR.top - hostR.top, width: frameR.width, height: frameR.height };
1921
+ setFilmOffset((cur) => (cur.left === next.left && cur.top === next.top && cur.width === next.width && cur.height === next.height ? cur : next));
1922
+ }, [previewRef]);
1923
+ const syncFilmOffsetRef = React.useRef(syncFilmOffset);
1924
+ syncFilmOffsetRef.current = syncFilmOffset;
1925
+ React.useLayoutEffect(() => {
1926
+ syncFilmOffset();
1927
+ let settle: ReturnType<typeof setTimeout> | null = null;
1928
+ const onResize = () => {
1929
+ setHover(null);
1930
+ syncFilmOffset();
1931
+ syncGeomSoon();
1932
+ if (settle) clearTimeout(settle);
1933
+ settle = setTimeout(() => { settle = null; syncFilmOffset(); syncGeomSoon(); }, 180);
1934
+ };
1935
+ const ro = new ResizeObserver(onResize);
1936
+ if (host.current) ro.observe(host.current);
1937
+ const frame = previewRef.current?.frame?.();
1938
+ if (frame) ro.observe(frame);
1939
+ window.addEventListener('resize', onResize);
1940
+ return () => {
1941
+ ro.disconnect();
1942
+ window.removeEventListener('resize', onResize);
1943
+ if (settle) clearTimeout(settle);
1944
+ };
1945
+ }, [previewRef, syncFilmOffset, syncGeomSoon]);
1946
+
1947
+ /* ── context menu, double click ── */
1948
+ const [menu, setMenu] = React.useState<{ x: number; y: number; items: readonly ContextMenuEntry[] } | null>(null);
1949
+ /** The menu's entries, then Show… / Unlock…, opened at a point on screen. */
1950
+ const openMenuAt = (at: { x: number; y: number }, items: readonly ContextMenuEntry[]) => {
1951
+ const recovery = recoveryMenu?.() ?? [];
1952
+ const combined: readonly ContextMenuEntry[] = recovery.length
1953
+ ? [...items, ...(items.length ? [{ id: 'recovery-separator', separator: true as const }] : []), ...recovery]
1954
+ : items;
1955
+ if (combined.length) setMenu({ x: at.x, y: at.y, items: combined });
1956
+ };
1957
+ /**
1958
+ * The menu of what is in hand (several layers, a layer, a whole clip), opened at `p` (overlay px): the right-click's
1959
+ * and the selection bar's (its tools are this menu's items). A delete leaves no frame behind (the thing is gone).
1960
+ */
1961
+ const heldMenu = (p: { x: number; y: number }): readonly ContextMenuEntry[] => {
1962
+ if (!menuFor) return [];
1963
+ const dropOnDelete = (items: readonly ContextMenuEntry[]) => items.map((entry) => ('label' in entry && entry.id === 'delete' && entry.onSelect
1964
+ ? { ...entry, onSelect: () => { entry.onSelect!(); dropPick(); } }
1965
+ : entry));
1966
+ const many = multiRef.current;
1967
+ if (many) {
1968
+ return dropOnDelete(menuFor(many[0]!.element, {
1969
+ arrange: null,
1970
+ at: p,
1971
+ multi: many.map((i) => i.element),
1972
+ align: (edge) => alignMany(edge),
1973
+ }));
1974
+ }
1975
+ const held = pickedRef.current;
1976
+ const element = held?.element;
1977
+ if (!element) return [];
1978
+ const layer = held.layer;
1979
+ return dropOnDelete(menuFor(element, {
1980
+ locked: Boolean(layer?.locked),
1981
+ ...(element.text && onEditTextInline && layer ? { editText: () => { startTextEditRef.current(); } } : {}),
1982
+ arrange: layer ? arrangeOf(layer.handle) : null,
1983
+ at: p,
1984
+ }));
1985
+ };
1986
+ const onStageContextMenu = (e: React.MouseEvent) => {
1987
+ e.preventDefault();
1988
+ if (!menuFor) return;
1989
+ const at = { x: e.clientX, y: e.clientY };
1990
+ const openMenu = (items: readonly ContextMenuEntry[]) => openMenuAt(at, items);
1991
+ const p = local(e);
1992
+ const vs = viewScaleNow();
1993
+ /* several selected, right-click inside their box: the menu is about them */
1994
+ const many = multiRef.current;
1995
+ const box0 = pickedRef.current?.rect;
1996
+ if (many && box0 && insideBox(scaleBox(box0, vs), p.x, p.y)) {
1997
+ openMenu(heldMenu(p));
1998
+ return;
1999
+ }
2000
+ const token = ++pressSeq.current;
2001
+ const forHeld = () => openMenu(heldMenu(p));
2002
+ /* inside what is in hand: that; elsewhere select what is under the pointer first (locked ones too: to unlock) */
2003
+ const held = pickedRef.current;
2004
+ if (held?.element && insideBox(scaleBox(held.rect, vs), p.x, p.y)) { forHeld(); return; }
2005
+ void ask<StageLayer>('hit', { x: p.x / vs, y: p.y / vs, includeLocked: true, from: held?.layer?.handle ?? null, detail: true }).then((layer) => {
2006
+ if (token !== pressSeq.current) return;
2007
+ if (!layer) { openMenu([]); return; }
2008
+ commitHit(hitOf(layer));
2009
+ forHeld();
2010
+ });
2011
+ };
2012
+
2013
+ const onStageDoubleClick = (e: React.MouseEvent) => {
2014
+ const p = local(e);
2015
+ const vs = viewScaleNow();
2016
+ const held = pickedRef.current;
2017
+ e.stopPropagation();
2018
+ const token = ++pressSeq.current;
2019
+ /* where the double-click began, in the film's own pixels: its first click can open the inspector, and the picture
2020
+ shrinks under the hand before the second — taken where the second landed on screen, it typed in another line */
2021
+ const first = doubleClickAim(pressesRef.current, e.timeStamp);
2022
+ const at = first ? { x: first.x, y: first.y } : { x: p.x / vs, y: p.y / vs };
2023
+ const inside = held && insideBox(held.rect, at.x, at.y);
2024
+ /* cropping, a double-click keeps the crop; on the video or still in hand, it starts one */
2025
+ if (cropRef.current) { finishCrop(true); return; }
2026
+ const outer = held?.element;
2027
+ const t = liveTRef.current ?? outer?.transform;
2028
+ if (outer?.isMgOuter && outer.mgBox && outer.clipLoc && t
2029
+ && insideFrame(visibleFrame(t, outer.mgBox, infoOf(outer.clipLoc)?.crop ?? NO_CROP), at.x, at.y) && startCrop()) return;
2030
+ void (async () => {
2031
+ /* double-click a group: go in, selecting the layer under the pointer (a group in it: one level). Double-click
2032
+ that to type. */
2033
+ if (inside && held?.layer?.group) {
2034
+ const layer = await ask<StageLayer>('hit', { ...at, from: held.layer.handle, enter: true, detail: true });
2035
+ if (token !== pressSeq.current) return;
2036
+ if (layer && layer.handle !== held.layer.handle) { commitHit(hitOf(layer)); return; }
2037
+ }
2038
+ if (!inside || !held?.element?.text) {
2039
+ const layer = await ask<StageLayer>('hit', { ...at, from: held?.layer?.handle ?? null, detail: true });
2040
+ if (token !== pressSeq.current || !layer?.text) return;
2041
+ commitHit(hitOf(layer));
2042
+ }
2043
+ startTextEditRef.current();
2044
+ })();
2045
+ };
2046
+
2047
+ /* ── during playback: the pointer stands still and the picture moves under it ── */
2048
+ const flushMove = (): void => {
2049
+ moveRaf.current = 0;
2050
+ const at = moveAt.current;
2051
+ const r = filmRect();
2052
+ if (!at || !r) return;
2053
+ const x = at.cx - r.left;
2054
+ const y = at.cy - r.top;
2055
+ const vs = r.width / stage.w;
2056
+ if (gestureRef.current) {
2057
+ applyMgGesture({ x: x / vs, y: y / vs }, Boolean(at.shift), Boolean(at.alt));
2058
+ return;
2059
+ }
2060
+ if (draftRef.current) {
2061
+ const d = draftRef.current = { ...draftRef.current, x2: x, y2: y };
2062
+ setDraft(d);
2063
+ const area = { x: Math.min(d.x1, d.x2) / vs, y: Math.min(d.y1, d.y2) / vs, w: Math.abs(d.x2 - d.x1) / vs, h: Math.abs(d.y2 - d.y1) / vs };
2064
+ const open = area.w >= MARQUEE_MIN_PX && area.h >= MARQUEE_MIN_PX;
2065
+ /* a box pulled open from the dark around the picture: the film stops now (see pressDown) */
2066
+ if (open && pauseWhenOpen.current) { pauseWhenOpen.current = false; onPause?.(); }
2067
+ marqueeQueue.push(open ? area : null);
2068
+ return;
2069
+ }
2070
+ /* with a clip in hand too: what is in it and around it is still found (its own outline is its frame) */
2071
+ if (textEdit.current) return;
2072
+ hoverQueue.push({ x: x / vs, y: y / vs, from: pickedRef.current?.layer?.handle ?? null });
2073
+ };
2074
+ const flushMoveRef = React.useRef(flushMove);
2075
+ flushMoveRef.current = flushMove;
2076
+ React.useEffect(() => {
2077
+ if (!playing) return undefined;
2078
+ const timer = window.setInterval(() => {
2079
+ if (moveAt.current && !gestureRef.current && !draftRef.current) flushMoveRef.current();
2080
+ if (pickedRef.current) syncQueue.push(0);
2081
+ }, 80);
2082
+ return () => window.clearInterval(timer);
2083
+ }, [playing, syncQueue]);
2084
+
2085
+ /* ── what is drawn ── */
2086
+ const vsNow = viewScaleNow();
2087
+ const heldT = liveT ?? picked?.element?.transform ?? null;
2088
+ /* a whole clip is held by the part of it that shows */
2089
+ const heldCrop = picked?.element?.isMgOuter && picked.element.clipLoc ? infoOf(picked.element.clipLoc)?.crop ?? NO_CROP : NO_CROP;
2090
+ const mgFrame: FrameGeometry | null = onEditClip && picked?.element?.isMgOuter && heldT && picked.element.mgBox && !lockedClip(picked.element.clipLoc)
2091
+ ? scaleFrame(visibleFrame(heldT, picked.element.mgBox, heldCrop), vsNow)
2092
+ : null;
2093
+ /* a group only moves: it fills the stage, so scaling or turning it would go about the stage's middle */
2094
+ const heldLayer = picked?.layer;
2095
+ const layerFrame: FrameGeometry | null = !mgFrame && picked?.element && layerMovable(heldLayer, picked.element)
2096
+ && editingText == null && !heldLayer.group
2097
+ ? scaleFrame(heldLayer.frame, vsNow)
2098
+ : null;
2099
+ /* the mask is drawn in the whole box (a crop does not move it); its handles stand in for the frame's meanwhile */
2100
+ const maskTarget = maskSession?.target;
2101
+ const maskBox: FrameGeometry | null = !maskTarget || cropping || editingText != null || !picked?.element ? null
2102
+ : maskTarget.kind === 'clip'
2103
+ ? (mgFrame && heldT && picked.element.mgBox && picked.element.clipLoc === maskTarget.clipLoc
2104
+ ? scaleFrame(visibleFrame(heldT, picked.element.mgBox, NO_CROP), vsNow) : null)
2105
+ : (heldLayer && !heldLayer.group && !multi && picked.element.loc === maskTarget.loc ? scaleFrame(heldLayer.frame, vsNow) : null);
2106
+ const outline = (f: FrameGeometry) => (
2107
+ <Box rect={{ left: f.cx - f.w / 2, top: f.cy - f.h / 2, width: f.w, height: f.h }} frame={f} tone="hover" />
2108
+ );
2109
+ const sizeLabel = (f: FrameGeometry) => `${Math.round(f.w / vsNow)} × ${Math.round(f.h / vsNow)}`;
2110
+ const stageBounds = { w: stage.w * vsNow, h: stage.h * vsNow };
2111
+ React.useEffect(() => {
2112
+ if (!picked) { reportGeometry(null); return; }
2113
+ /* 1920 measured through a 418px-wide preview comes back as 1919.9: within 0.15 of a whole number is that number */
2114
+ const round = (v: number) => {
2115
+ const whole = Math.round(v);
2116
+ return Math.abs(v - whole) < 0.15 ? whole : Math.round(v * 10) / 10;
2117
+ };
2118
+ if (picked && multi) {
2119
+ reportGeometry({
2120
+ kind: 'group',
2121
+ loc: null,
2122
+ x: round(picked.rect.left),
2123
+ y: round(picked.rect.top),
2124
+ w: round(picked.rect.width),
2125
+ h: round(picked.rect.height),
2126
+ r: 0,
2127
+ unit: 1,
2128
+ group: { n: multi.length, kinds: [...new Set(multi.map((i) => i.layer.kind))], elements: multi.map((i) => i.element) },
2129
+ stage,
2130
+ });
2131
+ return;
2132
+ }
2133
+ if (!picked.element) { reportGeometry(null); return; }
2134
+ if (mgFrame && heldT && picked.element.mgBox) {
2135
+ const size = mgSize(heldT, picked.element.mgBox);
2136
+ reportGeometry({
2137
+ kind: 'clip',
2138
+ loc: picked.element.clipLoc ?? null,
2139
+ x: round((picked.element.mgBox.x ?? 0) + heldT.x),
2140
+ y: round((picked.element.mgBox.y ?? 0) + heldT.y),
2141
+ w: round(size.w),
2142
+ h: round(size.h),
2143
+ r: round(heldT.rotate || 0),
2144
+ unit: 1,
2145
+ box: picked.element.mgBox,
2146
+ stage,
2147
+ });
2148
+ return;
2149
+ }
2150
+ const layer = picked.layer;
2151
+ if (!layer) { reportGeometry(null); return; }
2152
+ const f = layer.frame;
2153
+ reportGeometry({
2154
+ kind: 'layer',
2155
+ loc: picked.element.loc,
2156
+ x: round(f.cx - f.w / 2),
2157
+ y: round(f.cy - f.h / 2),
2158
+ w: round(f.w),
2159
+ h: round(f.h),
2160
+ /* its own angle: what the inspector types is what the override says, without its parents' (see parentR) */
2161
+ r: round(f.r - layer.geo.around),
2162
+ parentR: round(layer.geo.parentR),
2163
+ unit: 1 / (layer.geo.k || 1),
2164
+ stage,
2165
+ });
2166
+ });
2167
+
2168
+ /** A region drawn: flashed at once, then handed on with the clips and words the film finds in it. */
2169
+ const pointAt = async (box: StageBox) => {
2170
+ showFlash(box);
2171
+ const [clips, layers] = await Promise.all([
2172
+ ask<{ id: string; visible: boolean; x: number; y: number; w: number; h: number }[]>('clips'),
2173
+ ask<StageLayer[]>('box', { ...box, all: true }),
2174
+ ]);
2175
+ onRegion?.({ box, clipIds: regionClipIds(clips ?? [], box), texts: regionTexts(layers ?? []) });
2176
+ };
2177
+
2178
+ const endPointer = (e: React.PointerEvent<HTMLDivElement>) => {
2179
+ if (moveRaf.current) {
2180
+ cancelAnimationFrame(moveRaf.current);
2181
+ moveRaf.current = 0;
2182
+ }
2183
+ if (cropDrag.current) {
2184
+ e.stopPropagation();
2185
+ if (e.type === 'pointerup') stepCrop(local(e), e.shiftKey);
2186
+ cropDrag.current = null;
2187
+ setDragging(false);
2188
+ setHint(null);
2189
+ return;
2190
+ }
2191
+ const vs = viewScaleNow();
2192
+ const region = regionDraftRef.current;
2193
+ if (region) {
2194
+ regionDraftRef.current = null;
2195
+ setRegionDraft(null);
2196
+ e.stopPropagation();
2197
+ /* only a release draws it: a cancelled gesture (the system took the pointer) points at nothing */
2198
+ const p = local(e);
2199
+ const box = e.type === 'pointerup' ? regionBox({ ...region, x2: p.x, y2: p.y }, vs, stage) : null;
2200
+ if (box) void pointAt(box);
2201
+ return;
2202
+ }
2203
+ const cd = clipsDrag.current;
2204
+ if (cd) {
2205
+ clipsDrag.current = null;
2206
+ e.stopPropagation();
2207
+ setDragging(false);
2208
+ if (!cd.moved) {
2209
+ setGroupTurn(null);
2210
+ /* not pulled: a click on one of them takes that one alone */
2211
+ const p = local(e);
2212
+ const token = ++pressSeq.current;
2213
+ void ask<string>('clipAt', { x: p.x / vs, y: p.y / vs }).then((id) => {
2214
+ if (token !== pressSeq.current) return;
2215
+ const loc = id ? clipLocOf(docRef.current, id) : undefined;
2216
+ const info = loc ? infoOf(loc) : null;
2217
+ if (info) commitHit(describeClip(info, stage));
2218
+ });
2219
+ return;
2220
+ }
2221
+ const moved = cd.items.map((it) => ({ ...it, t: groupDragged(cd, it.t0, it.box) }));
2222
+ setGroupTurn(null);
2223
+ /* the outlines stay where they were let go (film.html follows a moment later) */
2224
+ setLinkedMarks((marks) => marks.map((m) => moved.find((it) => it.loc === m.loc) ? { ...m, t: moved.find((it) => it.loc === m.loc)!.t } : m));
2225
+ setClipsMoved(null);
2226
+ void clipsPlace.idle().then(() => onEditClip?.(moved.map((it) => ({ loc: it.loc, prop: 'box' as const, value: compactBox(it.t, it.box) })))).then((failed) => {
2227
+ if (!failed) return;
2228
+ clipsPlace.push(cd.items.map((it) => ({ clip: it.id, box: null })));
2229
+ setLinkedMarks((marks) => marks.map((m) => cd.items.find((it) => it.loc === m.loc) ? { ...m, t: cd.items.find((it) => it.loc === m.loc)!.t0 } : m));
2230
+ });
2231
+ return;
2232
+ }
2233
+ const lp = layersPull.current;
2234
+ if (lp) {
2235
+ layersPull.current = null;
2236
+ e.stopPropagation();
2237
+ setDragging(false);
2238
+ setLayersTurn(null);
2239
+ if (!lp.moved) return;
2240
+ void multiPaint.idle().then(() => onTransformLayers?.(lp.items.map((it) => ({ element: it.element, g: it.now }))));
2241
+ return;
2242
+ }
2243
+ const md = multiDrag.current;
2244
+ if (md) {
2245
+ multiDrag.current = null;
2246
+ e.stopPropagation();
2247
+ setDragging(false);
2248
+ if (md.moved) {
2249
+ void multiPaint.idle().then(() => onTransformLayers?.(md.items.map((it) => ({ element: it.element, g: it.now }))));
2250
+ return;
2251
+ }
2252
+ /* not pulled open: a click on one of them selects that one */
2253
+ const p = local(e);
2254
+ const token = ++pressSeq.current;
2255
+ void ask<StageLayer>('hit', { x: p.x / vs, y: p.y / vs, detail: true }).then((layer) => {
2256
+ if (layer && token === pressSeq.current) commitHit(hitOf(layer));
2257
+ });
2258
+ return;
2259
+ }
2260
+ const drag = layerDrag.current;
2261
+ if (drag) {
2262
+ if (layerRaf.current) {
2263
+ cancelAnimationFrame(layerRaf.current);
2264
+ layerRaf.current = 0;
2265
+ const at = layerMoveAt.current;
2266
+ layerMoveAt.current = null;
2267
+ if (at) stepLayerGesture(at.p, at.mods);
2268
+ }
2269
+ layerDrag.current = null;
2270
+ e.stopPropagation();
2271
+ if (drag.moved) {
2272
+ setDragging(false);
2273
+ setHint(null);
2274
+ setLiveGuides(NO_GUIDES);
2275
+ /* written as drawn: after the last step's answer (a scale's offset is the film's to work out) */
2276
+ void layerPaint.idle().then(() => commitLayer(drag.element, drag.layer, drag.base, drag.now));
2277
+ return;
2278
+ }
2279
+ /* not pulled open: a click; select what was clicked (most often itself) */
2280
+ const p = local(e);
2281
+ const token = ++pressSeq.current;
2282
+ void ask<StageLayer>('hit', { x: p.x / vs, y: p.y / vs, from: drag.layer.handle, detail: true }).then((layer) => {
2283
+ if (token !== pressSeq.current) return;
2284
+ commitHit(layer ? hitOf(layer) : null);
2285
+ });
2286
+ return;
2287
+ }
2288
+ if (gestureRef.current) {
2289
+ e.stopPropagation();
2290
+ const p = local(e);
2291
+ if (gestureRef.current.pendingClick) {
2292
+ /* a click on the clip in hand (a drag moves it): the layer of a page under the pointer is taken in hand, as
2293
+ a click in a selected frame selects what is in it; else the top clip there */
2294
+ const g = gestureRef.current;
2295
+ gestureRef.current = null;
2296
+ liveTRef.current = null;
2297
+ liveBoxRef.current = null;
2298
+ setLiveT(null);
2299
+ const at = g.hit ?? p;
2300
+ const token = ++pressSeq.current;
2301
+ void (async () => {
2302
+ const layer = await ask<StageLayer>('hit', { x: at.x / vs, y: at.y / vs, detail: true });
2303
+ if (token !== pressSeq.current) return;
2304
+ if (layer) { setHover(null); commitHit(hitOf(layer)); return; }
2305
+ const id = await ask<string>('clipAt', { x: at.x / vs, y: at.y / vs });
2306
+ if (token !== pressSeq.current) return;
2307
+ const loc = id ? clipLocOf(docRef.current, id) : undefined;
2308
+ const info = loc ? infoOf(loc) : null;
2309
+ commitHit(info ? describeClip(info, stage) : null);
2310
+ })();
2311
+ return;
2312
+ }
2313
+ applyMgGesture({ x: p.x / vs, y: p.y / vs }, e.shiftKey, e.altKey);
2314
+ endMgGesture();
2315
+ return;
2316
+ }
2317
+ const start = draftRef.current;
2318
+ if (!start) return;
2319
+ pauseWhenOpen.current = false;
2320
+ e.stopPropagation();
2321
+ const p = local(e);
2322
+ const box = { left: Math.min(start.x1, p.x), top: Math.min(start.y1, p.y), width: Math.abs(p.x - start.x1), height: Math.abs(p.y - start.y1) };
2323
+ draftRef.current = null;
2324
+ setDraft(null);
2325
+ const shift = e.shiftKey;
2326
+ const alt = e.altKey;
2327
+ const base = shiftBase.current;
2328
+ shiftBase.current = null;
2329
+ const cycle = alt ? cycleFrom.current : null;
2330
+ const from = pressedFrom.current;
2331
+ cycleFrom.current = null;
2332
+ pressedFrom.current = null;
2333
+ const token = pressSeq.current;
2334
+ void (async () => {
2335
+ /* the press was on a clip selected on the timeline: not a click elsewhere, nothing happens */
2336
+ if (!(await pressGo.current)) return;
2337
+ if (token !== pressSeq.current) return;
2338
+ /* the threshold is in stage px: the same gesture is a click in a small window and in full screen alike */
2339
+ if (box.width / vs >= MARQUEE_MIN_PX && box.height / vs >= MARQUEE_MIN_PX) {
2340
+ const area = { x: box.left / vs, y: box.top / vs, w: box.width / vs, h: box.height / vs };
2341
+ /* clips wholly inside (a page, a video, a still, by the part on the canvas) are selected whole, each one the
2342
+ box goes around; Shift adds them to the clips selected */
2343
+ const clips = onSelectClips ? await (marqueeClips.current ?? ask<StageClipBox[]>('clips')) ?? [] : [];
2344
+ if (token !== pressSeq.current) return;
2345
+ const around = clipsInside(clips, area).map((c) => c.loc);
2346
+ if (around.length) {
2347
+ const locs = [...new Set([...(shift ? linkedLocs ?? [] : []), ...around])];
2348
+ setHover(null);
2349
+ if (locs.length === 1) {
2350
+ const info = infoOf(locs[0]!);
2351
+ commitHit(info ? describeClip(info, stage) : null);
2352
+ } else {
2353
+ dropPick();
2354
+ onSelectClips!(locs);
2355
+ }
2356
+ return;
2357
+ }
2358
+ /* else the outermost layers wholly inside (a box within a page: its layers); Shift adds them */
2359
+ const inside = await ask<StageLayer[]>('box', area) ?? [];
2360
+ if (token !== pressSeq.current) return;
2361
+ const prior = shift ? (base ?? []) : [];
2362
+ const all = [...prior, ...inside.filter((l) => !prior.some((b) => b.handle === l.handle))];
2363
+ if (all.length) selectMany(all);
2364
+ else commitHit(null);
2365
+ return;
2366
+ }
2367
+ /* not pulled open far enough: a click. A layer of a page is selected, else the clip; on nothing, nothing is */
2368
+ const layer = await ask<StageLayer>('hit', { x: p.x / vs, y: p.y / vs, cycleFrom: cycle, from, detail: true });
2369
+ if (token !== pressSeq.current) return;
2370
+ /* Shift-click adds to / takes from the selection */
2371
+ if (base && layer) {
2372
+ const has = base.some((b) => b.handle === layer.handle);
2373
+ setHover(null);
2374
+ selectMany(has ? base.filter((b) => b.handle !== layer.handle) : [...base, layer]);
2375
+ return;
2376
+ }
2377
+ if (layer) {
2378
+ setHover(null);
2379
+ commitHit(hitOf(layer));
2380
+ return;
2381
+ }
2382
+ /* no layer there: the clip under the pointer is taken in hand, as on the timeline (a page by its empty part: a
2383
+ page smaller than the canvas is moved and sized as a video is) */
2384
+ const id = !base && onEditClip ? await ask<string>('clipAt', { x: p.x / vs, y: p.y / vs }) : null;
2385
+ if (token !== pressSeq.current) return;
2386
+ const loc = id ? clipLocOf(docRef.current, id) : undefined;
2387
+ const info = loc ? infoOf(loc) : null;
2388
+ commitHit(info ? describeClip(info, stage) : null);
2389
+ })();
2390
+ };
2391
+
2392
+ /** At the press: the layer in hand (Alt-click cycles from it; a click in a group picks inside it once entered). */
2393
+ const cycleFrom = React.useRef<number | null>(null);
2394
+ const pressedFrom = React.useRef<number | null>(null);
2395
+ const shiftBase = React.useRef<StageLayer[] | null>(null);
2396
+ /** Whether the press goes on (false: it was on a clip selected on the timeline, see the press). */
2397
+ const pressGo = React.useRef<Promise<boolean>>(Promise.resolve(true));
2398
+
2399
+ const draftRect = draft
2400
+ ? { left: Math.min(draft.x1, draft.x2), top: Math.min(draft.y1, draft.y2), width: Math.abs(draft.x2 - draft.x1), height: Math.abs(draft.y2 - draft.y1) }
2401
+ : null;
2402
+ const linkedShown = linkedOutlines(linkedMarks, picked?.element?.clipLoc, dragging);
2403
+ /* several clips selected: one frame around them all (it moves with them) */
2404
+ const groupMarks = clipsMoved ?? (linkedShown.length > 1 ? linkedShown : null);
2405
+ const groupStretch = Boolean(groupMarks?.every((m) => infoOf(m.loc)?.kind === 'video'));
2406
+ const groupBox = groupMarks ? unionRect(groupMarks.map((m) => clientOf(frameAabb(visibleFrame(m.t, m.box, infoOf(m.loc)?.crop ?? NO_CROP))))) : null;
2407
+ /* the clip of a pill hovered in the app's chat, where it is drawn now */
2408
+ const chatClip = chat?.clipLoc ? clipInfo(doc, spans, stage, chat.clipLoc) : null;
2409
+
2410
+ /* ── the bar beside what is in hand (SelectionToolbar): its tools are the menu's items ── */
2411
+ const t = useT();
2412
+ const stageBoxOf = (r: ClientBox): StageBox => ({ x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) });
2413
+ const pickedItems = (): StagePick[] => {
2414
+ const many = multiRef.current;
2415
+ if (many) return many.map((it) => ({ element: it.element, box: stageBoxOf(it.layer.rect) }));
2416
+ const held = pickedRef.current;
2417
+ if (!held?.element) return [];
2418
+ const whole = held.element.kind === 'clip' || held.element.isMgOuter;
2419
+ return [{ element: held.element, box: whole ? null : stageBoxOf(held.layer?.rect ?? held.rect) }];
2420
+ };
2421
+ const pickedItemsRef = React.useRef(pickedItems);
2422
+ pickedItemsRef.current = pickedItems;
2423
+ /** Space several layers evenly (as the inspector does several clips): one save. */
2424
+ const distributeMany = (axis: 'x' | 'y') => {
2425
+ const items = multiItemsNow();
2426
+ if (items.length < 3 || !onTransformLayers) return;
2427
+ const vs = viewScaleNow();
2428
+ const shifts = distributeShifts(items.map((it) => scaleBox(it.layer.rect, vs)), axis);
2429
+ shiftLayers(items, (i) => shifts[i]!.dx, (i) => shifts[i]!.dy);
2430
+ onTransformLayers(items.map((it) => ({ element: it.element, g: it.now })));
2431
+ };
2432
+ const heldEl = picked?.element;
2433
+ const barKind = !selectionBar || !picked ? null : multi ? 'layers' : !heldEl ? null : heldEl.kind === 'clip' || heldEl.isMgOuter ? 'clip' : 'layer';
2434
+ const barTools = ((): SelectionToolEntry[] => {
2435
+ if (!barKind) return [];
2436
+ const out: SelectionToolEntry[] = [];
2437
+ const entries = heldMenu({ x: 0, y: 0 });
2438
+ const find = (id: string): ContextMenuItem | null => {
2439
+ for (const e of entries) {
2440
+ if ('separator' in e) continue;
2441
+ if (e.id === id) return e;
2442
+ for (const sub of e.submenu ?? []) if (!('separator' in sub) && sub.id === id) return sub;
2443
+ }
2444
+ return null;
2445
+ };
2446
+ const tools: SelectionToolEntry[] = [];
2447
+ const fromMenu = (id: string, icon?: React.ReactNode) => {
2448
+ const item = find(id);
2449
+ if (!item) return;
2450
+ /* an item with a submenu (Replace picture): its list hangs from the button */
2451
+ const sub = item.submenu;
2452
+ tools.push({
2453
+ id, label: item.label, icon: icon ?? item.icon,
2454
+ ...(item.shortcut ? { shortcut: item.shortcut } : {}), ...(item.hint ? { hint: item.hint } : {}),
2455
+ ...(item.disabled || (sub && !sub.length) ? { disabled: true } : {}), ...(item.danger ? { danger: true } : {}),
2456
+ onSelect: (button) => {
2457
+ if (!sub) { item.onSelect?.(); return; }
2458
+ const r = button.getBoundingClientRect();
2459
+ openMenuAt({ x: r.left, y: r.bottom + 4 }, sub);
2460
+ },
2461
+ });
2462
+ };
2463
+ if (barKind === 'layer') for (const id of ['edit-text', 'replace-picture', 'front', 'back', 'hide', 'reset']) fromMenu(id);
2464
+ if (barKind === 'clip') {
2465
+ if (heldEl?.clipKind === 'video' && onEditClip) tools.push({ id: 'crop', label: t('selectionBar.crop'), icon: <CropIcon size={14} />, shortcut: 'C', onSelect: () => { startCrop(); } });
2466
+ if (onStartMask && heldEl?.clipLoc) {
2467
+ const clipLoc = heldEl.clipLoc;
2468
+ tools.push({
2469
+ id: 'mask', label: t('selectionBar.mask'), icon: <SquareDashed size={14} />,
2470
+ onSelect: (button) => {
2471
+ const r = button.getBoundingClientRect();
2472
+ const at = parseFilmDocLoc(clipLoc);
2473
+ const style = at ? docRef.current.tracks[at.track]?.clips[at.clip]?.style ?? '' : '';
2474
+ const box = heldEl?.mgBox ?? null;
2475
+ const now = maskOf(declared(style, 'mask-image'), declared(style, 'mask-composite'), box);
2476
+ const shapeNow = now === undefined ? 'none' : now?.shape ?? null;
2477
+ openMenuAt({ x: r.left, y: r.bottom + 4 }, (['none', ...MASK_SHAPES] as const).map((shape) => ({
2478
+ id: `mask-${shape}`,
2479
+ label: t(`inspector.maskShapes.${shape}`),
2480
+ icon: <MaskGlyph shape={shape} />,
2481
+ checked: shape === shapeNow,
2482
+ onSelect: () => onStartMask(shape, clipLoc),
2483
+ })));
2484
+ },
2485
+ });
2486
+ }
2487
+ fromMenu('reset');
2488
+ }
2489
+ if (barKind === 'layers') {
2490
+ fromMenu('align-left', <AlignStartVertical size={14} />);
2491
+ fromMenu('align-hcenter', <AlignCenterVertical size={14} />);
2492
+ fromMenu('align-right', <AlignEndVertical size={14} />);
2493
+ fromMenu('align-top', <AlignStartHorizontal size={14} />);
2494
+ fromMenu('align-vcenter', <AlignCenterHorizontal size={14} />);
2495
+ fromMenu('align-bottom', <AlignEndHorizontal size={14} />);
2496
+ if ((multi?.length ?? 0) >= 3 && onTransformLayers) {
2497
+ tools.push({ id: 'distribute-x', label: t('inspector.distributeH'), icon: <AlignHorizontalDistributeCenter size={14} />, onSelect: () => distributeMany('x') });
2498
+ tools.push({ id: 'distribute-y', label: t('inspector.distributeV'), icon: <AlignVerticalDistributeCenter size={14} />, onSelect: () => distributeMany('y') });
2499
+ }
2500
+ }
2501
+ out.push(...tools);
2502
+ if (entries.length) {
2503
+ out.push({
2504
+ id: 'more', label: t('selectionBar.more'), icon: <MoreHorizontal size={14} />,
2505
+ onSelect: (button) => {
2506
+ const r = button.getBoundingClientRect();
2507
+ openMenuAt({ x: r.left, y: r.bottom + 4 }, heldMenu(local({ clientX: r.left + r.width / 2, clientY: r.bottom })));
2508
+ },
2509
+ });
2510
+ }
2511
+ return out;
2512
+ })();
2513
+ /* where it is drawn (overlay px): its frame (turned: the box around it), else its shape, else several's union */
2514
+ const aabbOf = (f: FrameGeometry): ClientBox => { const a = frameAabb(f); return { left: a.x, top: a.y, width: a.w, height: a.h }; };
2515
+ const barBox = !barKind || !picked ? null : mgFrame ? aabbOf(mgFrame) : layerFrame ? aabbOf(layerFrame) : scaleBox(picked.layer?.rect ?? picked.rect, vsNow);
2516
+ const barBoxRef = React.useRef(barBox);
2517
+ barBoxRef.current = barBox;
2518
+ /* inside the viewer: from its top (its bar of buttons) to the bottom of the picture's area (not over the transport) */
2519
+ const measureBar = () => {
2520
+ const box = barBoxRef.current;
2521
+ const film = stageLayer.current?.getBoundingClientRect();
2522
+ if (!box || !film) return null;
2523
+ const pane = host.current?.closest('[data-viewer-pane]')?.getBoundingClientRect();
2524
+ const area = host.current?.closest('[data-viewer-picture]')?.getBoundingClientRect() ?? pane;
2525
+ const top = pane?.top ?? 0;
2526
+ const bounds = area
2527
+ ? { left: area.left, top, width: area.width, height: area.bottom - top }
2528
+ : { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight };
2529
+ return { anchor: { left: film.left + box.left, top: film.top + box.top, width: box.width, height: box.height }, bounds };
2530
+ };
2531
+ const barKey = !barKind ? null
2532
+ : barKind === 'layers' ? `layers:${(multi ?? []).map((i) => i.layer.handle).join(',')}`
2533
+ : barKind === 'clip' ? `clip:${heldEl?.clipLoc ?? ''}` : `layer:${heldEl?.loc ?? ''}:${picked?.layer?.handle ?? ''}`;
2534
+ const barLead = barKind && onRefer ? {
2535
+ id: 'refer', label: t('selectionBar.addToChat'), icon: <MessageSquarePlus size={14} />, shortcut: hintOf(currentKeymap(), 'refer', MAC),
2536
+ onSelect: () => onRefer(pickedItems()),
2537
+ } : null;
2538
+ const barLabel = barKind === 'layers' ? t('selectionBar.layers').replace('{n}', String(multi?.length ?? 0)) : undefined;
2539
+
2540
+ /**
2541
+ * A press: on the picture, or on the dark around it (a marquee starts there too, so what sits
2542
+ * at an edge can be boxed whole). The moves and the release then come to the overlay, which holds the pointer.
2543
+ * `outside`: in the dark, where nothing can be clicked, only a box drawn: the film plays on until the box is pulled
2544
+ * open (a click there only lets the selection go).
2545
+ */
2546
+ const pauseWhenOpen = React.useRef(false);
2547
+ const pressDown = (e: PressEvent, outside = false) => {
2548
+ if (e.button !== 0) return;
2549
+ e.stopPropagation();
2550
+ /* pause at the press, not the release: a clock still running would move the selection off under the hand */
2551
+ pauseWhenOpen.current = outside;
2552
+ if (!outside) onPause?.();
2553
+ const p = local(e);
2554
+ /* the first press of a double-click is kept: the second one lands where the picture has moved to */
2555
+ pressesRef.current = keepPress(pressesRef.current, { x: p.x / viewScaleNow(), y: p.y / viewScaleNow(), at: e.timeStamp });
2556
+ /* ⌘ held: a box to point at in the chat, whatever is under the press (nothing is selected or moved) */
2557
+ if (onRegion && regionKeyHeld(e)) {
2558
+ /* a box for the chat is meant at once, wherever it starts */
2559
+ if (outside) { pauseWhenOpen.current = false; onPause?.(); }
2560
+ host.current?.setPointerCapture(e.pointerId);
2561
+ regionDraftRef.current = { x1: p.x, y1: p.y, x2: p.x, y2: p.y };
2562
+ setRegionDraft(regionDraftRef.current);
2563
+ setHover(null);
2564
+ return;
2565
+ }
2566
+ measureAt(null);
2567
+ const vs = viewScaleNow();
2568
+ const stagePt = { x: p.x / vs, y: p.y / vs };
2569
+ const target = e.target as HTMLElement | null;
2570
+ /* cropping: the crop's handles move its edges, a press inside it pans; a press elsewhere keeps the crop */
2571
+ const crop = cropRef.current;
2572
+ if (crop) {
2573
+ const part = target?.closest('[data-crop-kind]');
2574
+ const kind = part?.getAttribute('data-crop-kind');
2575
+ if (kind === 'move' || kind === 'scale' || kind === 'stretch') {
2576
+ cropDrag.current = {
2577
+ kind,
2578
+ handle: HANDLE_AT[part?.getAttribute('data-crop-handle') ?? ''] ?? { hx: 0, hy: 0 },
2579
+ start: stagePt,
2580
+ rect0: crop.rect,
2581
+ pos0: crop.pos,
2582
+ };
2583
+ setDragging(true);
2584
+ host.current?.setPointerCapture(e.pointerId);
2585
+ } else {
2586
+ finishCrop(true);
2587
+ }
2588
+ return;
2589
+ }
2590
+ const handle = target?.closest('[data-mg-handle]:not([data-mg-kind="move"])') as HTMLElement | null;
2591
+ const moveClip = target?.closest('[data-mg-kind="move"]');
2592
+ const outer = pickedRef.current?.element;
2593
+ const origin = liveTRef.current ?? outer?.transform;
2594
+ const currentTarget = host.current!;
2595
+ const pointerId = e.pointerId;
2596
+ ++pressSeq.current;
2597
+ pressGo.current = Promise.resolve(true);
2598
+ const startGesture = (g: Omit<NonNullable<typeof gestureRef.current>, 'snapTargets'>) => {
2599
+ if (clipNudge.current) flushClipNudge();
2600
+ const gesture = { ...g, snapTargets: [{ x: 0, y: 0, w: stage.w, h: stage.h }] };
2601
+ gestureRef.current = gesture;
2602
+ /* counted once at the press, and only a move or a scale uses them: the stage first (it wins ties), then the
2603
+ part of each clip on screen now that shows */
2604
+ if (g.kind !== 'rotate') {
2605
+ void ask<{ id: string; visible: boolean; x: number; y: number; w: number; h: number }[]>('clips').then((list) => {
2606
+ if (gestureRef.current !== gesture || !list) return;
2607
+ gesture.snapTargets = [gesture.snapTargets[0]!, ...list.filter((c) => c.visible && c.id !== g.clipId).map(shownBoxOf)];
2608
+ });
2609
+ }
2610
+ liveTRef.current = g.origin;
2611
+ setLiveT(g.origin);
2612
+ /* a handle has no "maybe a click": pressed is dragging */
2613
+ if (!g.pendingClick) setDragging(true);
2614
+ currentTarget.setPointerCapture(pointerId);
2615
+ };
2616
+ const outerCrop = outer?.isMgOuter && outer.clipLoc ? infoOf(outer.clipLoc)?.crop ?? NO_CROP : NO_CROP;
2617
+ const stretchable = outer?.clipKind === 'video';
2618
+ if (onEditClip && handle && outer?.isMgOuter && outer.clipLoc && outer.clipId && outer.mgBox && origin && !lockedClip(outer.clipLoc)) {
2619
+ const kind = handle.getAttribute('data-mg-kind');
2620
+ const at = HANDLE_AT[handle.getAttribute('data-mg-handle') ?? ''];
2621
+ startGesture({
2622
+ kind: kind === 'rotate' ? 'rotate' : 'scale',
2623
+ ...(kind !== 'rotate' && at ? { handle: at } : {}),
2624
+ stretchable,
2625
+ start: stagePt,
2626
+ origin,
2627
+ box: outer.mgBox,
2628
+ crop: outerCrop,
2629
+ clipLoc: outer.clipLoc,
2630
+ clipId: outer.clipId,
2631
+ });
2632
+ return;
2633
+ }
2634
+ /*
2635
+ * A press inside the clip in hand moves it. Only the clip in hand: if a press on any clip started a drag,
2636
+ * every drag would move something and the marquee would never happen (and a move is a real edit).
2637
+ */
2638
+ if (onEditClip && outer?.isMgOuter && outer.clipLoc && outer.clipId && outer.mgBox && origin && !lockedClip(outer.clipLoc)
2639
+ && (moveClip || insideFrame(visibleFrame(origin, outer.mgBox, outerCrop), stagePt.x, stagePt.y))) {
2640
+ startGesture({
2641
+ kind: 'move',
2642
+ stretchable,
2643
+ pendingClick: true,
2644
+ start: stagePt,
2645
+ origin,
2646
+ box: outer.mgBox,
2647
+ crop: outerCrop,
2648
+ clipLoc: outer.clipLoc,
2649
+ clipId: outer.clipId,
2650
+ hit: { x: p.x, y: p.y },
2651
+ });
2652
+ return;
2653
+ }
2654
+ /* several clips selected: a press on one of them drags them all (Shift: a marquee
2655
+ that adds to them instead) */
2656
+ const linkedNow = linkedLocs ?? [];
2657
+ const groupHit = target?.closest('[data-group-kind]') as HTMLElement | null;
2658
+ if (linkedNow.length > 1 && groupBox && onEditClip && !e.shiftKey && !linkedNow.some((loc) => lockedClip(loc))
2659
+ && (groupHit || insideBox(groupBox, stagePt.x, stagePt.y))) {
2660
+ const items = linkedNow.flatMap((loc) => {
2661
+ const info = infoOf(loc);
2662
+ return info?.box ? [{ loc, id: info.id, t0: info.t, box: info.box }] : [];
2663
+ });
2664
+ const kind = groupHit?.getAttribute('data-group-kind');
2665
+ const handle = kind === 'scale' || kind === 'stretch' || kind === 'rotate' ? groupHit!.getAttribute('data-group-handle') : null;
2666
+ const g = groupBox;
2667
+ const mid = { x: g.left + g.width / 2, y: g.top + g.height / 2 };
2668
+ /* a corner or an edge's middle, on the side pulled (`far`: the opposite one, which stays) */
2669
+ const at = (h: string, far: boolean) => ({
2670
+ x: h.includes('e') || h.includes('w') ? g.left + (h.includes('e') !== far ? g.width : 0) : mid.x,
2671
+ y: h.includes('n') || h.includes('s') ? g.top + (h.includes('s') !== far ? g.height : 0) : mid.y,
2672
+ });
2673
+ const turning = kind === 'rotate';
2674
+ clipsDrag.current = {
2675
+ kind: turning ? 'rotate' : handle ? 'scale' : 'move', handle: turning ? null : handle,
2676
+ x: p.x, y: p.y, moved: false, items, dx: 0, dy: 0, kx: 1, ky: 1, turn: 0, box0: g,
2677
+ anchor: turning ? mid : handle ? at(handle, true) : { x: 0, y: 0 },
2678
+ from: handle && !turning ? at(handle, false) : { x: 0, y: 0 },
2679
+ a0: Math.atan2(stagePt.y - mid.y, stagePt.x - mid.x),
2680
+ };
2681
+ currentTarget.setPointerCapture(pointerId);
2682
+ return;
2683
+ }
2684
+ /* several selected: a press inside their box drags them all (Shift adds / takes away instead); its handles scale
2685
+ them or turn them together */
2686
+ const many = multiRef.current;
2687
+ const held0 = pickedRef.current;
2688
+ const pullKind = groupHit?.getAttribute('data-group-kind');
2689
+ if (many && held0 && onTransformLayers && !many.some((it) => lockedClip(it.element.clipLoc)) && (pullKind === 'scale' || pullKind === 'stretch' || pullKind === 'rotate')) {
2690
+ const handle = pullKind === 'rotate' ? null : groupHit!.getAttribute('data-group-handle');
2691
+ const g = held0.rect;
2692
+ const mid = { x: g.left + g.width / 2, y: g.top + g.height / 2 };
2693
+ const at = (h: string, far: boolean) => ({
2694
+ x: h.includes('e') || h.includes('w') ? g.left + (h.includes('e') !== far ? g.width : 0) : mid.x,
2695
+ y: h.includes('n') || h.includes('s') ? g.top + (h.includes('s') !== far ? g.height : 0) : mid.y,
2696
+ });
2697
+ const items = multiItemsNow();
2698
+ layersPull.current = {
2699
+ kind: pullKind === 'rotate' ? 'rotate' : 'scale', handle,
2700
+ x: p.x, y: p.y, moved: false, items, centers: items.map((it) => ({ x: it.layer.frame.cx, y: it.layer.frame.cy })),
2701
+ dx: 0, dy: 0, kx: 1, ky: 1, turn: 0, box0: g,
2702
+ anchor: pullKind === 'rotate' ? mid : handle ? at(handle, true) : mid,
2703
+ from: handle ? at(handle, false) : { x: 0, y: 0 },
2704
+ a0: Math.atan2(stagePt.y - mid.y, stagePt.x - mid.x),
2705
+ };
2706
+ currentTarget.setPointerCapture(pointerId);
2707
+ return;
2708
+ }
2709
+ if (many && held0 && !e.shiftKey && onTransformLayers && !many.some((it) => lockedClip(it.element.clipLoc)) && insideBox(scaleBox(held0.rect, vs), p.x, p.y)) {
2710
+ multiDrag.current = { x: p.x, y: p.y, moved: false, rect0: held0.rect, items: multiItemsNow() };
2711
+ currentTarget.setPointerCapture(pointerId);
2712
+ return;
2713
+ }
2714
+ shiftBase.current = e.shiftKey ? selectedLayers() : null;
2715
+ /* the layer in hand: its handles scale / turn it, a press in its box moves it (Alt in its box is kept for
2716
+ cycling down) */
2717
+ const held = pickedRef.current;
2718
+ const heldLayer0 = held?.layer;
2719
+ const layerHandle = target?.closest('[data-layer-kind]') as HTMLElement | null;
2720
+ const layerKind = layerHandle?.getAttribute('data-layer-kind');
2721
+ if (held?.element && layerMovable(heldLayer0, held.element)) {
2722
+ const base = heldLayer0.geo.g;
2723
+ const frame0 = scaleFrame(heldLayer0.frame, vs);
2724
+ const aabb0 = scaleBox(heldLayer0.rect, vs);
2725
+ const inside = insideBox(scaleBox(held.rect, vs), p.x, p.y);
2726
+ const kind = layerKind === 'scale' ? 'scale'
2727
+ : layerKind === 'stretch' ? 'stretch'
2728
+ : layerKind === 'rotate' ? 'rotate'
2729
+ : (layerKind === 'move' || inside) && !e.altKey ? 'move' : null;
2730
+ const at = HANDLE_AT[layerHandle?.getAttribute('data-layer-handle') ?? ''] ?? { hx: 0, hy: 0 };
2731
+ if (kind) {
2732
+ if (nudge.current) flushNudge();
2733
+ const d: LayerDrag = {
2734
+ kind,
2735
+ hx: at.hx,
2736
+ hy: at.hy,
2737
+ x: p.x,
2738
+ y: p.y,
2739
+ base,
2740
+ frame0,
2741
+ aabb0,
2742
+ scale: heldLayer0.geo.k * vs,
2743
+ layer: heldLayer0,
2744
+ element: held.element,
2745
+ /* a handle drags at once; a press in the box may be a click until it goes 4px */
2746
+ moved: kind !== 'move',
2747
+ now: base,
2748
+ targets: [{ x: 0, y: 0, w: stage.w * vs, h: stage.h * vs }],
2749
+ };
2750
+ layerDrag.current = d;
2751
+ if (kind !== 'rotate') {
2752
+ void ask<{ x: number; y: number; w: number; h: number }[]>('snaps', { handle: heldLayer0.handle }).then((list) => {
2753
+ if (list && layerDrag.current === d) d.targets = list.map((r) => ({ x: r.x * vs, y: r.y * vs, w: r.w * vs, h: r.h * vs }));
2754
+ });
2755
+ }
2756
+ if (kind !== 'move') {
2757
+ setDragging(true);
2758
+ setHover(null);
2759
+ }
2760
+ currentTarget.setPointerCapture(pointerId);
2761
+ return;
2762
+ }
2763
+ }
2764
+ /* about to draw: the previous selection's box goes first (two boxes on screen would read as two selected); the
2765
+ inspector and the timeline keep it until the release says what is selected instead (or nothing), so the
2766
+ inspector goes from one layer to the next, not through the film's settings. A press on a clip selected on
2767
+ the timeline (several: no handles) is not a press elsewhere */
2768
+ cycleFrom.current = e.altKey ? pickedRef.current?.layer?.handle ?? null : null;
2769
+ pressedFrom.current = pickedRef.current?.layer?.handle ?? null;
2770
+ const clearPrevious = () => {
2771
+ dropPick();
2772
+ setHover(null);
2773
+ };
2774
+ const linked = linkedLocs ?? [];
2775
+ if (linked.length) {
2776
+ pressGo.current = ask<string>('clipAt', stagePt).then((id) => {
2777
+ const loc = id ? clipLocOf(docRef.current, id) : undefined;
2778
+ if (loc && linked.includes(loc)) {
2779
+ draftRef.current = null;
2780
+ setDraft(null);
2781
+ return false;
2782
+ }
2783
+ clearPrevious();
2784
+ return true;
2785
+ });
2786
+ } else {
2787
+ clearPrevious();
2788
+ }
2789
+ /* a click or a marquee is decided at the release */
2790
+ currentTarget.setPointerCapture(pointerId);
2791
+ marqueeClips.current = onSelectClips ? ask<StageClipBox[]>('clips') : null;
2792
+ draftRef.current = { x1: p.x, y1: p.y, x2: p.x, y2: p.y };
2793
+ setDraft(draftRef.current);
2794
+ };
2795
+ const pressDownRef = React.useRef(pressDown);
2796
+ pressDownRef.current = pressDown;
2797
+ React.useEffect(() => {
2798
+ const area = host.current?.closest<HTMLElement>('[data-viewer-picture]');
2799
+ if (!area) return;
2800
+ const onDown = (e: PointerEvent) => {
2801
+ const el = e.target as Element | null;
2802
+ if (e.button !== 0 || !el || host.current?.contains(el) || el.closest(CONTROLS)) return;
2803
+ /* only the dark around the picture: what lies over the picture (the subtitles) has its own presses */
2804
+ const r = filmRect();
2805
+ if (r && e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom) return;
2806
+ pressDownRef.current(e, true);
2807
+ };
2808
+ area.addEventListener('pointerdown', onDown);
2809
+ return () => area.removeEventListener('pointerdown', onDown);
2810
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
2811
+
2812
+ return (
2813
+ <div
2814
+ ref={host}
2815
+ data-film-select
2816
+ /* below the subtitles (z-5): they must get the pointer in their own box to be dragged */
2817
+ /* the cursor stays an arrow: text cursors over the size label would read as "this can be typed in" */
2818
+ className="absolute inset-0 z-[4] cursor-default overflow-visible"
2819
+ /* while typing in place the overlay steps aside: the pointer goes to the line (move the caret, select words) */
2820
+ style={{ touchAction: 'none', pointerEvents: editingText != null ? 'none' : undefined, ...(modAt || regionDraft ? { cursor: 'crosshair' } : {}) }}
2821
+ onDoubleClick={onStageDoubleClick}
2822
+ onContextMenu={onStageContextMenu}
2823
+ {...pictureDrag}
2824
+ onClick={(e) => e.stopPropagation()}
2825
+ onPointerDown={pressDown}
2826
+ onPointerMove={(e) => {
2827
+ pointerAt.current = local(e);
2828
+ if (cropDrag.current) { stepCrop(local(e), e.shiftKey); return; }
2829
+ if (onRegion) {
2830
+ const p = local(e);
2831
+ const region = regionDraftRef.current;
2832
+ if (region) {
2833
+ regionDraftRef.current = { ...region, x2: p.x, y2: p.y };
2834
+ setRegionDraft(regionDraftRef.current);
2835
+ return;
2836
+ }
2837
+ const held = regionKeyHeld(e) && !gestureRef.current && !layerDrag.current && !multiDrag.current && !layersPull.current && !draftRef.current;
2838
+ setModAt(held ? p : null);
2839
+ /* nothing is hovered while ⌘ is held: what a box takes in is not one layer */
2840
+ if (held) { setHover(null); return; }
2841
+ }
2842
+ /* Alt held with something in hand: measuring, not hovering */
2843
+ if (e.altKey && pickedRef.current && !gestureRef.current && !layerDrag.current && !multiDrag.current && !layersPull.current && !draftRef.current && !cropRef.current) {
2844
+ measureAt(local(e));
2845
+ setHover(null);
2846
+ return;
2847
+ }
2848
+ if (altHeld.current) measureAt(null);
2849
+ if (clipsDrag.current) {
2850
+ const d = clipsDrag.current;
2851
+ const p = local(e);
2852
+ const vs = viewScaleNow();
2853
+ if (!d.moved && Math.hypot(p.x - d.x, p.y - d.y) < 4) return;
2854
+ if (!d.moved) { d.moved = true; setDragging(true); setHover(null); }
2855
+ const at = { x: p.x / vs, y: p.y / vs };
2856
+ if (d.kind === 'move') {
2857
+ d.dx = (p.x - d.x) / vs;
2858
+ d.dy = (p.y - d.y) / vs;
2859
+ } else {
2860
+ /* a corner scales both ways alike (a page is never stretched); Shift turns in steps of 15° */
2861
+ pullGroup(d, at, e.shiftKey);
2862
+ if (d.kind === 'rotate') setGroupTurn({ box: d.box0, turn: d.turn });
2863
+ }
2864
+ const moved = d.items.map((it) => ({ loc: it.loc, box: it.box, t: groupDragged(d, it.t0, it.box) }));
2865
+ setClipsMoved(moved);
2866
+ clipsPlace.push(d.items.map((it, i) => ({ clip: it.id, box: compactBox(moved[i]!.t, it.box) })));
2867
+ return;
2868
+ }
2869
+ if (layersPull.current) {
2870
+ const d = layersPull.current;
2871
+ const p = local(e);
2872
+ const vs = viewScaleNow();
2873
+ if (!d.moved && Math.hypot(p.x - d.x, p.y - d.y) < 4) return;
2874
+ if (!d.moved) { d.moved = true; setDragging(true); setHover(null); }
2875
+ pullGroup(d, { x: p.x / vs, y: p.y / vs }, e.shiftKey);
2876
+ if (d.kind === 'rotate') setLayersTurn({ box: d.box0, turn: d.turn });
2877
+ /* each one's center goes with the box, each one scaled or turned as much */
2878
+ d.items.forEach((it, i) => {
2879
+ const m = layerInGroup(d, d.centers[i]!, it.base);
2880
+ it.now = { ...it.base, t: movedOffset(it.base, m.dx * vs, m.dy * vs, it.layer.geo.parentR, it.scale), s: m.s, r: m.r };
2881
+ });
2882
+ multiPaint.push(d.items.map((it) => ({ ...it })));
2883
+ /* scaled: the box goes with the hand (turned, it is drawn turned, layersTurn) */
2884
+ if (d.kind === 'scale') {
2885
+ const b = d.box0;
2886
+ const left = d.anchor.x + (b.left - d.anchor.x) * d.kx, top = d.anchor.y + (b.top - d.anchor.y) * d.ky;
2887
+ setPicked((cur) => (cur ? { ...cur, rect: { left, top, width: b.width * d.kx, height: b.height * d.ky } } : cur));
2888
+ }
2889
+ return;
2890
+ }
2891
+ if (multiDrag.current) {
2892
+ const d = multiDrag.current;
2893
+ const p = local(e);
2894
+ const dx = p.x - d.x;
2895
+ const dy = p.y - d.y;
2896
+ if (!d.moved && Math.hypot(dx, dy) < 4) return;
2897
+ if (!d.moved) { d.moved = true; setDragging(true); setHover(null); }
2898
+ shiftLayers(d.items, () => dx, () => dy);
2899
+ const vs = viewScaleNow();
2900
+ setPicked((cur) => (cur ? { ...cur, rect: { ...d.rect0, left: d.rect0.left + dx / vs, top: d.rect0.top + dy / vs } } : cur));
2901
+ return;
2902
+ }
2903
+ if (layerDrag.current) {
2904
+ /* one step a frame: a fast mouse sends several moves a frame, and only the last one shows */
2905
+ layerMoveAt.current = { p: local(e), mods: { shift: e.shiftKey, alt: e.altKey } };
2906
+ if (!layerRaf.current) {
2907
+ layerRaf.current = requestAnimationFrame(() => {
2908
+ layerRaf.current = 0;
2909
+ const at = layerMoveAt.current;
2910
+ layerMoveAt.current = null;
2911
+ if (at && layerDrag.current) stepLayerGesture(at.p, at.mods);
2912
+ });
2913
+ }
2914
+ return;
2915
+ }
2916
+ moveAt.current = { cx: e.clientX, cy: e.clientY, shift: e.shiftKey, alt: e.altKey };
2917
+ if (!moveRaf.current) moveRaf.current = requestAnimationFrame(flushMove);
2918
+ }}
2919
+ onPointerLeave={() => {
2920
+ pointerAt.current = null;
2921
+ setModAt(null);
2922
+ measureAt(null);
2923
+ if (moveRaf.current) {
2924
+ cancelAnimationFrame(moveRaf.current);
2925
+ moveRaf.current = 0;
2926
+ }
2927
+ moveAt.current = null;
2928
+ setHover(null);
2929
+ }}
2930
+ onPointerUp={endPointer}
2931
+ /* the system took the gesture (a pen cancelled, a dialog): released where it last was; the capture lost after a
2932
+ release finds nothing in hand and does nothing */
2933
+ onPointerCancel={endPointer}
2934
+ onLostPointerCapture={endPointer}
2935
+ >
2936
+ <div
2937
+ ref={stageLayer}
2938
+ className="absolute overflow-visible"
2939
+ style={{
2940
+ left: filmOffset.width ? filmOffset.left : 0,
2941
+ top: filmOffset.width ? filmOffset.top : 0,
2942
+ width: filmOffset.width || '100%',
2943
+ height: filmOffset.height || '100%',
2944
+ }}
2945
+ >
2946
+ {/* the clips selected elsewhere, under the selection: what is being moved is always on top */}
2947
+ {linkedShown.map((mark) => <MgOutline key={mark.loc} t={mark.t} box={mark.box} viewScale={vsNow} />)}
2948
+ {clipsMoved?.map((mark) => <MgOutline key={mark.loc} t={mark.t} box={mark.box} viewScale={vsNow} />)}
2949
+ {groupBox ? (() => {
2950
+ const g = groupTurn?.box ?? groupBox;
2951
+ return (
2952
+ <StageTransformFrame
2953
+ frame={{ cx: (g.left + g.width / 2) * vsNow, cy: (g.top + g.height / 2) * vsNow, w: g.width * vsNow, h: g.height * vsNow, r: groupTurn?.turn ?? 0 }}
2954
+ scope="group"
2955
+ /* sides stretch videos only: a page is never stretched, a still keeps its shape (as one in hand) */
2956
+ edges={groupStretch}
2957
+ moving={dragging}
2958
+ label={groupTurn ? `${Math.round(groupTurn.turn)}°` : `${Math.round(g.width)} × ${Math.round(g.height)}`}
2959
+ bounds={stageBounds}
2960
+ />
2961
+ );
2962
+ })() : null}
2963
+ {/* the menu is drawn on body, but React events bubble through the component tree: a press on an item must not
2964
+ reach the stage as a press on the picture */}
2965
+ {menu ? (
2966
+ <div
2967
+ className="contents"
2968
+ onPointerDown={(e) => e.stopPropagation()}
2969
+ onPointerMove={(e) => e.stopPropagation()}
2970
+ onPointerUp={(e) => e.stopPropagation()}
2971
+ onClick={(e) => e.stopPropagation()}
2972
+ onDoubleClick={(e) => e.stopPropagation()}
2973
+ onContextMenu={(e) => { e.stopPropagation(); e.preventDefault(); }}
2974
+ >
2975
+ <ContextMenu x={menu.x} y={menu.y} items={menu.items} onClose={() => setMenu(null)} />
2976
+ </div>
2977
+ ) : null}
2978
+ {/* not on the layer in hand (it has its frame), not while drawing a marquee or dragging */}
2979
+ {hover && !draftRect && !dragging && editingText == null
2980
+ && !(picked && (hover.clip ? picked.element?.isMgOuter && hover.clip === picked.element.clipId : hover.handle === picked.layer?.handle)) ? (
2981
+ <Box rect={scaleBox(hover.rect, vsNow)} frame={hover.frame && scaleFrame(hover.frame, vsNow)} tone="hover" />
2982
+ ) : null}
2983
+ {draftRect ? marqueeHits.map((h, i) => (
2984
+ <Box key={h.clip ?? `${h.handle}:${i}`} rect={scaleBox(h.rect, vsNow)} frame={h.frame && scaleFrame(h.frame, vsNow)} tone="hover" />
2985
+ )) : null}
2986
+ {draftRect ? <Box rect={draftRect} tone="draft" /> : null}
2987
+ {/* the picture a dragged file would take the place of */}
2988
+ {dropOn ? <Box rect={scaleBox(dropOn.rect, vsNow)} frame={dropOn.frame && scaleFrame(dropOn.frame, vsNow)} tone="region" /> : null}
2989
+ {regionDraft ? <Box rect={boxOfDrag(regionDraft)} tone="region" /> : null}
2990
+ {flash ? <Box rect={scaleBox({ left: flash.box.x, top: flash.box.y, width: flash.box.w, height: flash.box.h }, vsNow)} tone="region" fading={flash.fading} /> : null}
2991
+ {chatClip?.box ? <MgOutline t={chatClip.t} box={chatClip.box} viewScale={vsNow} color={CHAT_TONE} /> : null}
2992
+ {chat?.box ? <Box rect={scaleBox(clientOf(chat.box), vsNow)} tone="chat" /> : null}
2993
+ {chat?.numbered.map(({ n, box }) => (
2994
+ <React.Fragment key={n}>
2995
+ <Box rect={scaleBox(clientOf(box), vsNow)} tone="chat" />
2996
+ <span data-chat-number={n} className="pointer-events-none absolute flex h-[16px] min-w-[16px] -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full px-[4px] text-[10px] font-semibold leading-none tabular-nums text-white shadow-[0_1px_3px_rgba(0,0,0,.4)]"
2997
+ style={{ left: box.x * vsNow, top: box.y * vsNow, background: CHAT_TONE }}>{n}</span>
2998
+ </React.Fragment>
2999
+ ))}
3000
+ {modAt && !regionDraft && regionHint ? <GestureHint x={modAt.x} y={modAt.y} text={regionHint} /> : null}
3001
+ {liveGuides.map((g) => <Guide key={`${g.axis}:${g.at}`} guide={g} viewScale={vsNow} />)}
3002
+ {cropping ? (
3003
+ <CropOverlay
3004
+ session={cropping}
3005
+ viewScale={vsNow}
3006
+ moving={dragging}
3007
+ bounds={stageBounds}
3008
+ onAspect={setCropAspect}
3009
+ onDone={() => finishCrop(true)}
3010
+ onCancel={() => finishCrop(false)}
3011
+ />
3012
+ ) : null}
3013
+ {measure && picked ? <Measures lines={measure.lines} other={measure.other} viewScale={vsNow} /> : null}
3014
+ {hint ? <GestureHint x={hint.x} y={hint.y} text={hint.text} /> : null}
3015
+ {picked ? (
3016
+ <>
3017
+ {mgFrame ? (
3018
+ /* cropping, the crop's own frame is the one in hand */
3019
+ cropping ? null : maskBox ? outline(mgFrame) : (
3020
+ <StageTransformFrame
3021
+ frame={mgFrame}
3022
+ scope="mg"
3023
+ edges={picked.element?.clipKind === 'video'}
3024
+ moving={liveT != null && dragging}
3025
+ label={sizeLabel(mgFrame)}
3026
+ bounds={stageBounds}
3027
+ />
3028
+ )
3029
+ ) : layerFrame ? (
3030
+ maskBox ? outline(layerFrame) : (
3031
+ <StageTransformFrame frame={layerFrame} scope="layer" edges moving={dragging} label={sizeLabel(layerFrame)} bounds={stageBounds} />
3032
+ )
3033
+ ) : multi && onTransformLayers && !multi.some((it) => lockedClip(it.element.clipLoc)) ? (() => {
3034
+ /* several layers: their box's handles scale and turn them together (layersPull) */
3035
+ const g = layersTurn?.box ?? picked.rect;
3036
+ return (
3037
+ <StageTransformFrame
3038
+ frame={{ cx: (g.left + g.width / 2) * vsNow, cy: (g.top + g.height / 2) * vsNow, w: g.width * vsNow, h: g.height * vsNow, r: layersTurn?.turn ?? 0 }}
3039
+ scope="group"
3040
+ edges
3041
+ moving={dragging}
3042
+ label={layersTurn ? `${Math.round(layersTurn.turn)}°` : `${Math.round(g.width)} × ${Math.round(g.height)}`}
3043
+ bounds={stageBounds}
3044
+ />
3045
+ );
3046
+ })() : (
3047
+ /* what cannot be moved (a locked layer, a group, a clip without its size yet): its drawn shape */
3048
+ <Box
3049
+ rect={scaleBox(picked.layer?.rect ?? picked.rect, vsNow)}
3050
+ frame={picked.layer && !picked.layer.group ? scaleFrame(picked.layer.frame, vsNow) : undefined}
3051
+ tone="picked"
3052
+ />
3053
+ )}
3054
+ {maskBox && maskSession ? <StageMaskOverlay frame={maskBox} session={maskSession} /> : null}
3055
+ {/* several together: each one outlined (turned as drawn); the outer box is their union */}
3056
+ {multi ? multi.map((it) => (
3057
+ <Box key={it.layer.handle} rect={scaleBox(it.layer.rect, vsNow)} frame={scaleFrame(it.layer.frame, vsNow)} tone="hover" />
3058
+ )) : null}
3059
+ </>
3060
+ ) : null}
3061
+ </div>
3062
+ <SelectionToolbar
3063
+ selection={barKey}
3064
+ measure={measureBar}
3065
+ hidden={Boolean(playing || dragging || cropping || maskSession || regionDraft || draftRect || editingText != null)}
3066
+ lead={barLead}
3067
+ {...(barLabel ? { label: barLabel } : {})}
3068
+ tools={barTools}
3069
+ ariaLabel={t('selectionBar.toolbar')}
3070
+ />
3071
+ </div>
3072
+ );
3073
+ }
3074
+
3075
+ /**
3076
+ * Memoized, and the time compared by its half-second bucket: the stage sits among a tree that renders for many other
3077
+ * reasons, and while playing the time changes every 50ms although only its bucket matters here.
3078
+ */
3079
+ export const FilmStageSelect = React.memo(FilmStageSelectImpl, (a, b) => (
3080
+ a.previewRef === b.previewRef
3081
+ && a.stage.w === b.stage.w
3082
+ && a.stage.h === b.stage.h
3083
+ && a.doc === b.doc
3084
+ && a.spans === b.spans
3085
+ && a.onPause === b.onPause
3086
+ && a.onSelectElement === b.onSelectElement
3087
+ && a.onEditClip === b.onEditClip
3088
+ && Boolean(a.playing) === Boolean(b.playing)
3089
+ && staleBucket(a.timeMs) === staleBucket(b.timeMs)
3090
+ /* by content: the list is rebuilt with every film.html change while the selection is the same clips */
3091
+ && sameLocs(a.linkedLocs, b.linkedLocs)
3092
+ && a.selectRequest?.seq === b.selectRequest?.seq
3093
+ && a.clipPreviewRef === b.clipPreviewRef
3094
+ && a.lookPreviewRef === b.lookPreviewRef
3095
+ && a.controlRef === b.controlRef
3096
+ && a.onTransformLayer === b.onTransformLayer
3097
+ && a.onTransformLayers === b.onTransformLayers
3098
+ && a.onEditTextInline === b.onEditTextInline
3099
+ && a.onGeometry === b.onGeometry
3100
+ && a.menuFor === b.menuFor
3101
+ && a.recoveryMenu === b.recoveryMenu
3102
+ && a.onRegion === b.onRegion
3103
+ && a.regionHint === b.regionHint
3104
+ && a.flashBox?.seq === b.flashBox?.seq
3105
+ && samePictureMarks(a.chat, b.chat)
3106
+ && a.onHoverLayer === b.onHoverLayer
3107
+ && Boolean(a.selectionBar) === Boolean(b.selectionBar)
3108
+ && a.onRefer === b.onRefer
3109
+ && a.onStartMask === b.onStartMask
3110
+ && a.pointLayer?.clipId === b.pointLayer?.clipId
3111
+ && a.pointLayer?.loc === b.pointLayer?.loc
3112
+ && a.onDropPicture === b.onDropPicture
3113
+ ));
3114
+
3115
+ function sameLocs(a: readonly string[] | undefined, b: readonly string[] | undefined): boolean {
3116
+ if (a === b) return true;
3117
+ if (!a || !b) return (a?.length ?? 0) === (b?.length ?? 0);
3118
+ return a.length === b.length && a.every((loc, i) => loc === b[i]);
3119
+ }
3120
+
3121
+ const boxOfDrag = (d: { x1: number; y1: number; x2: number; y2: number }): ClientBox => (
3122
+ { left: Math.min(d.x1, d.x2), top: Math.min(d.y1, d.y2), width: Math.abs(d.x2 - d.x1), height: Math.abs(d.y2 - d.y1) }
3123
+ );
3124
+
3125
+ function Box({ rect, frame, tone, fading }: {
3126
+ rect: ClientBox;
3127
+ /** A turned layer: its turned frame, the same shape hit and selected (see the stage's frameOf). */
3128
+ frame?: StageFrame | undefined;
3129
+ /** `region`: a box pointed at in the chat (drawn with ⌘, or flashed when the app shows one); `chat`: what the chat
3130
+ points at now (a pill hovered there, the message being written). */
3131
+ tone: 'hover' | 'draft' | 'picked' | 'region' | 'chat';
3132
+ /** Fading out (a flashed region). */
3133
+ fading?: boolean;
3134
+ }) {
3135
+ const turned = frame && frame.r ? frame : null;
3136
+ return (
3137
+ <div
3138
+ className="pointer-events-none absolute"
3139
+ style={{
3140
+ ...(turned
3141
+ ? { left: turned.cx - turned.w / 2, top: turned.cy - turned.h / 2, width: turned.w, height: turned.h, transform: `rotate(${turned.r}deg)` }
3142
+ : { left: rect.left, top: rect.top, width: rect.width, height: rect.height }),
3143
+ border: tone === 'picked' || tone === 'region' ? `2px solid ${PICK_BLUE}` : `1px solid ${PICK_BLUE}`,
3144
+ ...(tone === 'draft' ? { background: 'rgba(12,140,232,.13)' } : {}),
3145
+ ...(tone === 'region' ? { background: 'rgba(12,140,232,.2)', borderRadius: 3, transition: 'opacity 300ms ease-out', opacity: fading ? 0 : 1 } : {}),
3146
+ ...(tone === 'picked' ? { background: 'rgba(12,140,232,.06)', boxShadow: '0 0 0 1px rgba(255,255,255,.45)' } : {}),
3147
+ ...(tone === 'chat' ? { border: `2px solid ${CHAT_TONE}`, borderRadius: 3, background: `color-mix(in srgb, ${CHAT_TONE} 10%, transparent)`, boxShadow: '0 0 0 1px rgba(255,255,255,.45)' } : {}),
3148
+ }}
3149
+ />
3150
+ );
3151
+ }
3152
+
3153
+ /**
3154
+ * A snap guide, drawn only while snapped (a set of candidate lines always on screen would stop meaning "lined up"),
3155
+ * across the whole picture so it is plain what it lines up with.
3156
+ */
3157
+ function Guide({ guide, viewScale }: { guide: MgGuide; viewScale: number }) {
3158
+ const at = guide.at * viewScale;
3159
+ return (
3160
+ <div
3161
+ className="pointer-events-none absolute z-[6]"
3162
+ style={guide.axis === 'x'
3163
+ ? { left: at, top: 0, bottom: 0, width: 1, background: PICK_BLUE }
3164
+ : { top: at, left: 0, right: 0, height: 1, background: PICK_BLUE }}
3165
+ />
3166
+ );
3167
+ }
3168
+
3169
+ /**
3170
+ * Crop mode on the picture: the clip's whole box with what the crop cuts away darkened (a shadow thrown out of the
3171
+ * crop's rect, clipped by the box), its thirds, the crop's own frame and handles, and a bar of ratios with Done and
3172
+ * Cancel under it. Everything turns with the clip.
3173
+ */
3174
+ function CropOverlay({ session: s, viewScale: vs, moving, bounds, onAspect, onDone, onCancel }: {
3175
+ session: { t: MgTransform; box: MgBox; size: { w: number; h: number }; rect: Rect; aspect: CropAspect };
3176
+ viewScale: number;
3177
+ moving: boolean;
3178
+ bounds: { w: number; h: number };
3179
+ onAspect: (aspect: CropAspect) => void;
3180
+ onDone: () => void;
3181
+ onCancel: () => void;
3182
+ }) {
3183
+ const t = useT();
3184
+ const whole = scaleFrame(visibleFrame(s.t, s.box, NO_CROP), vs);
3185
+ const frame = scaleFrame(visibleFrame(s.t, s.box, cropOfRect(s.rect, s.size.w, s.size.h)), vs);
3186
+ /* the bar under the crop, or over it when the stage's bottom is near (below is the viewer's own bar) */
3187
+ const around = frameAabb(frame);
3188
+ const BAR_H = 30;
3189
+ const below = around.y + around.h + 10;
3190
+ const barTop = below + BAR_H <= bounds.h ? below : Math.max(0, around.y - BAR_H - 10);
3191
+ const ratioLabel = (a: CropAspect) => (a === 'free' ? t('stageCrop.free') : a === 'original' ? t('stageCrop.original') : a);
3192
+ return (
3193
+ <>
3194
+ <div
3195
+ aria-hidden
3196
+ className="pointer-events-none absolute overflow-hidden"
3197
+ style={{
3198
+ left: whole.cx - whole.w / 2,
3199
+ top: whole.cy - whole.h / 2,
3200
+ width: whole.w,
3201
+ height: whole.h,
3202
+ transform: whole.r ? `rotate(${whole.r}deg)` : undefined,
3203
+ }}
3204
+ >
3205
+ <div
3206
+ className="absolute"
3207
+ style={{ left: s.rect.x * vs, top: s.rect.y * vs, width: s.rect.w * vs, height: s.rect.h * vs, boxShadow: '0 0 0 100000px rgba(0,0,0,.55)' }}
3208
+ >
3209
+ {[1, 2].map((i) => (
3210
+ <React.Fragment key={i}>
3211
+ <div className="absolute inset-y-0 w-px bg-white/45" style={{ left: `${(i * 100) / 3}%` }} />
3212
+ <div className="absolute inset-x-0 h-px bg-white/45" style={{ top: `${(i * 100) / 3}%` }} />
3213
+ </React.Fragment>
3214
+ ))}
3215
+ </div>
3216
+ </div>
3217
+ <StageTransformFrame frame={frame} scope="crop" edges rotate={false} moving={moving} label={null} />
3218
+ {/* its own presses: none reaches the stage as a press on the picture, and its buttons take no focus (Enter
3219
+ stays Done) */}
3220
+ <div
3221
+ data-crop-bar
3222
+ role="toolbar"
3223
+ aria-label={t('stageCrop.bar')}
3224
+ className="absolute z-[9] flex -translate-x-1/2 items-center gap-0.5 whitespace-nowrap rounded-[6px] p-[3px] text-[11px] font-medium leading-[16px] text-white shadow-[0_2px_10px_rgba(0,0,0,.35)]"
3225
+ style={{ left: Math.min(Math.max(frame.cx, 0), bounds.w), top: barTop, height: BAR_H, background: 'rgba(30,30,30,.94)' }}
3226
+ onPointerDown={(e) => e.stopPropagation()}
3227
+ onPointerUp={(e) => e.stopPropagation()}
3228
+ onMouseDown={(e) => e.preventDefault()}
3229
+ onDoubleClick={(e) => e.stopPropagation()}
3230
+ onContextMenu={(e) => { e.stopPropagation(); e.preventDefault(); }}
3231
+ >
3232
+ <span className="px-1.5 tabular-nums text-white/60">{Math.round(s.rect.w)} × {Math.round(s.rect.h)}</span>
3233
+ {CROP_ASPECTS.map((a) => (
3234
+ <button
3235
+ key={a}
3236
+ type="button"
3237
+ aria-pressed={s.aspect === a}
3238
+ className={`rounded-[4px] px-1.5 py-0.5 ${s.aspect === a ? 'bg-white/20' : 'hover:bg-white/10'}`}
3239
+ onClick={() => onAspect(a)}
3240
+ >
3241
+ {ratioLabel(a)}
3242
+ </button>
3243
+ ))}
3244
+ <span aria-hidden className="mx-1 h-4 w-px bg-white/20" />
3245
+ <button type="button" title={t('stageCrop.cancelHint')} className="rounded-[4px] px-2 py-0.5 hover:bg-white/10" onClick={onCancel}>
3246
+ {t('stageCrop.cancel')}
3247
+ </button>
3248
+ <button type="button" title={t('stageCrop.doneHint')} className="rounded-[4px] px-2 py-0.5" style={{ background: FRAME_BLUE }} onClick={onDone}>
3249
+ {t('stageCrop.done')}
3250
+ </button>
3251
+ </div>
3252
+ </>
3253
+ );
3254
+ }
3255
+
3256
+ /**
3257
+ * Distances while Alt is held: a red line across each gap with its length in stage px, dashed helpers from
3258
+ * the other box's edge, and the other box outlined.
3259
+ */
3260
+ function Measures({ lines, other, viewScale: vs }: { lines: readonly MeasureLine[]; other: Rect | null; viewScale: number }) {
3261
+ return (
3262
+ <>
3263
+ {other ? (
3264
+ <div
3265
+ aria-hidden
3266
+ className="pointer-events-none absolute z-[6]"
3267
+ style={{ left: other.x * vs, top: other.y * vs, width: other.w * vs, height: other.h * vs, border: `1px solid ${MEASURE_RED}` }}
3268
+ />
3269
+ ) : null}
3270
+ {lines.map((l) => {
3271
+ const across = l.y1 === l.y2;
3272
+ const x = Math.min(l.x1, l.x2) * vs;
3273
+ const y = Math.min(l.y1, l.y2) * vs;
3274
+ const span = (across ? Math.abs(l.x2 - l.x1) : Math.abs(l.y2 - l.y1)) * vs;
3275
+ const stroke = `1px ${l.length == null ? 'dashed' : 'solid'} ${MEASURE_RED}`;
3276
+ return (
3277
+ <React.Fragment key={`${l.x1},${l.y1},${l.x2},${l.y2}`}>
3278
+ <div
3279
+ aria-hidden
3280
+ className="pointer-events-none absolute z-[6]"
3281
+ style={across ? { left: x, top: y, width: span, height: 0, borderTop: stroke } : { left: x, top: y, width: 0, height: span, borderLeft: stroke }}
3282
+ />
3283
+ {l.length != null ? (
3284
+ <div
3285
+ className="pointer-events-none absolute z-[7] -translate-x-1/2 -translate-y-1/2 whitespace-nowrap rounded-[3px] px-1 text-[10px] font-medium leading-[14px] text-white tabular-nums"
3286
+ style={{ left: ((l.x1 + l.x2) / 2) * vs, top: ((l.y1 + l.y2) / 2) * vs, background: MEASURE_RED }}
3287
+ >
3288
+ {Math.round(l.length)}
3289
+ </div>
3290
+ ) : null}
3291
+ </React.Fragment>
3292
+ );
3293
+ })}
3294
+ </>
3295
+ );
3296
+ }