@growgroup/visual-editor 0.1.0

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 (219) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +52 -0
  3. package/README.md +143 -0
  4. package/dist/editor.css +2 -0
  5. package/package.json +94 -0
  6. package/src/components/DeckSlideRender.tsx +21 -0
  7. package/src/components/SaveNote.tsx +21 -0
  8. package/src/components/auth/AuthProvider.tsx +44 -0
  9. package/src/components/ui/accordion.tsx +57 -0
  10. package/src/components/ui/alert-dialog.tsx +141 -0
  11. package/src/components/ui/badge.tsx +36 -0
  12. package/src/components/ui/button.tsx +56 -0
  13. package/src/components/ui/card.tsx +76 -0
  14. package/src/components/ui/checkbox.tsx +30 -0
  15. package/src/components/ui/collapsible.tsx +11 -0
  16. package/src/components/ui/context-menu.tsx +200 -0
  17. package/src/components/ui/dialog.tsx +122 -0
  18. package/src/components/ui/dropdown-menu.tsx +201 -0
  19. package/src/components/ui/input.tsx +22 -0
  20. package/src/components/ui/label.tsx +26 -0
  21. package/src/components/ui/popover.tsx +33 -0
  22. package/src/components/ui/scroll-area.tsx +48 -0
  23. package/src/components/ui/scrubbable-label.tsx +95 -0
  24. package/src/components/ui/select.tsx +159 -0
  25. package/src/components/ui/separator.tsx +31 -0
  26. package/src/components/ui/slider.tsx +28 -0
  27. package/src/components/ui/switch.tsx +29 -0
  28. package/src/components/ui/tabs.tsx +55 -0
  29. package/src/components/ui/textarea.tsx +22 -0
  30. package/src/components/ui/tooltip.tsx +32 -0
  31. package/src/components/viewer/useDeck.ts +58 -0
  32. package/src/editor/EditorContext.tsx +632 -0
  33. package/src/editor/EditorToolbar.tsx +446 -0
  34. package/src/editor/FrontendVisualEditor.tsx +2595 -0
  35. package/src/editor/autosave.ts +34 -0
  36. package/src/editor/components/AiPromptPopover.tsx +378 -0
  37. package/src/editor/components/BreakpointGuides.tsx +323 -0
  38. package/src/editor/components/BreakpointSelector.tsx +180 -0
  39. package/src/editor/components/ComponentPropertyEditor.tsx +426 -0
  40. package/src/editor/components/CssEditorDialog.tsx +168 -0
  41. package/src/editor/components/EditorCanvas.tsx +987 -0
  42. package/src/editor/components/EditorContextMenu.tsx +515 -0
  43. package/src/editor/components/EditorFooter.tsx +81 -0
  44. package/src/editor/components/EditorHeader.tsx +524 -0
  45. package/src/editor/components/EditorLayerPanel.tsx +1197 -0
  46. package/src/editor/components/EditorPropertyPanel.tsx +2996 -0
  47. package/src/editor/components/EditorSidebar.tsx +226 -0
  48. package/src/editor/components/FigmaColorPicker.tsx +1577 -0
  49. package/src/editor/components/HtmlEditorDialog.tsx +71 -0
  50. package/src/editor/components/HtmlImportDialog.tsx +346 -0
  51. package/src/editor/components/JsEditorDialog.tsx +175 -0
  52. package/src/editor/components/LayerTreeItem.tsx +494 -0
  53. package/src/editor/components/MasterComponentEditor.tsx +846 -0
  54. package/src/editor/components/MediaLibraryDialog.tsx +447 -0
  55. package/src/editor/components/PageSettingsDialog.tsx +776 -0
  56. package/src/editor/components/VariablePicker.tsx +355 -0
  57. package/src/editor/components/VariablesDialog.tsx +564 -0
  58. package/src/editor/components/VariantEditor.tsx +687 -0
  59. package/src/editor/components/VariantMatrix.tsx +507 -0
  60. package/src/editor/components/component-panel/CategoryAccordion.tsx +139 -0
  61. package/src/editor/components/component-panel/ComponentGrid.tsx +51 -0
  62. package/src/editor/components/component-panel/ComponentItem.tsx +368 -0
  63. package/src/editor/components/component-panel/ComponentPanel.tsx +275 -0
  64. package/src/editor/components/component-panel/ComponentSearch.tsx +82 -0
  65. package/src/editor/components/component-panel/VariantList.tsx +260 -0
  66. package/src/editor/components/component-panel/index.ts +12 -0
  67. package/src/editor/components/index.ts +48 -0
  68. package/src/editor/components/multi-page/CanvasZoomControls.tsx +71 -0
  69. package/src/editor/components/multi-page/InfiniteCanvas.tsx +95 -0
  70. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +1404 -0
  71. package/src/editor/components/multi-page/PageFrameOverlay.tsx +76 -0
  72. package/src/editor/components/multi-page/PageLabel.tsx +34 -0
  73. package/src/editor/components/multi-page/PageLivePreview.tsx +242 -0
  74. package/src/editor/components/multi-page/PageThumbnail.tsx +59 -0
  75. package/src/editor/components/multi-page/index.ts +7 -0
  76. package/src/editor/components/ppt/PptChrome.tsx +2508 -0
  77. package/src/editor/components/ppt/PptComments.tsx +589 -0
  78. package/src/editor/components/ppt/PptDesignProposals.tsx +275 -0
  79. package/src/editor/components/ppt/PptFormatPane.tsx +320 -0
  80. package/src/editor/components/ppt/PptNotes.tsx +246 -0
  81. package/src/editor/components/property-panel/AlignmentPanel.tsx +110 -0
  82. package/src/editor/components/property-panel/AutoLayoutPanel.tsx +615 -0
  83. package/src/editor/components/property-panel/BorderSection.tsx +104 -0
  84. package/src/editor/components/property-panel/CompactNumberInput.tsx +237 -0
  85. package/src/editor/components/property-panel/CompactSizeInput.tsx +148 -0
  86. package/src/editor/components/property-panel/EffectSection.tsx +157 -0
  87. package/src/editor/components/property-panel/FillSection.tsx +69 -0
  88. package/src/editor/components/property-panel/GoogleFontPicker.tsx +192 -0
  89. package/src/editor/components/property-panel/ImageSection.tsx +206 -0
  90. package/src/editor/components/property-panel/ImgSrcSection.tsx +125 -0
  91. package/src/editor/components/property-panel/InstanceOverrideSection.tsx +413 -0
  92. package/src/editor/components/property-panel/LayoutSection.tsx +105 -0
  93. package/src/editor/components/property-panel/LinkSection.tsx +103 -0
  94. package/src/editor/components/property-panel/PropertyVariablePicker.tsx +311 -0
  95. package/src/editor/components/property-panel/ScalePanel.tsx +212 -0
  96. package/src/editor/components/property-panel/TypographySection.tsx +230 -0
  97. package/src/editor/components/property-panel/UnitAwareNumberInput.tsx +238 -0
  98. package/src/editor/components/property-panel/VariableAwareColorInput.tsx +477 -0
  99. package/src/editor/components/property-panel/VariableAwareInput.tsx +606 -0
  100. package/src/editor/components/property-panel/VariableAwareSizeInput.tsx +588 -0
  101. package/src/editor/components/property-panel/VariableAwareUnitInput.tsx +546 -0
  102. package/src/editor/components/property-panel/index.ts +24 -0
  103. package/src/editor/components/property-panel/unit-utils.ts +261 -0
  104. package/src/editor/components/shell/EditorTopBar.tsx +392 -0
  105. package/src/editor/components/shell/LeftPanel.tsx +47 -0
  106. package/src/editor/components/shell/PagesPanel.tsx +118 -0
  107. package/src/editor/components/variables-panel/ColorValueCell.tsx +165 -0
  108. package/src/editor/components/variables-panel/TextValueCell.tsx +116 -0
  109. package/src/editor/components/variables-panel/VariablesPanel.tsx +518 -0
  110. package/src/editor/components/variables-panel/index.ts +7 -0
  111. package/src/editor/constants.ts +468 -0
  112. package/src/editor/contexts/EditorArtboardContext.tsx +229 -0
  113. package/src/editor/contexts/EditorComponentsContext.tsx +1320 -0
  114. package/src/editor/contexts/EditorDocumentContext.tsx +113 -0
  115. package/src/editor/contexts/EditorHistoryContext.tsx +516 -0
  116. package/src/editor/contexts/EditorRefsContext.tsx +204 -0
  117. package/src/editor/contexts/EditorSelectionContext.tsx +54 -0
  118. package/src/editor/contexts/EditorToolContext.tsx +58 -0
  119. package/src/editor/contexts/EditorUIStateContext.tsx +122 -0
  120. package/src/editor/contexts/EditorVariablesContext.tsx +397 -0
  121. package/src/editor/contexts/EditorViewContext.tsx +101 -0
  122. package/src/editor/contexts/MultiPageCanvasContext.tsx +498 -0
  123. package/src/editor/contexts/index.ts +17 -0
  124. package/src/editor/editor-skin.css +122 -0
  125. package/src/editor/hooks/index.ts +38 -0
  126. package/src/editor/hooks/useAiReplace.ts +397 -0
  127. package/src/editor/hooks/useAltMeasure.ts +67 -0
  128. package/src/editor/hooks/useBrowserZoomPrevention.ts +121 -0
  129. package/src/editor/hooks/useCanvasControls.ts +1222 -0
  130. package/src/editor/hooks/useComponentEditMode.ts +658 -0
  131. package/src/editor/hooks/useComponentInstances.ts +515 -0
  132. package/src/editor/hooks/useContextMenuHandler.ts +157 -0
  133. package/src/editor/hooks/useCoordinateTransform.ts +207 -0
  134. package/src/editor/hooks/useDragResize.ts +1317 -0
  135. package/src/editor/hooks/useDrawingMode.ts +472 -0
  136. package/src/editor/hooks/useEditorColors.ts +144 -0
  137. package/src/editor/hooks/useEditorMessages.ts +108 -0
  138. package/src/editor/hooks/useElementActions.ts +1754 -0
  139. package/src/editor/hooks/useElementSelection.ts +1573 -0
  140. package/src/editor/hooks/useFocusManagement.ts +249 -0
  141. package/src/editor/hooks/useGoogleFonts.ts +305 -0
  142. package/src/editor/hooks/useIframeInitializer.ts +584 -0
  143. package/src/editor/hooks/useIframeSetup.ts +360 -0
  144. package/src/editor/hooks/useImageUpload.ts +270 -0
  145. package/src/editor/hooks/useInfiniteCanvas.ts +301 -0
  146. package/src/editor/hooks/useKeyboardShortcuts.ts +205 -0
  147. package/src/editor/hooks/useMarqueeSelection.ts +388 -0
  148. package/src/editor/hooks/useMediaLibrary.ts +129 -0
  149. package/src/editor/hooks/usePageSettingsManager.ts +739 -0
  150. package/src/editor/hooks/useResizablePanel.ts +163 -0
  151. package/src/editor/hooks/useRichPaste.ts +255 -0
  152. package/src/editor/hooks/useTouchGestures.ts +197 -0
  153. package/src/editor/index.ts +27 -0
  154. package/src/editor/types/page-settings.ts +513 -0
  155. package/src/editor/types.ts +382 -0
  156. package/src/editor/utils/align-elements.ts +169 -0
  157. package/src/editor/utils/api-json.ts +21 -0
  158. package/src/editor/utils/aspect-lock.ts +50 -0
  159. package/src/editor/utils/component-renderer.ts +702 -0
  160. package/src/editor/utils/component-sync.ts +845 -0
  161. package/src/editor/utils/crop-mode.ts +265 -0
  162. package/src/editor/utils/dom-utils.ts +2238 -0
  163. package/src/editor/utils/element-effects.ts +218 -0
  164. package/src/editor/utils/eyedropper.ts +35 -0
  165. package/src/editor/utils/figma-export.ts +560 -0
  166. package/src/editor/utils/figma-kiwi-decoder.ts +722 -0
  167. package/src/editor/utils/figma-kiwi-encoder.ts +583 -0
  168. package/src/editor/utils/figma-paste.ts +2262 -0
  169. package/src/editor/utils/flex-reorder.ts +231 -0
  170. package/src/editor/utils/flex-utils.ts +1230 -0
  171. package/src/editor/utils/geometry.ts +232 -0
  172. package/src/editor/utils/grid-layout.ts +210 -0
  173. package/src/editor/utils/html-utils.ts +470 -0
  174. package/src/editor/utils/index.ts +11 -0
  175. package/src/editor/utils/ink-style.ts +57 -0
  176. package/src/editor/utils/inline-format.ts +236 -0
  177. package/src/editor/utils/measure-distance.ts +120 -0
  178. package/src/editor/utils/override-apply.ts +526 -0
  179. package/src/editor/utils/override-detection.ts +613 -0
  180. package/src/editor/utils/paste-processors.ts +609 -0
  181. package/src/editor/utils/paste-sanitizer.ts +244 -0
  182. package/src/editor/utils/restore-flow.ts +188 -0
  183. package/src/editor/utils/shape-library.ts +171 -0
  184. package/src/editor/utils/slide-root.ts +29 -0
  185. package/src/editor/utils/smart-guides.ts +218 -0
  186. package/src/editor/utils/style-utils.ts +685 -0
  187. package/src/editor/utils/table-edit.ts +151 -0
  188. package/src/editor/utils/tailwind-mappings.ts +778 -0
  189. package/src/editor/utils/tailwind-utils.ts +804 -0
  190. package/src/editor/utils/text-highlight.ts +216 -0
  191. package/src/editor/utils/theme-to-variables.ts +341 -0
  192. package/src/editor/utils/variant-resolver.ts +456 -0
  193. package/src/editor/utils/viewport-utils.ts +295 -0
  194. package/src/hooks/useEditorHistory.ts +195 -0
  195. package/src/hooks/useEditorShortcuts.ts +260 -0
  196. package/src/index.ts +40 -0
  197. package/src/io.ts +134 -0
  198. package/src/lib/agent/slide-agent/types.ts +547 -0
  199. package/src/lib/agent/website-agent/types.ts +805 -0
  200. package/src/lib/api/auth-fetch.ts +58 -0
  201. package/src/lib/deck.ts +69 -0
  202. package/src/lib/export.ts +47 -0
  203. package/src/lib/firebase/config.ts +29 -0
  204. package/src/lib/firebase/css-variables.ts +378 -0
  205. package/src/lib/firebase/editor-components.ts +677 -0
  206. package/src/lib/firebase/storage.ts +469 -0
  207. package/src/lib/utils.ts +6 -0
  208. package/src/styles/editor.css +52 -0
  209. package/src/styles/skin.css +122 -0
  210. package/src/types/css-variables.ts +645 -0
  211. package/src/types/editor-components.ts +944 -0
  212. package/src/types/editor.ts +420 -0
  213. package/src/types/page-master.ts +197 -0
  214. package/src/types/page.ts +419 -0
  215. package/src/types/slide.ts +402 -0
  216. package/src/types/website-theme.ts +788 -0
  217. package/src/types/website.ts +374 -0
  218. package/src/vendor/firebase-firestore.ts +13 -0
  219. package/src/vendor/firebase-functions.ts +17 -0
