@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.
- package/LICENSE +21 -0
- package/NOTICE +52 -0
- package/README.md +143 -0
- package/dist/editor.css +2 -0
- package/package.json +94 -0
- package/src/components/DeckSlideRender.tsx +21 -0
- package/src/components/SaveNote.tsx +21 -0
- package/src/components/auth/AuthProvider.tsx +44 -0
- package/src/components/ui/accordion.tsx +57 -0
- package/src/components/ui/alert-dialog.tsx +141 -0
- package/src/components/ui/badge.tsx +36 -0
- package/src/components/ui/button.tsx +56 -0
- package/src/components/ui/card.tsx +76 -0
- package/src/components/ui/checkbox.tsx +30 -0
- package/src/components/ui/collapsible.tsx +11 -0
- package/src/components/ui/context-menu.tsx +200 -0
- package/src/components/ui/dialog.tsx +122 -0
- package/src/components/ui/dropdown-menu.tsx +201 -0
- package/src/components/ui/input.tsx +22 -0
- package/src/components/ui/label.tsx +26 -0
- package/src/components/ui/popover.tsx +33 -0
- package/src/components/ui/scroll-area.tsx +48 -0
- package/src/components/ui/scrubbable-label.tsx +95 -0
- package/src/components/ui/select.tsx +159 -0
- package/src/components/ui/separator.tsx +31 -0
- package/src/components/ui/slider.tsx +28 -0
- package/src/components/ui/switch.tsx +29 -0
- package/src/components/ui/tabs.tsx +55 -0
- package/src/components/ui/textarea.tsx +22 -0
- package/src/components/ui/tooltip.tsx +32 -0
- package/src/components/viewer/useDeck.ts +58 -0
- package/src/editor/EditorContext.tsx +632 -0
- package/src/editor/EditorToolbar.tsx +446 -0
- package/src/editor/FrontendVisualEditor.tsx +2595 -0
- package/src/editor/autosave.ts +34 -0
- package/src/editor/components/AiPromptPopover.tsx +378 -0
- package/src/editor/components/BreakpointGuides.tsx +323 -0
- package/src/editor/components/BreakpointSelector.tsx +180 -0
- package/src/editor/components/ComponentPropertyEditor.tsx +426 -0
- package/src/editor/components/CssEditorDialog.tsx +168 -0
- package/src/editor/components/EditorCanvas.tsx +987 -0
- package/src/editor/components/EditorContextMenu.tsx +515 -0
- package/src/editor/components/EditorFooter.tsx +81 -0
- package/src/editor/components/EditorHeader.tsx +524 -0
- package/src/editor/components/EditorLayerPanel.tsx +1197 -0
- package/src/editor/components/EditorPropertyPanel.tsx +2996 -0
- package/src/editor/components/EditorSidebar.tsx +226 -0
- package/src/editor/components/FigmaColorPicker.tsx +1577 -0
- package/src/editor/components/HtmlEditorDialog.tsx +71 -0
- package/src/editor/components/HtmlImportDialog.tsx +346 -0
- package/src/editor/components/JsEditorDialog.tsx +175 -0
- package/src/editor/components/LayerTreeItem.tsx +494 -0
- package/src/editor/components/MasterComponentEditor.tsx +846 -0
- package/src/editor/components/MediaLibraryDialog.tsx +447 -0
- package/src/editor/components/PageSettingsDialog.tsx +776 -0
- package/src/editor/components/VariablePicker.tsx +355 -0
- package/src/editor/components/VariablesDialog.tsx +564 -0
- package/src/editor/components/VariantEditor.tsx +687 -0
- package/src/editor/components/VariantMatrix.tsx +507 -0
- package/src/editor/components/component-panel/CategoryAccordion.tsx +139 -0
- package/src/editor/components/component-panel/ComponentGrid.tsx +51 -0
- package/src/editor/components/component-panel/ComponentItem.tsx +368 -0
- package/src/editor/components/component-panel/ComponentPanel.tsx +275 -0
- package/src/editor/components/component-panel/ComponentSearch.tsx +82 -0
- package/src/editor/components/component-panel/VariantList.tsx +260 -0
- package/src/editor/components/component-panel/index.ts +12 -0
- package/src/editor/components/index.ts +48 -0
- package/src/editor/components/multi-page/CanvasZoomControls.tsx +71 -0
- package/src/editor/components/multi-page/InfiniteCanvas.tsx +95 -0
- package/src/editor/components/multi-page/MultiPageCanvasView.tsx +1404 -0
- package/src/editor/components/multi-page/PageFrameOverlay.tsx +76 -0
- package/src/editor/components/multi-page/PageLabel.tsx +34 -0
- package/src/editor/components/multi-page/PageLivePreview.tsx +242 -0
- package/src/editor/components/multi-page/PageThumbnail.tsx +59 -0
- package/src/editor/components/multi-page/index.ts +7 -0
- package/src/editor/components/ppt/PptChrome.tsx +2508 -0
- package/src/editor/components/ppt/PptComments.tsx +589 -0
- package/src/editor/components/ppt/PptDesignProposals.tsx +275 -0
- package/src/editor/components/ppt/PptFormatPane.tsx +320 -0
- package/src/editor/components/ppt/PptNotes.tsx +246 -0
- package/src/editor/components/property-panel/AlignmentPanel.tsx +110 -0
- package/src/editor/components/property-panel/AutoLayoutPanel.tsx +615 -0
- package/src/editor/components/property-panel/BorderSection.tsx +104 -0
- package/src/editor/components/property-panel/CompactNumberInput.tsx +237 -0
- package/src/editor/components/property-panel/CompactSizeInput.tsx +148 -0
- package/src/editor/components/property-panel/EffectSection.tsx +157 -0
- package/src/editor/components/property-panel/FillSection.tsx +69 -0
- package/src/editor/components/property-panel/GoogleFontPicker.tsx +192 -0
- package/src/editor/components/property-panel/ImageSection.tsx +206 -0
- package/src/editor/components/property-panel/ImgSrcSection.tsx +125 -0
- package/src/editor/components/property-panel/InstanceOverrideSection.tsx +413 -0
- package/src/editor/components/property-panel/LayoutSection.tsx +105 -0
- package/src/editor/components/property-panel/LinkSection.tsx +103 -0
- package/src/editor/components/property-panel/PropertyVariablePicker.tsx +311 -0
- package/src/editor/components/property-panel/ScalePanel.tsx +212 -0
- package/src/editor/components/property-panel/TypographySection.tsx +230 -0
- package/src/editor/components/property-panel/UnitAwareNumberInput.tsx +238 -0
- package/src/editor/components/property-panel/VariableAwareColorInput.tsx +477 -0
- package/src/editor/components/property-panel/VariableAwareInput.tsx +606 -0
- package/src/editor/components/property-panel/VariableAwareSizeInput.tsx +588 -0
- package/src/editor/components/property-panel/VariableAwareUnitInput.tsx +546 -0
- package/src/editor/components/property-panel/index.ts +24 -0
- package/src/editor/components/property-panel/unit-utils.ts +261 -0
- package/src/editor/components/shell/EditorTopBar.tsx +392 -0
- package/src/editor/components/shell/LeftPanel.tsx +47 -0
- package/src/editor/components/shell/PagesPanel.tsx +118 -0
- package/src/editor/components/variables-panel/ColorValueCell.tsx +165 -0
- package/src/editor/components/variables-panel/TextValueCell.tsx +116 -0
- package/src/editor/components/variables-panel/VariablesPanel.tsx +518 -0
- package/src/editor/components/variables-panel/index.ts +7 -0
- package/src/editor/constants.ts +468 -0
- package/src/editor/contexts/EditorArtboardContext.tsx +229 -0
- package/src/editor/contexts/EditorComponentsContext.tsx +1320 -0
- package/src/editor/contexts/EditorDocumentContext.tsx +113 -0
- package/src/editor/contexts/EditorHistoryContext.tsx +516 -0
- package/src/editor/contexts/EditorRefsContext.tsx +204 -0
- package/src/editor/contexts/EditorSelectionContext.tsx +54 -0
- package/src/editor/contexts/EditorToolContext.tsx +58 -0
- package/src/editor/contexts/EditorUIStateContext.tsx +122 -0
- package/src/editor/contexts/EditorVariablesContext.tsx +397 -0
- package/src/editor/contexts/EditorViewContext.tsx +101 -0
- package/src/editor/contexts/MultiPageCanvasContext.tsx +498 -0
- package/src/editor/contexts/index.ts +17 -0
- package/src/editor/editor-skin.css +122 -0
- package/src/editor/hooks/index.ts +38 -0
- package/src/editor/hooks/useAiReplace.ts +397 -0
- package/src/editor/hooks/useAltMeasure.ts +67 -0
- package/src/editor/hooks/useBrowserZoomPrevention.ts +121 -0
- package/src/editor/hooks/useCanvasControls.ts +1222 -0
- package/src/editor/hooks/useComponentEditMode.ts +658 -0
- package/src/editor/hooks/useComponentInstances.ts +515 -0
- package/src/editor/hooks/useContextMenuHandler.ts +157 -0
- package/src/editor/hooks/useCoordinateTransform.ts +207 -0
- package/src/editor/hooks/useDragResize.ts +1317 -0
- package/src/editor/hooks/useDrawingMode.ts +472 -0
- package/src/editor/hooks/useEditorColors.ts +144 -0
- package/src/editor/hooks/useEditorMessages.ts +108 -0
- package/src/editor/hooks/useElementActions.ts +1754 -0
- package/src/editor/hooks/useElementSelection.ts +1573 -0
- package/src/editor/hooks/useFocusManagement.ts +249 -0
- package/src/editor/hooks/useGoogleFonts.ts +305 -0
- package/src/editor/hooks/useIframeInitializer.ts +584 -0
- package/src/editor/hooks/useIframeSetup.ts +360 -0
- package/src/editor/hooks/useImageUpload.ts +270 -0
- package/src/editor/hooks/useInfiniteCanvas.ts +301 -0
- package/src/editor/hooks/useKeyboardShortcuts.ts +205 -0
- package/src/editor/hooks/useMarqueeSelection.ts +388 -0
- package/src/editor/hooks/useMediaLibrary.ts +129 -0
- package/src/editor/hooks/usePageSettingsManager.ts +739 -0
- package/src/editor/hooks/useResizablePanel.ts +163 -0
- package/src/editor/hooks/useRichPaste.ts +255 -0
- package/src/editor/hooks/useTouchGestures.ts +197 -0
- package/src/editor/index.ts +27 -0
- package/src/editor/types/page-settings.ts +513 -0
- package/src/editor/types.ts +382 -0
- package/src/editor/utils/align-elements.ts +169 -0
- package/src/editor/utils/api-json.ts +21 -0
- package/src/editor/utils/aspect-lock.ts +50 -0
- package/src/editor/utils/component-renderer.ts +702 -0
- package/src/editor/utils/component-sync.ts +845 -0
- package/src/editor/utils/crop-mode.ts +265 -0
- package/src/editor/utils/dom-utils.ts +2238 -0
- package/src/editor/utils/element-effects.ts +218 -0
- package/src/editor/utils/eyedropper.ts +35 -0
- package/src/editor/utils/figma-export.ts +560 -0
- package/src/editor/utils/figma-kiwi-decoder.ts +722 -0
- package/src/editor/utils/figma-kiwi-encoder.ts +583 -0
- package/src/editor/utils/figma-paste.ts +2262 -0
- package/src/editor/utils/flex-reorder.ts +231 -0
- package/src/editor/utils/flex-utils.ts +1230 -0
- package/src/editor/utils/geometry.ts +232 -0
- package/src/editor/utils/grid-layout.ts +210 -0
- package/src/editor/utils/html-utils.ts +470 -0
- package/src/editor/utils/index.ts +11 -0
- package/src/editor/utils/ink-style.ts +57 -0
- package/src/editor/utils/inline-format.ts +236 -0
- package/src/editor/utils/measure-distance.ts +120 -0
- package/src/editor/utils/override-apply.ts +526 -0
- package/src/editor/utils/override-detection.ts +613 -0
- package/src/editor/utils/paste-processors.ts +609 -0
- package/src/editor/utils/paste-sanitizer.ts +244 -0
- package/src/editor/utils/restore-flow.ts +188 -0
- package/src/editor/utils/shape-library.ts +171 -0
- package/src/editor/utils/slide-root.ts +29 -0
- package/src/editor/utils/smart-guides.ts +218 -0
- package/src/editor/utils/style-utils.ts +685 -0
- package/src/editor/utils/table-edit.ts +151 -0
- package/src/editor/utils/tailwind-mappings.ts +778 -0
- package/src/editor/utils/tailwind-utils.ts +804 -0
- package/src/editor/utils/text-highlight.ts +216 -0
- package/src/editor/utils/theme-to-variables.ts +341 -0
- package/src/editor/utils/variant-resolver.ts +456 -0
- package/src/editor/utils/viewport-utils.ts +295 -0
- package/src/hooks/useEditorHistory.ts +195 -0
- package/src/hooks/useEditorShortcuts.ts +260 -0
- package/src/index.ts +40 -0
- package/src/io.ts +134 -0
- package/src/lib/agent/slide-agent/types.ts +547 -0
- package/src/lib/agent/website-agent/types.ts +805 -0
- package/src/lib/api/auth-fetch.ts +58 -0
- package/src/lib/deck.ts +69 -0
- package/src/lib/export.ts +47 -0
- package/src/lib/firebase/config.ts +29 -0
- package/src/lib/firebase/css-variables.ts +378 -0
- package/src/lib/firebase/editor-components.ts +677 -0
- package/src/lib/firebase/storage.ts +469 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles/editor.css +52 -0
- package/src/styles/skin.css +122 -0
- package/src/types/css-variables.ts +645 -0
- package/src/types/editor-components.ts +944 -0
- package/src/types/editor.ts +420 -0
- package/src/types/page-master.ts +197 -0
- package/src/types/page.ts +419 -0
- package/src/types/slide.ts +402 -0
- package/src/types/website-theme.ts +788 -0
- package/src/types/website.ts +374 -0
- package/src/vendor/firebase-firestore.ts +13 -0
- package/src/vendor/firebase-functions.ts +17 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iframe初期化処理フック
|
|
3
|
+
* - スタイル注入
|
|
4
|
+
* - data-editable属性の付与
|
|
5
|
+
* - インライン要素の検出
|
|
6
|
+
* - MutationObserverのセットアップ
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useCallback, useRef, useEffect } from 'react';
|
|
10
|
+
import { useEditorContext } from '../EditorContext';
|
|
11
|
+
import { buildDomTree, isInlineElement } from '../utils/dom-utils';
|
|
12
|
+
import { EDITOR_IFRAME_STYLES } from '../constants';
|
|
13
|
+
|
|
14
|
+
interface UseIframeSetupReturn {
|
|
15
|
+
/**
|
|
16
|
+
* iframe ドキュメントの初期化処理
|
|
17
|
+
* - スタイル注入
|
|
18
|
+
* - data-editable属性の付与
|
|
19
|
+
* - DOMツリーの構築
|
|
20
|
+
*/
|
|
21
|
+
initializeIframeDocument: (iframeDoc: Document) => void;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* MutationObserverをセットアップ
|
|
25
|
+
* @returns クリーンアップ関数
|
|
26
|
+
*/
|
|
27
|
+
setupMutationObserver: (iframeDoc: Document) => () => void;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* テキスト要素ホバーリスナーをセットアップ
|
|
31
|
+
*
|
|
32
|
+
* @param resolveHoverTarget
|
|
33
|
+
* 「いまクリックしたら選択されるであろう要素」を返す関数。
|
|
34
|
+
* 選択側(useElementSelection)と同じ判別器を渡すことで、
|
|
35
|
+
* ホバー輪郭が必ずクリック結果の予告になる。
|
|
36
|
+
* 省略可(未指定なら輪郭は出さず、従来のテキスト下線だけ動く)。
|
|
37
|
+
* @returns クリーンアップ関数
|
|
38
|
+
*/
|
|
39
|
+
setupTextHoverListener: (
|
|
40
|
+
iframeDoc: Document,
|
|
41
|
+
resolveHoverTarget?: HoverTargetResolver,
|
|
42
|
+
) => () => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** ホバー予告の対象を決める関数の型(選択側の判別器と同じもの) */
|
|
46
|
+
export type HoverTargetResolver = (
|
|
47
|
+
target: EventTarget | null,
|
|
48
|
+
iframeDoc: Document,
|
|
49
|
+
options: { meta: boolean },
|
|
50
|
+
) => HTMLElement | null;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* iframe初期化処理フック
|
|
54
|
+
*/
|
|
55
|
+
export function useIframeSetup(): UseIframeSetupReturn {
|
|
56
|
+
const { setDomTree } = useEditorContext();
|
|
57
|
+
|
|
58
|
+
// クロージャ問題回避のためのref
|
|
59
|
+
const setDomTreeRef = useRef(setDomTree);
|
|
60
|
+
|
|
61
|
+
// refを最新値に同期
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
setDomTreeRef.current = setDomTree;
|
|
64
|
+
}, [setDomTree]);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 全要素にdata-editable属性とdata-element-idを付与
|
|
68
|
+
*/
|
|
69
|
+
const initializeEditableElements = useCallback((iframeDoc: Document) => {
|
|
70
|
+
const artboard = iframeDoc.getElementById('artboard');
|
|
71
|
+
if (!artboard) {
|
|
72
|
+
console.warn('[IframeSetup] #artboard not found');
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let elementIndex = 0;
|
|
77
|
+
const elements = artboard.querySelectorAll('*');
|
|
78
|
+
|
|
79
|
+
elements.forEach((el) => {
|
|
80
|
+
const element = el as HTMLElement;
|
|
81
|
+
|
|
82
|
+
// スキップする要素
|
|
83
|
+
if (element === artboard) return;
|
|
84
|
+
if (element === iframeDoc.documentElement) return;
|
|
85
|
+
// [移植時の修正] スライドのキャンバス本体(#artboard 直下で紙面と同じ大きさの器)は
|
|
86
|
+
// 編集対象にしない。ここを掴めるとスライドごと動いてしまい、中身が版面の外
|
|
87
|
+
// (overflow:hidden)へ出るため、要素が消えたように見える
|
|
88
|
+
if (
|
|
89
|
+
element.parentElement === artboard &&
|
|
90
|
+
element.offsetWidth >= artboard.clientWidth - 2 &&
|
|
91
|
+
element.offsetHeight >= artboard.clientHeight - 2
|
|
92
|
+
) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (
|
|
96
|
+
element.tagName === 'SCRIPT' ||
|
|
97
|
+
element.tagName === 'STYLE' ||
|
|
98
|
+
element.tagName === 'HEAD'
|
|
99
|
+
)
|
|
100
|
+
return;
|
|
101
|
+
if (element.closest('script, style, svg path, svg g')) return;
|
|
102
|
+
if (element.classList.contains('material-icons')) return;
|
|
103
|
+
if (element.classList.contains('material-icons-outlined')) return;
|
|
104
|
+
// selection-box およびその子要素はスキップ
|
|
105
|
+
if (element.classList.contains('selection-box')) return;
|
|
106
|
+
if (element.classList.contains('resize-handle')) return;
|
|
107
|
+
if (element.classList.contains('selection-outline')) return;
|
|
108
|
+
if (element.classList.contains('size-label')) return;
|
|
109
|
+
if (element.classList.contains('border-radius-handle')) return;
|
|
110
|
+
if (element.classList.contains('rotation-handle')) return;
|
|
111
|
+
// selection-boxの子孫要素をすべてスキップ(念のため)
|
|
112
|
+
if (element.closest('.selection-box')) return;
|
|
113
|
+
|
|
114
|
+
// 既にIDがある場合はスキップ
|
|
115
|
+
if (!element.getAttribute('data-element-id')) {
|
|
116
|
+
const elementId = `el-${Date.now()}-${elementIndex++}`;
|
|
117
|
+
element.setAttribute('data-editable', 'true');
|
|
118
|
+
element.setAttribute('data-element-id', elementId);
|
|
119
|
+
|
|
120
|
+
// インライン要素にはフラグを付与(絶対配置に変換しない)
|
|
121
|
+
const computedStyle = iframeDoc.defaultView?.getComputedStyle(element);
|
|
122
|
+
if (computedStyle && isInlineElement(element, computedStyle)) {
|
|
123
|
+
element.setAttribute('data-inline', 'true');
|
|
124
|
+
}
|
|
125
|
+
} else if (!element.getAttribute('data-editable')) {
|
|
126
|
+
element.setAttribute('data-editable', 'true');
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return elementIndex;
|
|
131
|
+
}, []);
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* iframe ドキュメントの初期化
|
|
135
|
+
*/
|
|
136
|
+
const initializeIframeDocument = useCallback(
|
|
137
|
+
(iframeDoc: Document) => {
|
|
138
|
+
console.log('[IframeSetup] Initializing iframe document...');
|
|
139
|
+
|
|
140
|
+
// スタイルを注入
|
|
141
|
+
const style = iframeDoc.createElement('style');
|
|
142
|
+
style.textContent = EDITOR_IFRAME_STYLES;
|
|
143
|
+
iframeDoc.head.appendChild(style);
|
|
144
|
+
|
|
145
|
+
// タッチアクションを無効化
|
|
146
|
+
iframeDoc.documentElement.style.touchAction = 'none';
|
|
147
|
+
iframeDoc.body.style.touchAction = 'none';
|
|
148
|
+
|
|
149
|
+
// 全要素にdata-editableとdata-element-idを付与
|
|
150
|
+
const elementCount = initializeEditableElements(iframeDoc);
|
|
151
|
+
console.log('[IframeSetup] Initialized', elementCount, 'editable elements');
|
|
152
|
+
|
|
153
|
+
// DOMツリーを構築
|
|
154
|
+
const newTree = buildDomTree(iframeDoc);
|
|
155
|
+
setDomTreeRef.current(newTree);
|
|
156
|
+
},
|
|
157
|
+
[initializeEditableElements]
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* MutationObserverをセットアップ
|
|
162
|
+
*/
|
|
163
|
+
const setupMutationObserver = useCallback(
|
|
164
|
+
(iframeDoc: Document) => {
|
|
165
|
+
const artboard = iframeDoc.getElementById('artboard');
|
|
166
|
+
if (!artboard) {
|
|
167
|
+
console.warn('[IframeSetup] #artboard not found for MutationObserver');
|
|
168
|
+
return () => {};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let mutationTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
172
|
+
|
|
173
|
+
const observer = new MutationObserver(() => {
|
|
174
|
+
// デバウンス: 連続した変更を一度にまとめる
|
|
175
|
+
if (mutationTimeout) {
|
|
176
|
+
clearTimeout(mutationTimeout);
|
|
177
|
+
}
|
|
178
|
+
mutationTimeout = setTimeout(() => {
|
|
179
|
+
// 新しい要素にdata-editableを付与
|
|
180
|
+
initializeEditableElements(iframeDoc);
|
|
181
|
+
// DOMツリーを再構築
|
|
182
|
+
const newTree = buildDomTree(iframeDoc);
|
|
183
|
+
setDomTreeRef.current(newTree);
|
|
184
|
+
}, 100);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
observer.observe(artboard, {
|
|
188
|
+
childList: true,
|
|
189
|
+
subtree: true,
|
|
190
|
+
attributes: false,
|
|
191
|
+
characterData: false,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// クリーンアップ関数
|
|
195
|
+
return () => {
|
|
196
|
+
if (mutationTimeout) {
|
|
197
|
+
clearTimeout(mutationTimeout);
|
|
198
|
+
}
|
|
199
|
+
observer.disconnect();
|
|
200
|
+
};
|
|
201
|
+
},
|
|
202
|
+
[initializeEditableElements]
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 要素が直下にテキストを含むかチェック
|
|
207
|
+
*/
|
|
208
|
+
const hasDirectTextContent = useCallback((element: HTMLElement): boolean => {
|
|
209
|
+
// 画像やSVGは除外
|
|
210
|
+
const tagName = element.tagName.toUpperCase();
|
|
211
|
+
if (['IMG', 'SVG', 'VIDEO', 'IFRAME', 'CANVAS'].includes(tagName)) return false;
|
|
212
|
+
|
|
213
|
+
// 直接テキストノードを含むかチェック
|
|
214
|
+
for (const child of element.childNodes) {
|
|
215
|
+
if (child.nodeType === Node.TEXT_NODE && child.textContent?.trim()) {
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// テキスト系タグで、テキストコンテンツがある場合
|
|
221
|
+
const textTags = ['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'SPAN', 'A', 'LABEL', 'LI', 'TD', 'TH', 'BUTTON'];
|
|
222
|
+
if (textTags.includes(tagName) && element.textContent?.trim()) {
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return false;
|
|
227
|
+
}, []);
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* テキスト要素ホバーリスナーをセットアップ
|
|
231
|
+
*/
|
|
232
|
+
const setupTextHoverListener = useCallback(
|
|
233
|
+
(iframeDoc: Document, resolveHoverTarget?: HoverTargetResolver) => {
|
|
234
|
+
let currentHoveredTextElement: HTMLElement | null = null;
|
|
235
|
+
// [選択セマンティクスの一本化] クリック結果を予告する輪郭は1要素だけに付ける。
|
|
236
|
+
// CSS の :hover は祖先チェーン全体に当たってしまうため、ここで付け替える。
|
|
237
|
+
let currentPreviewElement: HTMLElement | null = null;
|
|
238
|
+
|
|
239
|
+
const clearPreview = () => {
|
|
240
|
+
if (currentPreviewElement) {
|
|
241
|
+
currentPreviewElement.classList.remove('hover-preview');
|
|
242
|
+
currentPreviewElement = null;
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/** 予告輪郭を1要素だけに付け替える */
|
|
247
|
+
const updatePreview = (e: MouseEvent) => {
|
|
248
|
+
if (!resolveHoverTarget) return;
|
|
249
|
+
// ドラッグ/マーキー中は予告を出さない(掴んでいる最中に輪郭が踊るのを防ぐ)
|
|
250
|
+
if (
|
|
251
|
+
iframeDoc.body.classList.contains('marquee-active') ||
|
|
252
|
+
iframeDoc.querySelector('.dragging')
|
|
253
|
+
) {
|
|
254
|
+
clearPreview();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const next = resolveHoverTarget(e.target, iframeDoc, {
|
|
258
|
+
meta: e.metaKey || e.ctrlKey,
|
|
259
|
+
});
|
|
260
|
+
if (next === currentPreviewElement) return;
|
|
261
|
+
clearPreview();
|
|
262
|
+
if (next) {
|
|
263
|
+
next.classList.add('hover-preview');
|
|
264
|
+
currentPreviewElement = next;
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
269
|
+
// 描画モードやテキストモードでは無効
|
|
270
|
+
const bodyClasses = iframeDoc.body.classList;
|
|
271
|
+
if (bodyClasses.contains('draw-mode') || bodyClasses.contains('text-mode') || bodyClasses.contains('move-mode')) {
|
|
272
|
+
if (currentHoveredTextElement) {
|
|
273
|
+
currentHoveredTextElement.classList.remove('text-editable-hover');
|
|
274
|
+
currentHoveredTextElement = null;
|
|
275
|
+
}
|
|
276
|
+
clearPreview();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
updatePreview(e);
|
|
281
|
+
|
|
282
|
+
// マウス下の要素を取得
|
|
283
|
+
const target = e.target as HTMLElement;
|
|
284
|
+
|
|
285
|
+
// 編集可能要素を探す
|
|
286
|
+
let editableElement: HTMLElement | null = target;
|
|
287
|
+
while (editableElement && editableElement !== iframeDoc.body) {
|
|
288
|
+
if (editableElement.getAttribute('data-editable') === 'true') {
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
editableElement = editableElement.parentElement;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (!editableElement || editableElement === iframeDoc.body) {
|
|
295
|
+
// 編集可能要素がない場合、既存のホバーをクリア
|
|
296
|
+
if (currentHoveredTextElement) {
|
|
297
|
+
currentHoveredTextElement.classList.remove('text-editable-hover');
|
|
298
|
+
currentHoveredTextElement = null;
|
|
299
|
+
}
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// テキスト編集可能かチェック
|
|
304
|
+
const isTextElement = hasDirectTextContent(editableElement);
|
|
305
|
+
|
|
306
|
+
if (isTextElement && editableElement !== currentHoveredTextElement) {
|
|
307
|
+
// 前のホバーをクリア
|
|
308
|
+
if (currentHoveredTextElement) {
|
|
309
|
+
currentHoveredTextElement.classList.remove('text-editable-hover');
|
|
310
|
+
}
|
|
311
|
+
// 新しいホバーを設定
|
|
312
|
+
editableElement.classList.add('text-editable-hover');
|
|
313
|
+
currentHoveredTextElement = editableElement;
|
|
314
|
+
} else if (!isTextElement && currentHoveredTextElement) {
|
|
315
|
+
// テキスト要素でない場合、ホバーをクリア
|
|
316
|
+
currentHoveredTextElement.classList.remove('text-editable-hover');
|
|
317
|
+
currentHoveredTextElement = null;
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const handleMouseLeave = () => {
|
|
322
|
+
if (currentHoveredTextElement) {
|
|
323
|
+
currentHoveredTextElement.classList.remove('text-editable-hover');
|
|
324
|
+
currentHoveredTextElement = null;
|
|
325
|
+
}
|
|
326
|
+
clearPreview();
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
// Cmd/Ctrl の押し下げ・解放でも予告先が変わる(Cmdは最深要素を選ぶため)
|
|
330
|
+
const handleModifierChange = (e: KeyboardEvent) => {
|
|
331
|
+
if (e.key !== 'Meta' && e.key !== 'Control') return;
|
|
332
|
+
clearPreview();
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
iframeDoc.addEventListener('mousemove', handleMouseMove);
|
|
336
|
+
iframeDoc.addEventListener('mouseleave', handleMouseLeave);
|
|
337
|
+
iframeDoc.addEventListener('keydown', handleModifierChange);
|
|
338
|
+
iframeDoc.addEventListener('keyup', handleModifierChange);
|
|
339
|
+
|
|
340
|
+
// クリーンアップ関数
|
|
341
|
+
return () => {
|
|
342
|
+
iframeDoc.removeEventListener('mousemove', handleMouseMove);
|
|
343
|
+
iframeDoc.removeEventListener('mouseleave', handleMouseLeave);
|
|
344
|
+
iframeDoc.removeEventListener('keydown', handleModifierChange);
|
|
345
|
+
iframeDoc.removeEventListener('keyup', handleModifierChange);
|
|
346
|
+
if (currentHoveredTextElement) {
|
|
347
|
+
currentHoveredTextElement.classList.remove('text-editable-hover');
|
|
348
|
+
}
|
|
349
|
+
clearPreview();
|
|
350
|
+
};
|
|
351
|
+
},
|
|
352
|
+
[hasDirectTextContent]
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
initializeIframeDocument,
|
|
357
|
+
setupMutationObserver,
|
|
358
|
+
setupTextHoverListener,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* エディタの画像アップロード機能を管理するHook
|
|
5
|
+
* - ファイル選択からのアップロード
|
|
6
|
+
* - ドラッグ&ドロップ
|
|
7
|
+
* - クリップボードからのペースト
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useState, useCallback } from 'react';
|
|
11
|
+
import { useEditorContext } from '../EditorContext';
|
|
12
|
+
import {
|
|
13
|
+
uploadEditorImage,
|
|
14
|
+
uploadEditorImageFromDataUrl,
|
|
15
|
+
SUPPORTED_IMAGE_TYPES,
|
|
16
|
+
MAX_IMAGE_SIZE,
|
|
17
|
+
} from '../../lib/firebase/storage';
|
|
18
|
+
import { buildDomTree } from '../utils/dom-utils';
|
|
19
|
+
import { findInsertionParent, findSlideRoot } from '../utils/slide-root';
|
|
20
|
+
|
|
21
|
+
interface UploadResult {
|
|
22
|
+
storageUrl: string;
|
|
23
|
+
storagePath: string;
|
|
24
|
+
width?: number;
|
|
25
|
+
height?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface UseImageUploadOptions {
|
|
29
|
+
presentationId?: string;
|
|
30
|
+
slideId?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useImageUpload(options: UseImageUploadOptions = {}) {
|
|
34
|
+
const { getIframeDoc, setDomTree, notifyIframeChange, layoutMode } = useEditorContext();
|
|
35
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
36
|
+
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 画像要素をiframeに挿入
|
|
40
|
+
*/
|
|
41
|
+
const insertImageElement = useCallback((
|
|
42
|
+
url: string,
|
|
43
|
+
options: { width?: number; height?: number; x?: number; y?: number } = {}
|
|
44
|
+
) => {
|
|
45
|
+
const iframeDoc = getIframeDoc();
|
|
46
|
+
if (!iframeDoc) return;
|
|
47
|
+
|
|
48
|
+
// 画像要素を作成
|
|
49
|
+
const img = iframeDoc.createElement('img');
|
|
50
|
+
img.src = url;
|
|
51
|
+
img.alt = 'Uploaded image';
|
|
52
|
+
|
|
53
|
+
// サイズを設定(デフォルトは元のサイズ、最大幅500px)
|
|
54
|
+
const maxWidth = 500;
|
|
55
|
+
let width = options.width || 300;
|
|
56
|
+
let height = options.height || 200;
|
|
57
|
+
|
|
58
|
+
if (width > maxWidth) {
|
|
59
|
+
const ratio = maxWidth / width;
|
|
60
|
+
width = maxWidth;
|
|
61
|
+
height = Math.round(height * ratio);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
img.style.width = `${width}px`;
|
|
65
|
+
img.style.height = `${height}px`;
|
|
66
|
+
|
|
67
|
+
// 位置を設定
|
|
68
|
+
// [移植時の修正] スライド編集中はオートレイアウトでも絶対配置にする。
|
|
69
|
+
// スライドは1920×1080の固定枠(overflow:hidden)で、中身も絶対配置で組まれているため、
|
|
70
|
+
// フロー配置にすると画像がスライドの下(1080pxの外)に積まれて見えなくなり、
|
|
71
|
+
// その状態でドラッグすると画面外へ飛んでいったように見えてしまう。
|
|
72
|
+
const insideSlide = !!findSlideRoot(iframeDoc);
|
|
73
|
+
if (layoutMode === 'absolute' || insideSlide) {
|
|
74
|
+
const x = options.x ?? 100;
|
|
75
|
+
const y = options.y ?? 100;
|
|
76
|
+
img.style.position = 'absolute';
|
|
77
|
+
img.style.left = `${x}px`;
|
|
78
|
+
img.style.top = `${y}px`;
|
|
79
|
+
} else {
|
|
80
|
+
// オートレイアウトモード: 通常のレイアウトフローに従う
|
|
81
|
+
img.style.display = 'block';
|
|
82
|
+
img.style.margin = '10px auto';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 編集可能属性を追加
|
|
86
|
+
const elementId = `el-${Date.now()}-img`;
|
|
87
|
+
img.setAttribute('data-editable', 'true');
|
|
88
|
+
img.setAttribute('data-element-id', elementId);
|
|
89
|
+
|
|
90
|
+
// [移植時の修正] スライドがある場合はスライドの中に追加する。
|
|
91
|
+
// 外側(#artboard/body)に入れると、ズーム倍率の分だけ座標系がずれて
|
|
92
|
+
// スライド外に表示されたり、ドラッグで飛んでいったりする。
|
|
93
|
+
findInsertionParent(iframeDoc).appendChild(img);
|
|
94
|
+
|
|
95
|
+
// DOMツリーを更新
|
|
96
|
+
const newTree = buildDomTree(iframeDoc);
|
|
97
|
+
setDomTree(newTree);
|
|
98
|
+
|
|
99
|
+
// 変更を通知
|
|
100
|
+
notifyIframeChange();
|
|
101
|
+
|
|
102
|
+
return elementId;
|
|
103
|
+
}, [getIframeDoc, setDomTree, notifyIframeChange]);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* ファイルからアップロード
|
|
107
|
+
*/
|
|
108
|
+
const uploadFromFile = useCallback(async (
|
|
109
|
+
file: File,
|
|
110
|
+
insertOptions?: { x?: number; y?: number }
|
|
111
|
+
): Promise<UploadResult | null> => {
|
|
112
|
+
setIsUploading(true);
|
|
113
|
+
setUploadError(null);
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
// ファイルタイプチェック
|
|
117
|
+
if (!SUPPORTED_IMAGE_TYPES.includes(file.type)) {
|
|
118
|
+
throw new Error('サポートされていない画像形式です。JPEG、PNG、GIF、WebP、SVGがサポートされています。');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ファイルサイズチェック
|
|
122
|
+
if (file.size > MAX_IMAGE_SIZE) {
|
|
123
|
+
throw new Error(`画像サイズが大きすぎます。${MAX_IMAGE_SIZE / (1024 * 1024)}MB以下にしてください。`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const result = await uploadEditorImage(file, {
|
|
127
|
+
presentationId: options.presentationId,
|
|
128
|
+
slideId: options.slideId,
|
|
129
|
+
fileName: file.name,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// 画像をiframeに挿入
|
|
133
|
+
insertImageElement(result.storageUrl, {
|
|
134
|
+
width: result.width,
|
|
135
|
+
height: result.height,
|
|
136
|
+
x: insertOptions?.x,
|
|
137
|
+
y: insertOptions?.y,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return result;
|
|
141
|
+
} catch (error) {
|
|
142
|
+
const message = error instanceof Error ? error.message : '画像のアップロードに失敗しました。';
|
|
143
|
+
setUploadError(message);
|
|
144
|
+
console.error('Image upload error:', error);
|
|
145
|
+
return null;
|
|
146
|
+
} finally {
|
|
147
|
+
setIsUploading(false);
|
|
148
|
+
}
|
|
149
|
+
}, [options.presentationId, options.slideId, insertImageElement]);
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 複数ファイルからアップロード
|
|
153
|
+
*/
|
|
154
|
+
const uploadFromFiles = useCallback(async (
|
|
155
|
+
files: FileList | File[],
|
|
156
|
+
insertOptions?: { x?: number; y?: number }
|
|
157
|
+
): Promise<UploadResult[]> => {
|
|
158
|
+
const results: UploadResult[] = [];
|
|
159
|
+
let currentX = insertOptions?.x ?? 100;
|
|
160
|
+
const startY = insertOptions?.y ?? 100;
|
|
161
|
+
|
|
162
|
+
for (const file of Array.from(files)) {
|
|
163
|
+
const result = await uploadFromFile(file, { x: currentX, y: startY });
|
|
164
|
+
if (result) {
|
|
165
|
+
results.push(result);
|
|
166
|
+
currentX += (result.width || 300) + 20; // 次の画像の位置
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return results;
|
|
171
|
+
}, [uploadFromFile]);
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* クリップボードからアップロード
|
|
175
|
+
*/
|
|
176
|
+
const uploadFromClipboard = useCallback(async (
|
|
177
|
+
clipboardData: DataTransfer,
|
|
178
|
+
insertOptions?: { x?: number; y?: number }
|
|
179
|
+
): Promise<UploadResult | null> => {
|
|
180
|
+
console.log('[useImageUpload] uploadFromClipboard called');
|
|
181
|
+
// クリップボードから画像を探す
|
|
182
|
+
const items = Array.from(clipboardData.items);
|
|
183
|
+
console.log('[useImageUpload] Clipboard items:', items.map(i => ({ type: i.type, kind: i.kind })));
|
|
184
|
+
|
|
185
|
+
for (const item of items) {
|
|
186
|
+
if (item.type.startsWith('image/')) {
|
|
187
|
+
const file = item.getAsFile();
|
|
188
|
+
console.log('[useImageUpload] Got file from clipboard item:', file);
|
|
189
|
+
if (file) {
|
|
190
|
+
return uploadFromFile(file, insertOptions);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// DataURLとして取得を試みる
|
|
196
|
+
const htmlData = clipboardData.getData('text/html');
|
|
197
|
+
if (htmlData) {
|
|
198
|
+
const imgMatch = htmlData.match(/<img[^>]+src="(data:image\/[^"]+)"/);
|
|
199
|
+
if (imgMatch) {
|
|
200
|
+
setIsUploading(true);
|
|
201
|
+
setUploadError(null);
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const result = await uploadEditorImageFromDataUrl(imgMatch[1], {
|
|
205
|
+
presentationId: options.presentationId,
|
|
206
|
+
slideId: options.slideId,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
insertImageElement(result.storageUrl, {
|
|
210
|
+
width: result.width,
|
|
211
|
+
height: result.height,
|
|
212
|
+
x: insertOptions?.x,
|
|
213
|
+
y: insertOptions?.y,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return result;
|
|
217
|
+
} catch (error) {
|
|
218
|
+
const message = error instanceof Error ? error.message : '画像のアップロードに失敗しました。';
|
|
219
|
+
setUploadError(message);
|
|
220
|
+
console.error('Image upload error:', error);
|
|
221
|
+
return null;
|
|
222
|
+
} finally {
|
|
223
|
+
setIsUploading(false);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return null;
|
|
229
|
+
}, [options.presentationId, options.slideId, uploadFromFile, insertImageElement]);
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* URLから画像を挿入(アップロードなし、外部URL)
|
|
233
|
+
*/
|
|
234
|
+
const insertImageFromUrl = useCallback((
|
|
235
|
+
url: string,
|
|
236
|
+
insertOptions?: { width?: number; height?: number; x?: number; y?: number }
|
|
237
|
+
) => {
|
|
238
|
+
return insertImageElement(url, insertOptions);
|
|
239
|
+
}, [insertImageElement]);
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* ファイル選択ダイアログを開く
|
|
243
|
+
*/
|
|
244
|
+
const openFilePicker = useCallback((insertOptions?: { x?: number; y?: number }) => {
|
|
245
|
+
const input = document.createElement('input');
|
|
246
|
+
input.type = 'file';
|
|
247
|
+
input.accept = SUPPORTED_IMAGE_TYPES.join(',');
|
|
248
|
+
input.multiple = true;
|
|
249
|
+
|
|
250
|
+
input.onchange = async (e) => {
|
|
251
|
+
const files = (e.target as HTMLInputElement).files;
|
|
252
|
+
if (files && files.length > 0) {
|
|
253
|
+
await uploadFromFiles(files, insertOptions);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
input.click();
|
|
258
|
+
}, [uploadFromFiles]);
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
isUploading,
|
|
262
|
+
uploadError,
|
|
263
|
+
uploadFromFile,
|
|
264
|
+
uploadFromFiles,
|
|
265
|
+
uploadFromClipboard,
|
|
266
|
+
insertImageFromUrl,
|
|
267
|
+
openFilePicker,
|
|
268
|
+
insertImageElement,
|
|
269
|
+
};
|
|
270
|
+
}
|