@bycrux/editor 0.11.2 → 1.0.1

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 (268) hide show
  1. package/package.json +3 -1
  2. package/src/ControlsInfoModal.tsx +251 -61
  3. package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
  4. package/src/__tests__/adapter.test.ts +59 -1
  5. package/src/__tests__/schema-assignability.test.ts +93 -0
  6. package/src/__tests__/schema.test.ts +15 -0
  7. package/src/__tests__/video-adapter-contract.test.ts +128 -5
  8. package/src/carousel/AddElementMenu.tsx +10 -4
  9. package/src/carousel/CarouselEditor.tsx +46 -8
  10. package/src/carousel/CarouselRenderModal.tsx +15 -10
  11. package/src/carousel/OverlayPicker.tsx +10 -3
  12. package/src/carousel/SlidePropertyPanel.tsx +43 -21
  13. package/src/components/FilmstripScrubber.tsx +277 -0
  14. package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
  15. package/src/engine/__tests__/audio-clock.test.ts +655 -0
  16. package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
  17. package/src/engine/__tests__/batch-planner.test.ts +298 -0
  18. package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
  19. package/src/engine/__tests__/demux-ranged.test.ts +495 -0
  20. package/src/engine/__tests__/demux-truncated.test.ts +136 -0
  21. package/src/engine/__tests__/demux.test.ts +305 -0
  22. package/src/engine/__tests__/eligibility.test.ts +146 -0
  23. package/src/engine/__tests__/engine-recovery.test.ts +263 -0
  24. package/src/engine/__tests__/engine.test.ts +326 -0
  25. package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
  26. package/src/engine/__tests__/frame-server.test.ts +326 -0
  27. package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
  28. package/src/engine/__tests__/media-loader.test.ts +100 -0
  29. package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
  30. package/src/engine/__tests__/scheduler.test.ts +1310 -0
  31. package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
  32. package/src/engine/__tests__/scrub-source.test.ts +195 -0
  33. package/src/engine/__tests__/source-host.test.ts +455 -0
  34. package/src/engine/__tests__/time-stretch.test.ts +182 -0
  35. package/src/engine/audio-clock.ts +1179 -0
  36. package/src/engine/audio-worklet-source.ts +160 -0
  37. package/src/engine/batch-planner.ts +308 -0
  38. package/src/engine/debug-hud.tsx +54 -0
  39. package/src/engine/decode-worker-source.ts +142 -0
  40. package/src/engine/demux.ts +900 -0
  41. package/src/engine/eligibility.ts +140 -0
  42. package/src/engine/frame-server.ts +644 -0
  43. package/src/engine/index.ts +950 -0
  44. package/src/engine/media-loader.ts +380 -0
  45. package/src/engine/mp4box.d.ts +214 -0
  46. package/src/engine/scheduler.ts +1324 -0
  47. package/src/engine/scrub-resolve.ts +66 -0
  48. package/src/engine/scrub-source.ts +496 -0
  49. package/src/engine/time-stretch.ts +224 -0
  50. package/src/index.ts +83 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +146 -3
  53. package/src/state/__tests__/use-project-sync.test.tsx +56 -0
  54. package/src/state/use-project-sync.ts +17 -0
  55. package/src/test-setup.ts +32 -0
  56. package/src/text/FontPicker.tsx +85 -51
  57. package/src/text/TextFormattingToolbar.tsx +6 -1
  58. package/src/text/__tests__/FontPicker.options.test.ts +43 -0
  59. package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
  60. package/src/theme.ts +108 -3
  61. package/src/types.ts +601 -22
  62. package/src/ui/Loader.tsx +64 -0
  63. package/src/ui/NumberField.tsx +254 -0
  64. package/src/ui/Slider.tsx +128 -0
  65. package/src/ui/Tooltip.tsx +118 -0
  66. package/src/ui/__tests__/NumberField.test.tsx +298 -0
  67. package/src/ui/__tests__/Slider.test.tsx +150 -0
  68. package/src/ui/__tests__/Tooltip.test.tsx +105 -0
  69. package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
  70. package/src/ui/badge.tsx +12 -4
  71. package/src/ui/index.ts +6 -0
  72. package/src/ui/input.tsx +1 -1
  73. package/src/ui/select.tsx +1 -1
  74. package/src/ui/switch.tsx +12 -2
  75. package/src/ui/textarea.tsx +1 -1
  76. package/src/ui/usePersistentState.ts +63 -0
  77. package/src/video/AudioPolishModal.tsx +983 -0
  78. package/src/video/CaptionListPanel.test.tsx +944 -0
  79. package/src/video/CaptionListPanel.tsx +1106 -0
  80. package/src/video/CaptionRegenModal.tsx +37 -9
  81. package/src/video/CaptionSpecimen.tsx +160 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +137 -0
  85. package/src/video/OverlayInspector.tsx +977 -0
  86. package/src/video/RenderModal.tsx +1035 -62
  87. package/src/video/VersionCompare.tsx +258 -0
  88. package/src/video/VersionPanel.tsx +117 -42
  89. package/src/video/VideoEditor.tsx +2229 -243
  90. package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
  91. package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
  92. package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
  93. package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
  94. package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
  95. package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
  96. package/src/video/__tests__/CommandPalette.test.tsx +119 -0
  97. package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
  98. package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
  99. package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
  100. package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
  101. package/src/video/__tests__/VersionCompare.test.tsx +182 -0
  102. package/src/video/__tests__/VersionPanel.test.tsx +279 -0
  103. package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
  104. package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
  105. package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
  106. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
  107. package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
  108. package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
  109. package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
  110. package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
  111. package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
  112. package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
  113. package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
  114. package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
  115. package/src/video/__tests__/VideoEditor.test.tsx +403 -50
  116. package/src/video/__tests__/audioMagnet.test.ts +158 -0
  117. package/src/video/__tests__/audioPolish.test.ts +1202 -0
  118. package/src/video/__tests__/captionActiveWord.test.ts +107 -0
  119. package/src/video/__tests__/captionLanes.test.ts +262 -0
  120. package/src/video/__tests__/captionPositioning.test.tsx +8 -3
  121. package/src/video/__tests__/captionWordFloor.test.ts +228 -0
  122. package/src/video/__tests__/clipboard-ops.test.ts +430 -0
  123. package/src/video/__tests__/cuts.insert.test.ts +244 -0
  124. package/src/video/__tests__/cuts.test.ts +1213 -34
  125. package/src/video/__tests__/export-limits.test.ts +195 -0
  126. package/src/video/__tests__/hover-scrub.test.ts +163 -0
  127. package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
  128. package/src/video/__tests__/keyframeOps.test.ts +643 -0
  129. package/src/video/__tests__/keymap.test.tsx +171 -0
  130. package/src/video/__tests__/render-progress.test.tsx +20 -4
  131. package/src/video/__tests__/shuttle.test.ts +210 -0
  132. package/src/video/__tests__/source-preview.test.ts +60 -0
  133. package/src/video/__tests__/timecode.test.ts +77 -0
  134. package/src/video/__tests__/use-report-context.test.tsx +101 -0
  135. package/src/video/audioMagnet.ts +72 -0
  136. package/src/video/audioPolish.ts +774 -0
  137. package/src/video/captionActiveWord.ts +74 -0
  138. package/src/video/captionLanes.ts +202 -0
  139. package/src/video/captionRepair.ts +9 -5
  140. package/src/video/captionStyleDefaults.ts +100 -0
  141. package/src/video/captionWordFloor.ts +83 -0
  142. package/src/video/clipboard-ops.ts +377 -0
  143. package/src/video/cuts.ts +713 -37
  144. package/src/video/export-limits.ts +102 -0
  145. package/src/video/hover-scrub.ts +102 -0
  146. package/src/video/imageTone.ts +58 -0
  147. package/src/video/imageToneExamples.ts +10 -0
  148. package/src/video/keyframeOps.ts +384 -0
  149. package/src/video/keymap.ts +146 -0
  150. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  151. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  152. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  153. package/src/video/panels/TabNav.tsx +60 -0
  154. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  155. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  156. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  157. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  158. package/src/video/preview/CaptionPreview.tsx +57 -69
  159. package/src/video/preview/EngineSurface.tsx +103 -0
  160. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  161. package/src/video/preview/PreviewPlayer.tsx +337 -56
  162. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  163. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  164. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  165. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  167. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  168. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  169. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  170. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  171. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  172. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  173. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  174. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  175. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  176. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  177. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  178. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  179. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  180. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  181. package/src/video/preview/audio-context.ts +111 -0
  182. package/src/video/preview/captionDragState.ts +91 -1
  183. package/src/video/preview/proxySupport.ts +86 -0
  184. package/src/video/preview/transformStyle.ts +22 -12
  185. package/src/video/preview/useDragOverlay.ts +92 -18
  186. package/src/video/preview/useEnginePlayback.ts +625 -0
  187. package/src/video/preview/useVideoPlayback.ts +211 -167
  188. package/src/video/sdrCurves.ts +56 -0
  189. package/src/video/shuttle.ts +159 -0
  190. package/src/video/source-preview.ts +66 -0
  191. package/src/video/timecode.ts +59 -0
  192. package/src/video/timeline/EditableSegment.tsx +1 -1
  193. package/src/video/timeline/Scrubber.tsx +46 -174
  194. package/src/video/timeline/SpeedControl.tsx +95 -0
  195. package/src/video/timeline/Timeline.tsx +1061 -328
  196. package/src/video/timeline/TimelineContext.ts +17 -10
  197. package/src/video/timeline/TrackGutter.tsx +560 -0
  198. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  199. package/src/video/timeline/VolumeControl.tsx +113 -0
  200. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  201. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  202. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  204. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  205. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  206. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  208. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  209. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  210. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  211. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  212. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  213. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  214. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  215. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  216. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  217. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  218. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  219. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  220. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  225. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  226. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  227. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  228. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  229. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  230. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  231. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  232. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  233. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  234. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  235. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  236. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  237. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  238. package/src/video/timeline/canvas/draw.ts +2187 -0
  239. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  240. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  241. package/src/video/timeline/canvas/hit-test.ts +501 -0
  242. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  243. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  244. package/src/video/timeline/canvas/snap.ts +232 -0
  245. package/src/video/timeline/canvas/viewport.ts +457 -0
  246. package/src/video/timeline/canvas/waveforms.ts +664 -0
  247. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  248. package/src/video/timeline/multiSelectOps.ts +214 -55
  249. package/src/video/timeline/placement.ts +282 -0
  250. package/src/video/timeline/timeline-model.ts +919 -0
  251. package/src/video/timeline/useItemDragDrop.ts +151 -178
  252. package/src/video/timeline/useTimelineZoom.ts +25 -60
  253. package/src/video/timeline/utils.ts +0 -12
  254. package/src/video/use-report-context.ts +75 -0
  255. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  256. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  257. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  258. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  259. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  260. package/src/video/timeline/PlayheadLine.tsx +0 -18
  261. package/src/video/timeline/TranscriptModal.tsx +0 -70
  262. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  263. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  264. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  265. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  266. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  267. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  268. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -0,0 +1,214 @@