@@ -0,0 +1,113 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * EditorDocumentContext
5
+ *
6
+ * エディタのドキュメント/HTML状態を管理するContext
7
+ * 変更頻度: 中(編集操作時)
8
+ *
9
+ * 含まれる状態:
10
+ * - originalHtml: 初期HTML(変更検知用)
11
+ * - hasChanges: 変更があるかどうか
12
+ * - saving: 保存中フラグ
13
+ * - layoutMode: レイアウトモード(absolute/auto)
14
+ * - showLayoutHint: レイアウトヒント表示
15
+ * - autoLayoutHtml: オートレイアウトHTML
16
+ */
17
+
18
+ import React, { createContext, useContext, useState, useEffect } from 'react';
19
+
20
+ export interface EditorDocumentContextValue {
21
+ // 基本状態
22
+ originalHtml: string;
23
+ /**
24
+ * iframeを組み立てる「源」。ページ切替(initialHtmlプロップ)でのみ変わる。
25
+ * originalHtml は変更判定の基準として initLayout が上書きするため、
26
+ * これをiframe構築に使うと 変換→基準更新→再構築→変換… の無限ループになる
27
+ */
28
+ sourceHtml: string;
29
+ /**
30
+ * 変更判定の基準を差し替える。エディタは開いた直後に絶対位置変換を行うため、
31
+ * 変換後のHTMLを基準にし直さないと「開いただけで未保存の変更あり」になる
32
+ */
33
+ setOriginalHtml: (html: string) => void;
34
+ hasChanges: boolean;
35
+ saving: boolean;
36
+ setSaving: (saving: boolean) => void;
37
+
38
+ // レイアウトモード
39
+ layoutMode: 'absolute' | 'auto';
40
+ setLayoutMode: (mode: 'absolute' | 'auto') => void;
41
+ showLayoutHint: boolean;
42
+ setShowLayoutHint: (show: boolean) => void;
43
+ autoLayoutHtml: string | null;
44
+ setAutoLayoutHtml: (html: string | null) => void;
45
+ }
46
+
47
+ const EditorDocumentContext = createContext<EditorDocumentContextValue | null>(null);
48
+
49
+ export function useEditorDocument(): EditorDocumentContextValue {
50
+ const context = useContext(EditorDocumentContext);
51
+ if (!context) {
52
+ throw new Error('useEditorDocument must be used within EditorDocumentProvider');
53
+ }
54
+ return context;
55
+ }
56
+
57
+ interface EditorDocumentProviderProps {
58
+ initialHtml: string;
59
+ currentHtml: string; // EditorHistoryContextからのhtml
60
+ initialLayoutMode?: 'absolute' | 'auto';
61
+ children: React.ReactNode;
62
+ }
63
+
64
+ export function EditorDocumentProvider({
65
+ initialHtml,
66
+ currentHtml,
67
+ initialLayoutMode = 'auto',
68
+ children,
69
+ }: EditorDocumentProviderProps) {
70
+ // 基本状態
71
+ const [originalHtml, setOriginalHtml] = useState(initialHtml);
72
+ const [saving, setSaving] = useState(false);
73
+
74
+ // ページ切替時にoriginalHtmlを更新(hasChanges・resetが正しく動作するように)
75
+ useEffect(() => {
76
+ setOriginalHtml(initialHtml);
77
+ }, [initialHtml]);
78
+
79
+ // [モード廃止] レイアウトモードは常に 'absolute'。
80
+ // 「オートレイアウト/絶対配置」という二重モードはFigmaに無い概念で、
81
+ // 同じ操作(ドラッグ・矢印キー)がモードによって別の意味を持つ根本原因になっていた。
82
+ // 位置の扱いはモードではなく **要素自身の position** で判定する。
83
+ // in-flow の要素は最初に動かした時点でその要素だけを絶対配置へ変換する(遅延変換)。
84
+ const [layoutMode, setLayoutMode] = useState<'absolute' | 'auto'>('absolute');
85
+ const [showLayoutHint, setShowLayoutHint] = useState(false);
86
+ const [autoLayoutHtml, setAutoLayoutHtml] = useState<string | null>(null);
87
+
88
+ // [モード廃止] 親からの initialLayoutMode は無視する(常に absolute)
89
+
90
+ // 変更追跡
91
+ const hasChanges = currentHtml !== originalHtml;
92
+
93
+ const value: EditorDocumentContextValue = {
94
+ originalHtml,
95
+ sourceHtml: initialHtml,
96
+ setOriginalHtml,
97
+ hasChanges,
98
+ saving,
99
+ setSaving,
100
+ layoutMode,
101
+ setLayoutMode,
102
+ showLayoutHint,
103
+ setShowLayoutHint,
104
+ autoLayoutHtml,
105
+ setAutoLayoutHtml,
106
+ };
107
+
108
+ return (
109
+ <EditorDocumentContext.Provider value={value}>
110
+ {children}
111
+ </EditorDocumentContext.Provider>
112
+ );
113
+ }
@@ -0,0 +1,516 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * EditorHistoryContext
5
+ *
6
+ * エディタの履歴(Undo/Redo)を管理するContext。
7
+ *
8
+ * ここが担保する操作感(Figma基準):
9
+ * 1. 1回の操作(ドラッグ/リサイズ/キーリピート移動/連続入力)= 1回の取り消し
10
+ * → 途中経過や「見た目が何も変わらない取り消し」を履歴に溜めない
11
+ * 2. Cmd+Z で戻したとき、その操作の対象だった要素が選択された状態で復元される
12
+ *
13
+ * ■ なぜ履歴スタックを自前で持つのか
14
+ * 以前は useEditorHistory(hooks/useEditorHistory.ts) に委譲していたが、
15
+ * - past/present を state だけで持つため、同じtick内に2回 push されると
16
+ * 古い present を掴んで「同じ状態」を二重に積んでしまう(= 何も起きないUndoが挟まる)
17
+ * - 1操作を1エントリにまとめる(連続変更の吸収)ができない
18
+ * - どのエントリがどの要素を対象にした操作なのかを保持していないため選択復元ができない
19
+ * という3点が構造的に解決できない。よってスタックはこのファイルで保持し、
20
+ * 「現在値はrefが正、stateは描画用のミラー」という形にして同一tickの多重pushでも壊れないようにした。
21
+ *
22
+ * ■ 保存するHTMLの正規化について
23
+ * #artboard.innerHTML には選択枠(.selection-box)やホバー/選択の一時クラスがそのまま入る。
24
+ * これを素で履歴に積むと
25
+ * - 選択が変わっただけで別状態と見なされ、見た目が変わらないUndoステップが増える
26
+ * - Undoで復元したHTMLに古い選択枠が焼き付いたまま挿入され、Reactの選択状態と食い違う
27
+ * (実測: 2回操作して1回Undoすると、状態は未選択なのに .selection-box が紙面に残る)
28
+ * ため、push時にエディタUIの痕跡を落としてから積む。
29
+ */
30
+
31
+ import React, {
32
+ createContext,
33
+ useCallback,
34
+ useContext,
35
+ useEffect,
36
+ useMemo,
37
+ useRef,
38
+ useState,
39
+ } from 'react';
40
+ import { MAX_HISTORY_SIZE } from '../../types/editor';
41
+ import { useEditorRefs } from './EditorRefsContext';
42
+
43
+ export interface EditorHistoryContextValue {
44
+ // HTML状態(履歴と連動)
45
+ html: string;
46
+ setHtml: (html: string) => void;
47
+
48
+ // 履歴操作
49
+ pushHistory: (html: string, selectedId?: string | null) => void;
50
+ undo: () => void;
51
+ redo: () => void;
52
+ canUndo: boolean;
53
+ canRedo: boolean;
54
+ clearHistory: () => void;
55
+
56
+ // Undo/Redo実行検知用(他のContextから参照)
57
+ isUndoRedoRef: React.MutableRefObject<boolean>;
58
+ }
59
+
60
+ const EditorHistoryContext = createContext<EditorHistoryContextValue | null>(null);
61
+
62
+ export function useEditorHistory(): EditorHistoryContextValue {
63
+ const context = useContext(EditorHistoryContext);
64
+ if (!context) {
65
+ throw new Error('useEditorHistory must be used within EditorHistoryProvider');
66
+ }
67
+ return context;
68
+ }
69
+
70
+ // ============================================================
71
+ // 定数
72
+ // ============================================================
73
+
74
+ /**
75
+ * 連続変更をまとめる時間窓(ms)。
76
+ * この間に届いた「構造も対象も同じ変更」は直前のエントリを置き換える。
77
+ * - 矢印キーのキーリピート(約30ms間隔)・テキスト入力・プロパティ入力が1エントリにまとまる
78
+ * - 意図的に間を空けた操作(400ms超)は別エントリのまま残る
79
+ * ドラッグ/リサイズはmouseupで1回しかpushされないため、この窓に依存せず元から1エントリ。
80
+ */
81
+ const COALESCE_WINDOW_MS = 400;
82
+
83
+ /**
84
+ * 履歴に残してはいけないエディタUIのノード。
85
+ * (紙面の中身ではなく、エディタが一時的に差し込んでいる装飾)
86
+ */
87
+ const EDITOR_CHROME_SELECTOR = [
88
+ '.selection-box',
89
+ '.marquee-selection-box',
90
+ '.drag-ghost',
91
+ '.flex-drop-indicator',
92
+ '.nesting-drop-indicator',
93
+ '.auto-layout-drop-indicator',
94
+ ].join(',');
95
+
96
+ /**
97
+ * 履歴に残してはいけない一時クラス。
98
+ * 選択やホバーの状態はReact側が持つべきもので、HTMLに焼き付けない。
99
+ * (同時進行で選択まわりを直しているため、確実に一時的だと分かるものだけに絞る)
100
+ */
101
+ const TRANSIENT_CLASSES = ['selected', 'hover-preview', 'dragging', 'editing'];
102
+
103
+ /** Undo/Redo後に選択を復元するまでの再試行タイミング(ms) */
104
+ const SELECTION_RESTORE_DELAYS = [30, 90, 200, 360, 560];
105
+
106
+ // ============================================================
107
+ // 履歴エントリ
108
+ // ============================================================
109
+
110
+ interface HistoryEntry {
111
+ /** 正規化済みHTML(エディタUIの痕跡を除いたもの) */
112
+ html: string;
113
+ /** この状態を作った操作の対象要素(Undo時にこれを選び直す) */
114
+ selectionIds: string[];
115
+ /** 構造シグネチャ。要素の増減・並び替えを検出して連続吸収を打ち切るために使う */
116
+ signature: string;
117
+ /** 直前に積まれた時刻 */
118
+ timestamp: number;
119
+ /** 次の変更をこのエントリに吸収してよいか(初期状態・Undo直後は不可) */
120
+ coalescable: boolean;
121
+ }
122
+
123
+ interface HistoryStacks {
124
+ past: HistoryEntry[];
125
+ present: HistoryEntry;
126
+ future: HistoryEntry[];
127
+ }
128
+
129
+ // ============================================================
130
+ // HTMLの正規化
131
+ // ============================================================
132
+
133
+ /**
134
+ * エディタUIの痕跡を落としたHTMLと、構造シグネチャを作る。
135
+ * DOMParserではなく <template> を使うのは、内容がinertで画像読み込み等の副作用が無いため。
136
+ */
137
+ function normalizeSnapshot(rawHtml: string): { html: string; signature: string } {
138
+ if (typeof document === 'undefined') {
139
+ return { html: rawHtml, signature: rawHtml.length.toString() };
140
+ }
141
+
142
+ try {
143
+ const template = document.createElement('template');
144
+ template.innerHTML = rawHtml;
145
+
146
+ // 1) エディタが差し込んだ装飾ノードを取り除く
147
+ template.content.querySelectorAll(EDITOR_CHROME_SELECTOR).forEach((node) => {
148
+ node.remove();
149
+ });
150
+
151
+ // 2) 一時クラスを落とす
152
+ TRANSIENT_CLASSES.forEach((cls) => {
153
+ template.content.querySelectorAll(`.${cls}`).forEach((node) => {
154
+ node.classList.remove(cls);
155
+ });
156
+ });
157
+ // 空になったclass属性は落とす。
158
+ // 「元から空」と「剥がして空になった」が同じ表記になるようにして、
159
+ // 見た目が同じなのに別状態と判定される(=何も起きないUndoが増える)のを防ぐ。
160
+ template.content.querySelectorAll('[class=""]').forEach((node) => {
161
+ node.removeAttribute('class');
162
+ });
163
+
164
+ // 3) 構造シグネチャ(要素idの並び)。装飾ノード除去後に取るので、
165
+ // ハンドル等に付いているidは混ざらない。
166
+ const ids: string[] = [];
167
+ template.content.querySelectorAll('[data-element-id]').forEach((node) => {
168
+ ids.push(node.getAttribute('data-element-id') || '');
169
+ });
170
+
171
+ return { html: template.innerHTML, signature: ids.join(',') };
172
+ } catch {
173
+ // パースに失敗しても履歴自体は止めない
174
+ return { html: rawHtml, signature: rawHtml.length.toString() };
175
+ }
176
+ }
177
+
178
+ function sameSelection(a: string[], b: string[]): boolean {
179
+ if (a.length !== b.length) return false;
180
+ return a.every((id, i) => id === b[i]);
181
+ }
182
+
183
+ /** 指定idが復元先のHTMLに実在するか */
184
+ function existsInHtml(html: string, id: string): boolean {
185
+ return html.includes(`data-element-id="${id}"`);
186
+ }
187
+
188
+ // ============================================================
189
+ // Provider
190
+ // ============================================================
191
+
192
+ interface EditorHistoryProviderProps {
193
+ initialHtml: string;
194
+ children: React.ReactNode;
195
+ }
196
+
197
+ export function EditorHistoryProvider({
198
+ initialHtml,
199
+ children,
200
+ }: EditorHistoryProviderProps) {
201
+ // Undo/Redo実行中フラグ(iframe同期側が参照する)
202
+ const isUndoRedoRef = useRef<boolean>(false);
203
+
204
+ // EditorRefsProviderはこのProviderの外側にあるので参照できる。
205
+ // - getIframeDoc: 選択を戻す対象のiframeを取り違えないため
206
+ // - iframeHtmlRef: Undo/Redoの反映漏れを防ぐため(詳細は applyEntry のコメント)
207
+ const { getIframeDoc, iframeHtmlRef } = useEditorRefs();
208
+
209
+ const createInitialStacks = (): HistoryStacks => ({
210
+ past: [],
211
+ // 初期状態は coalescable: false。
212
+ // ここを吸収可能にすると最初の1操作が初期状態を上書きして戻せなくなる。
213
+ present: {
214
+ html: initialHtml,
215
+ selectionIds: [],
216
+ signature: '',
217
+ timestamp: 0,
218
+ coalescable: false,
219
+ },
220
+ future: [],
221
+ });
222
+
223
+ const [stacks, setStacks] = useState<HistoryStacks>(createInitialStacks);
224
+
225
+ // 現在値の正はref側。stateは描画用のミラー。
226
+ // こうしないと、同じtickに複数回pushが来たとき古いpresentを二重に積んでしまう
227
+ // (=何も起きないUndoが1回挟まる)。
228
+ const stacksRef = useRef<HistoryStacks>(stacks);
229
+
230
+ const commit = useCallback((next: HistoryStacks) => {
231
+ stacksRef.current = next;
232
+ setStacks(next);
233
+ }, []);
234
+
235
+ // 最後に分かっている選択(削除など、push時点では選択が消えている操作の保険)
236
+ const lastSelectionRef = useRef<string[]>([]);
237
+ // 選択復元の世代。新しい操作が入ったら古い復元処理は諦める
238
+ const restoreTokenRef = useRef<number>(0);
239
+
240
+ // --- 選択の解決 ---------------------------------------------------------
241
+ // 呼び出し側が渡す selectedId が最も確度が高い(削除時も、React stateの更新前に
242
+ // 呼ばれるため削除対象のidが入っている)。無い場合のみ直近の選択にフォールバックする。
243
+ const resolveSelectionIds = useCallback((selectedId?: string | null): string[] => {
244
+ if (selectedId) {
245
+ lastSelectionRef.current = [selectedId];
246
+ return [selectedId];
247
+ }
248
+ return lastSelectionRef.current;
249
+ }, []);
250
+
251
+ // --- 選択復元 -----------------------------------------------------------
252
+ /**
253
+ * Undo/Redo後に対象要素を選び直す。
254
+ *
255
+ * 復元後は EditorContext 側の同期処理が選択を明示的にクリアするため、
256
+ * こちらは「クリアされた後」に選び直す必要がある。タイミングを決め打ちできないので
257
+ * 数回に分けて試し、成功したら止める。
258
+ * 選択のセットには既存の 'breadcrumb-select' メッセージ経路を使う
259
+ * (クラス付与・選択枠・React stateの更新が1か所で揃うため)。
260
+ *
261
+ * [制限] この経路は setSelectedElementIds([id]) と単一選択しか組み立てられないため、
262
+ * 複数要素をまとめて動かした操作のUndoでは代表1件だけが選択された状態に戻る。
263
+ * 複数件を戻すには受け側(useEditorMessages)に複数選択用のメッセージが必要で、
264
+ * それは今回の担当ファイル外なのでここでは踏み込まない。
265
+ * 半端に .selected だけ付けるとReact側の選択状態と食い違うため、DOM直接操作はしない。
266
+ */
267
+ const scheduleSelectionRestore = useCallback((ids: string[], targetHtml: string) => {
268
+ const id = ids.find((candidate) => candidate && existsInHtml(targetHtml, candidate));
269
+ if (!id) return;
270
+
271
+ const token = ++restoreTokenRef.current;
272
+
273
+ const findDoc = (): Document | null => {
274
+ // 編集中のiframeが正。対象要素がまだ入っていない間は「復元待ち」として扱う
275
+ const doc = getIframeDoc();
276
+ if (doc && doc.querySelector(`[data-element-id="${id}"]`)) return doc;
277
+ return null;
278
+ };
279
+
280
+ // 1回でも選択を投げたか。
281
+ // 投げる前のDOMは「まだ差し替わっていない直前の状態」の可能性があり、
282
+ // そこには操作中の .selected が残っている。これを「復元済み」「ユーザーが選び直した」と
283
+ // 誤読して打ち切ると、差し替え後に選択が消えたまま二度と戻らなくなる。
284
+ // よって打ち切り判定は「1回投げた後」だけ効かせる。
285
+ let posted = false;
286
+
287
+ const attempt = () => {
288
+ if (restoreTokenRef.current !== token) return; // 新しい操作が入った
289
+ const doc = findDoc();
290
+ if (!doc) return; // まだ差し替わっていない → 次の試行へ
291
+
292
+ const target = doc.querySelector(`[data-element-id="${id}"]`) as HTMLElement | null;
293
+ if (!target) return;
294
+
295
+ if (posted && target.classList.contains('selected')) {
296
+ restoreTokenRef.current++; // 復元済み。以降の試行は打ち切る
297
+ return;
298
+ }
299
+
300
+ // ユーザーが別の要素を選び直していたら邪魔しない
301
+ const otherSelected = Array.from(doc.querySelectorAll('.selected')).some(
302
+ (el) => !el.closest('.selection-box') && el !== target
303
+ );
304
+ if (posted && otherSelected) {
305
+ restoreTokenRef.current++;
306
+ return;
307
+ }
308
+
309
+ window.postMessage({ type: 'breadcrumb-select', elementId: id }, '*');
310
+ posted = true;
311
+ };
312
+
313
+ SELECTION_RESTORE_DELAYS.forEach((delay) => setTimeout(attempt, delay));
314
+ }, [getIframeDoc]);
315
+
316
+ /**
317
+ * Undo/Redoで状態を適用する共通処理。
318
+ *
319
+ * iframeへの反映は他Context側のeffectが
320
+ * 「isUndoRedoRef が立っている」かつ「history.html !== iframeHtmlRef.current」
321
+ * のときだけ行う。iframeHtmlRefは変更経路によっては更新されない(notifyIframeChange経由の
322
+ * 削除・整列など)ため、"Undo → 別の編集 → Undo" で戻り先が前回と同じHTMLになると
323
+ * 一致してしまい、Cmd+Zが完全に無反応になる(旧実装でも同じ現象を実測)。
324
+ * ここで「いま画面に出ているのは離れる側の状態」と記録し直しておけば、
325
+ * 履歴は重複を積まないので必ず不一致になり、反映が飛ばされることがなくなる。
326
+ */
327
+ const applyEntry = useCallback(
328
+ (leaving: HistoryEntry, next: HistoryStacks) => {
329
+ isUndoRedoRef.current = true;
330
+ iframeHtmlRef.current = leaving.html;
331
+ commit(next);
332
+ },
333
+ [commit, iframeHtmlRef]
334
+ );
335
+
336
+ // --- 履歴追加 -----------------------------------------------------------
337
+ const pushHistory = useCallback(
338
+ (rawHtml: string, selectedId?: string | null) => {
339
+ const { html, signature } = normalizeSnapshot(rawHtml);
340
+ const current = stacksRef.current;
341
+
342
+ // 実質的な変化が無いものは積まない
343
+ // (選択枠や一時クラスの差はここまでで消えているので、選択操作では履歴が増えない)
344
+ if (html === current.present.html) return;
345
+
346
+ const selectionIds = resolveSelectionIds(selectedId);
347
+ const now = Date.now();
348
+
349
+ // 直前のエントリに吸収するか(= 1操作1エントリにまとめる)
350
+ const merge =
351
+ current.present.coalescable &&
352
+ now - current.present.timestamp <= COALESCE_WINDOW_MS &&
353
+ current.present.signature === signature && // 要素の増減・並び替えがあれば別操作
354
+ sameSelection(current.present.selectionIds, selectionIds); // 対象が違えば別操作
355
+
356
+ const nextEntry: HistoryEntry = {
357
+ html,
358
+ selectionIds,
359
+ signature,
360
+ timestamp: now,
361
+ coalescable: true,
362
+ };
363
+
364
+ if (merge) {
365
+ // pastには積まず、現在エントリを更新するだけ
366
+ commit({ past: current.past, present: nextEntry, future: [] });
367
+ restoreTokenRef.current++;
368
+ return;
369
+ }
370
+
371
+ const past = [...current.past, current.present];
372
+ commit({
373
+ past: past.length > MAX_HISTORY_SIZE ? past.slice(past.length - MAX_HISTORY_SIZE) : past,
374
+ present: nextEntry,
375
+ future: [], // 新しい操作でRedoは無効化
376
+ });
377
+ restoreTokenRef.current++;
378
+ },
379
+ [commit, resolveSelectionIds]
380
+ );
381
+
382
+ // --- 履歴に残さないHTML更新 --------------------------------------------
383
+ const setHtml = useCallback(
384
+ (rawHtml: string) => {
385
+ const { html, signature } = normalizeSnapshot(rawHtml);
386
+ const current = stacksRef.current;
387
+ if (html === current.present.html) return;
388
+
389
+ commit({
390
+ ...current,
391
+ present: {
392
+ ...current.present,
393
+ html,
394
+ signature,
395
+ // 履歴段を作らない更新なので、次のpushはここに吸収させず新しい段にする
396
+ coalescable: false,
397
+ timestamp: Date.now(),
398
+ },
399
+ });
400
+ },
401
+ [commit]
402
+ );
403
+
404
+ // --- Undo ---------------------------------------------------------------
405
+ const undo = useCallback(() => {
406
+ const current = stacksRef.current;
407
+ if (current.past.length === 0) return;
408
+
409
+ const previous = current.past[current.past.length - 1];
410
+ const leaving = current.present;
411
+
412
+ applyEntry(leaving, {
413
+ past: current.past.slice(0, -1),
414
+ // 戻した直後の状態には吸収させない(次の操作は必ず新しい段になる)
415
+ present: { ...previous, coalescable: false },
416
+ future: [leaving, ...current.future],
417
+ });
418
+
419
+ // 取り消した操作の対象要素を選び直す。
420
+ // 対象が消えている場合(挿入のUndoなど)は、戻した先が対象にしていた要素を試す。
421
+ scheduleSelectionRestore(
422
+ [...leaving.selectionIds, ...previous.selectionIds],
423
+ previous.html
424
+ );
425
+ }, [applyEntry, scheduleSelectionRestore]);
426
+
427
+ // --- Redo ---------------------------------------------------------------
428
+ const redo = useCallback(() => {
429
+ const current = stacksRef.current;
430
+ if (current.future.length === 0) return;
431
+
432
+ const next = current.future[0];
433
+
434
+ applyEntry(current.present, {
435
+ past: [...current.past, current.present],
436
+ present: { ...next, coalescable: false },
437
+ future: current.future.slice(1),
438
+ });
439
+
440
+ scheduleSelectionRestore(
441
+ [...next.selectionIds, ...current.present.selectionIds],
442
+ next.html
443
+ );
444
+ }, [applyEntry, scheduleSelectionRestore]);
445
+
446
+ // --- 履歴クリア ---------------------------------------------------------
447
+ const clearHistory = useCallback(() => {
448
+ const current = stacksRef.current;
449
+ commit({
450
+ past: [],
451
+ present: { ...current.present, coalescable: false },
452
+ future: [],
453
+ });
454
+ restoreTokenRef.current++;
455
+ }, [commit]);
456
+
457
+ // --- スライド切り替え時のリセット ---------------------------------------
458
+ // 履歴が空のまま初期HTMLだけ差し替わった場合(別スライドを開いた等)は現在値を追従させる。
459
+ // 旧実装(useEditorHistory)と同じ条件・同じ依存配列を維持している。
460
+ useEffect(() => {
461
+ const current = stacksRef.current;
462
+ if (
463
+ current.past.length === 0 &&
464
+ current.future.length === 0 &&
465
+ current.present.html !== initialHtml
466
+ ) {
467
+ commit({
468
+ past: [],
469
+ present: {
470
+ html: initialHtml,
471
+ selectionIds: [],
472
+ signature: '',
473
+ timestamp: 0,
474
+ coalescable: false,
475
+ },
476
+ future: [],
477
+ });
478
+ }
479
+ // eslint-disable-next-line react-hooks/exhaustive-deps
480
+ }, [initialHtml]);
481
+
482
+ // --- 開発時の確認用 -----------------------------------------------------
483
+ // 「1操作=1エントリ」を実機で数えるために、スタックの深さだけ公開する。
484
+ useEffect(() => {
485
+ // [パッケージ化での変更] バンドラ固有の env に依存しない
486
+ if (process.env.NODE_ENV !== 'development') return;
487
+ (window as unknown as Record<string, unknown>).__editorHistoryDebug = {
488
+ past: stacks.past.length,
489
+ future: stacks.future.length,
490
+ selectionIds: stacks.present.selectionIds,
491
+ timestamp: stacks.present.timestamp,
492
+ htmlLength: stacks.present.html.length,
493
+ };
494
+ }, [stacks]);
495
+
496
+ const value = useMemo<EditorHistoryContextValue>(
497
+ () => ({
498
+ html: stacks.present.html,
499
+ setHtml,
500
+ pushHistory,
501
+ undo,
502
+ redo,
503
+ canUndo: stacks.past.length > 0,
504
+ canRedo: stacks.future.length > 0,
505
+ clearHistory,
506
+ isUndoRedoRef,
507
+ }),
508
+ [stacks, setHtml, pushHistory, undo, redo, clearHistory]
509
+ );
510
+
511
+ return (
512
+ <EditorHistoryContext.Provider value={value}>
513
+ {children}
514
+ </EditorHistoryContext.Provider>
515
+ );
516
+ }