1
+ import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { Check, Instagram, Music2, Slash, Youtube, type LucideIcon } from 'lucide-react'
4
+ import type { SocialPreviewPlatform } from './SocialSafeZoneOverlay'
5
+
6
+ /**
7
+ * Platform picker for the realistic social-media preview chrome — mirrors
8
+ * CapCut's "Preview your video for social media" menu. Opened from the
9
+ * preview controls-row button (see VideoEditor.tsx); lists TikTok, YouTube
10
+ * Shorts and Instagram Reels, plus a "None" entry that clears the selection.
11
+ *
12
+ * Positioning is the same "render once off-screen, measure, place" two-pass
13
+ * shape `TrackSettingsPopover.tsx` uses (portaled to `document.body` for the
14
+ * same reason: this trigger sits inside `previewRegion`'s `overflow-hidden`
15
+ * box, which would clip an `absolute`-positioned popover the moment it grew
16
+ * past the row's own bounds). It DEFAULTS to opening upward rather than
17
+ * `TrackSettingsPopover`'s downward-by-default: the trigger lives in the
18
+ * BOTTOM preview controls row, so there's rarely room below it, and this is
19
+ * the same "icon variant opens upward" call `ImageToneMenu.tsx` makes for its
20
+ * own bottom-toolbar placement.
21
+ */
22
+
23
+ const OFFSET_PX = 6
24
+ const VIEWPORT_MARGIN_PX = 8
25
+
26
+ interface Position {
27
+ left: number
28
+ top: number
29
+ }
30
+
31
+ export interface SocialPreviewMenuProps {
32
+ /** The controls-row button this popover is anchored to and positioned against. */
33
+ anchorRef: RefObject<HTMLButtonElement | null>
34
+ /** Currently selected platform. `null` = "None" is the active entry. */
35
+ value: SocialPreviewPlatform | null
36
+ /** Persist a new selection (or clear it, via `null`). */
37
+ onChange: (platform: SocialPreviewPlatform | null) => void
38
+ onClose: () => void
39
+ /** Editor theme mode — light/dark. This popover portals onto
40
+ * `--editor-surface`, NOT over the black video canvas like the rest of
41
+ * `preview/`, so its accents have to read on a light ground: the selected
42
+ * checkmark's sky-400 is ~2.9:1 on white. Defaults to `'dark'`, so a caller
43
+ * that omits it renders exactly as before. */
44
+ mode?: 'light' | 'dark'
45
+ }
46
+
47
+ /**
48
+ * A platform's small identifying mark: a nominative "this is the TikTok/
49
+ * YouTube/Instagram row" glyph, NOT a pixel-exact reproduction of the
50
+ * official trademarked logo. `Youtube`/`Instagram` are lucide's own
51
+ * simplified brand glyphs (already generic outline icons, not hand-traced
52
+ * copies of the real marks); TikTok has no such lucide glyph, so it gets a
53
+ * generic music-note icon instead, which is enough to read as "TikTok" next
54
+ * to the plain-text label without drawing the note/logo itself.
55
+ */
56
+ export interface PlatformOption {
57
+ id: SocialPreviewPlatform
58
+ label: string
59
+ icon: LucideIcon
60
+ /** Tailwind background for the small badge behind the glyph — a solid
61
+ * color for YouTube's red, a two-stop gradient standing in for TikTok's
62
+ * cyan/pink and Instagram's purple/pink duotone marks. */
63
+ badgeClassName: string
64
+ }
65
+
66
+ export const PLATFORM_OPTIONS: PlatformOption[] = [
67
+ { id: 'tiktok', label: 'TikTok', icon: Music2, badgeClassName: 'bg-gradient-to-br from-cyan-400 to-pink-500' },
68
+ { id: 'youtube', label: 'YouTube Shorts', icon: Youtube, badgeClassName: 'bg-red-600' },
69
+ { id: 'instagram', label: 'Instagram Reels', icon: Instagram, badgeClassName: 'bg-gradient-to-br from-purple-500 to-pink-500' },
70
+ ]
71
+
72
+ /** `PLATFORM_OPTIONS` entry for a platform, or `null` for "None"/unset — the
73
+ * lookup VideoEditor's trigger button uses to show the active selection's
74
+ * glyph (falling back to its own Smartphone icon on `null`). */
75
+ export function platformOption(platform: SocialPreviewPlatform | null): PlatformOption | null {
76
+ return platform ? PLATFORM_OPTIONS.find(o => o.id === platform) ?? null : null
77
+ }
78
+
79
+ /** The glyph itself — a small colored badge, decorative only. The
80
+ * accessible name is always the platform's plain-text label (on the menu
81
+ * row) or the trigger's own `aria-label` — never this glyph, hence
82
+ * `aria-hidden`. */
83
+ export function PlatformGlyph({ icon: Icon, badgeClassName, size = 20 }: { icon: LucideIcon; badgeClassName: string; size?: number }) {
84
+ return (
85
+ <span
86
+ aria-hidden="true"
87
+ className={`flex items-center justify-center rounded-md shrink-0 ${badgeClassName}`}
88
+ style={{ width: size, height: size }}
89
+ >
90
+ <Icon size={Math.round(size * 0.65)} strokeWidth={2.25} className="text-white" />
91
+ </span>
92
+ )
93
+ }
94
+
95
+ export default function SocialPreviewMenu({ anchorRef, value, onChange, onClose, mode = 'dark' }: SocialPreviewMenuProps) {
96
+ const popoverRef = useRef<HTMLDivElement>(null)
97
+ const [position, setPosition] = useState<Position | null>(null)
98
+
99
+ // Measure once the popover has a real size, then place it. Anchored from
100
+ // the trigger's right edge (the trigger sits at the right end of the
101
+ // controls row, alongside Zoom-to-fit/Fullscreen) so the menu's right edge
102
+ // lines up with the button rather than running off the right edge of the
103
+ // window. Opens upward by default (see the file-level doc comment); falls
104
+ // back to downward only when there truly isn't room above.
105
+ useLayoutEffect(() => {
106
+ const anchorRect = anchorRef.current?.getBoundingClientRect()
107
+ const popover = popoverRef.current
108
+ if (!anchorRect || !popover) return
109
+ const { width, height } = popover.getBoundingClientRect()
110
+
111
+ let left = anchorRect.right - width
112
+ if (left < VIEWPORT_MARGIN_PX) left = VIEWPORT_MARGIN_PX
113
+ if (left + width > window.innerWidth - VIEWPORT_MARGIN_PX) {
114
+ left = window.innerWidth - VIEWPORT_MARGIN_PX - width
115
+ }
116
+
117
+ const fitsAbove = anchorRect.top - OFFSET_PX - height >= VIEWPORT_MARGIN_PX
118
+ const top = fitsAbove ? anchorRect.top - OFFSET_PX - height : anchorRect.bottom + OFFSET_PX
119
+ setPosition({ left, top })
120
+ }, [anchorRef])
121
+
122
+ // Close on outside click / Escape — same shape as ImageToneMenu.tsx's,
123
+ // extended to exclude the portaled popover itself (which isn't a DOM
124
+ // descendant of the trigger once portaled to document.body).
125
+ useEffect(() => {
126
+ const onDown = (e: MouseEvent) => {
127
+ const target = e.target as Node
128
+ if (anchorRef.current?.contains(target)) return
129
+ if (popoverRef.current?.contains(target)) return
130
+ onClose()
131
+ }
132
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
133
+ document.addEventListener('mousedown', onDown)
134
+ document.addEventListener('keydown', onKey)
135
+ return () => {
136
+ document.removeEventListener('mousedown', onDown)
137
+ document.removeEventListener('keydown', onKey)
138
+ }
139
+ }, [anchorRef, onClose])
140
+
141
+ if (typeof document === 'undefined') return null
142
+
143
+ return createPortal(
144
+ <div
145
+ ref={popoverRef}
146
+ role="menu"
147
+ aria-label="Preview for social media"
148
+ className="fixed z-[100] w-64 rounded-xl border border-[var(--editor-border)] bg-[var(--editor-surface)] shadow-2xl p-2 flex flex-col gap-1"
149
+ style={{
150
+ left: position?.left ?? -9999,
151
+ top: position?.top ?? -9999,
152
+ // Invisible until positioned: the first render has nothing measured
153
+ // to position against yet, so it paints once off-screen for the
154
+ // effect above to measure, then becomes visible in its real place —
155
+ // never a visible jump.
156
+ visibility: position ? 'visible' : 'hidden',
157
+ }}
158
+ >
159
+ <p className="px-2 pt-1 pb-1.5">
160
+ <span className="block text-[11px] font-semibold text-[var(--editor-text)]/90">Preview for social media</span>
161
+ <span className="block text-[10px] text-[var(--editor-text)]/50">What you see may vary depending on your device.</span>
162
+ </p>
163
+
164
+ {PLATFORM_OPTIONS.map(opt => {
165
+ const active = value === opt.id
166
+ return (
167
+ <button
168
+ key={opt.id}
169
+ type="button"
170
+ title={opt.label}
171
+ aria-label={opt.label}
172
+ role="menuitemradio"
173
+ aria-checked={active}
174
+ onClick={() => { onChange(opt.id); onClose() }}
175
+ className={`flex items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left text-xs transition-colors ${
176
+ active
177
+ ? 'text-[var(--editor-text)] bg-sky-400/10'
178
+ : 'text-[var(--editor-text)]/80 hover:bg-[var(--editor-text)]/5'
179
+ }`}
180
+ >
181
+ <span className="flex items-center gap-2">
182
+ <PlatformGlyph icon={opt.icon} badgeClassName={opt.badgeClassName} />
183
+ <span>{opt.label}</span>
184
+ </span>
185
+ {active && <Check size={13} className={mode === 'light' ? 'text-sky-600' : 'text-sky-400'} />}
186
+ </button>
187
+ )
188
+ })}
189
+
190
+ <div className="my-1 h-px bg-[var(--editor-border)]" />
191
+
192
+ <button
193
+ type="button"
194
+ title="None"
195
+ aria-label="None"
196
+ role="menuitemradio"
197
+ aria-checked={value === null}
198
+ onClick={() => { onChange(null); onClose() }}
199
+ className={`flex items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left text-xs transition-colors ${
200
+ value === null
201
+ ? 'text-[var(--editor-text)] bg-sky-400/10'
202
+ : 'text-[var(--editor-text)]/80 hover:bg-[var(--editor-text)]/5'
203
+ }`}
204
+ >
205
+ <span className="flex items-center gap-2">
206
+ <PlatformGlyph icon={Slash} badgeClassName="bg-[var(--editor-text)]/15" />
207
+ <span>None</span>
208
+ </span>
209
+ {value === null && <Check size={13} className={mode === 'light' ? 'text-sky-600' : 'text-sky-400'} />}
210
+ </button>
211
+ </div>,
212
+ document.body,
213
+ )
214
+ }