@growgroup/visual-editor 0.3.0 → 0.4.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 (65) hide show
  1. package/README.md +10 -1
  2. package/dist/editor.css +1 -1
  3. package/package.json +3 -2
  4. package/src/editor/EditorContext.tsx +11 -2
  5. package/src/editor/EditorToolbar.tsx +8 -2
  6. package/src/editor/FrontendVisualEditor.tsx +128 -45
  7. package/src/editor/components/EditorCanvas.tsx +87 -15
  8. package/src/editor/components/EditorContextMenu.tsx +12 -3
  9. package/src/editor/components/EditorFooter.tsx +2 -0
  10. package/src/editor/components/EditorHeader.tsx +3 -2
  11. package/src/editor/components/EditorLayerPanel.tsx +28 -2
  12. package/src/editor/components/EditorPropertyPanel.tsx +54 -25
  13. package/src/editor/components/FigmaColorPicker.tsx +2 -1
  14. package/src/editor/components/JsEditorDialog.tsx +1 -0
  15. package/src/editor/components/PageSettingsDialog.tsx +1 -0
  16. package/src/editor/components/comments/CommentBoard.tsx +9 -6
  17. package/src/editor/components/multi-page/CanvasFrameDecor.tsx +172 -0
  18. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +204 -58
  19. package/src/editor/components/multi-page/PageFramePreview.tsx +131 -22
  20. package/src/editor/components/multi-page/index.ts +1 -0
  21. package/src/editor/components/ppt/PptChrome.tsx +6 -2
  22. package/src/editor/components/ppt/PptComments.tsx +411 -71
  23. package/src/editor/constants.ts +44 -1
  24. package/src/editor/contexts/EditorArtboardContext.tsx +60 -1
  25. package/src/editor/contexts/EditorComponentsContext.tsx +48 -31
  26. package/src/editor/contexts/EditorUIStateContext.tsx +4 -3
  27. package/src/editor/contexts/EditorVariablesContext.tsx +5 -4
  28. package/src/editor/contexts/MultiPageCanvasContext.tsx +211 -34
  29. package/src/editor/hooks/useAiReplace.ts +4 -3
  30. package/src/editor/hooks/useBrowserZoomPrevention.ts +2 -1
  31. package/src/editor/hooks/useComponentEditMode.ts +27 -26
  32. package/src/editor/hooks/useEditorMessages.ts +3 -2
  33. package/src/editor/hooks/useElementActions.ts +27 -26
  34. package/src/editor/hooks/useElementSelection.ts +19 -16
  35. package/src/editor/hooks/useFocusManagement.ts +7 -6
  36. package/src/editor/hooks/useGoogleFonts.ts +3 -2
  37. package/src/editor/hooks/useIframeInitializer.ts +6 -3
  38. package/src/editor/hooks/useIframeSetup.ts +4 -3
  39. package/src/editor/hooks/useImageUpload.ts +4 -3
  40. package/src/editor/hooks/useMarqueeSelection.ts +2 -1
  41. package/src/editor/hooks/usePageSettingsManager.ts +24 -23
  42. package/src/editor/hooks/useRichPaste.ts +11 -10
  43. package/src/editor/parts.ts +7 -25
  44. package/src/editor/utils/component-renderer.ts +8 -7
  45. package/src/editor/utils/component-sync.ts +16 -15
  46. package/src/editor/utils/debug.ts +52 -0
  47. package/src/editor/utils/dom-utils.ts +27 -10
  48. package/src/editor/utils/drop-target.ts +135 -0
  49. package/src/editor/utils/eyedropper.ts +3 -1
  50. package/src/editor/utils/figma-export.ts +6 -5
  51. package/src/editor/utils/figma-kiwi-decoder.ts +28 -27
  52. package/src/editor/utils/figma-kiwi-encoder.ts +9 -8
  53. package/src/editor/utils/figma-paste.ts +57 -56
  54. package/src/editor/utils/flex-utils.ts +12 -10
  55. package/src/editor/utils/html-utils.ts +19 -6
  56. package/src/editor/utils/override-apply.ts +3 -2
  57. package/src/editor/utils/override-detection.ts +11 -10
  58. package/src/editor/utils/tailwind-utils.ts +4 -3
  59. package/src/index.ts +5 -0
  60. package/src/io.ts +22 -1
  61. package/src/lib/firebase/css-variables.ts +4 -3
  62. package/src/lib/firebase/storage.ts +3 -2
  63. package/src/styles/skin.css +63 -4
  64. package/src/types/editor.ts +6 -2
  65. package/src/version.ts +9 -0
@@ -9,25 +9,64 @@
9
9
  * フォントや画像の読み込みで高さが変わっても追従する
10
10
  * - 画面から遠いものは描かない(IntersectionObserver)。webpage は高さの実測が
11
11
  * レイアウトに要るので、順番に少しずつ先読みする(eagerMountCount)
12
+ * - iframe に sandbox は付けない。script は本文から外してある(generatePreviewHtml)。
13
+ * sandbox で script を禁じると、ページへ script を差し込むブラウザ拡張(React DevTools 等)が
14
+ * 1 枚ごとに「Blocked script execution in 'about:srcdoc'」をコンソールへ出す(実測 26 枚 = 26 件)
15
+ * - 本文が外から変わった(revision / invalidatePages)ときは、次の本文が来るまで前の姿を出したまま読み直す
16
+ * - 文書の属性(documentAttributes)は読み直さずに <html> へ付け替える
17
+ *
18
+ * [大きく縮小している間は画像]
19
+ * 全体が見えるところまで縮めると、26 枚の iframe を倍率が変わるたびにラスタライズし直す
20
+ * のが一番重い。利用側が contentList[].thumbnail(ページ全体を写した画像)を渡していれば、
21
+ * 倍率が PREVIEW_IMAGE_ZOOM 未満の間は iframe を外して画像 1 枚にする(本文も読まない)。
22
+ * 画像が無いページ・画像が読めなかったページは今までどおり iframe
12
23
  */
13
24
 
14
25
  import { memo, useEffect, useRef, useState } from 'react';
15
26
  import { useMultiPageCanvas, type PageFrameLayout } from '../../contexts/MultiPageCanvasContext';
27
+ import type { DocumentAttributes } from '../../contexts/EditorArtboardContext';
16
28
  import { generatePreviewHtml } from '../../utils/html-utils';
17
29
 
30
+ /** 文書の <html> に属性を付ける(null / undefined は外す)。エディタ本体(EditorCanvas)も同じ関数を使う */
31
+ export function applyDocumentAttributes(doc: Document | null | undefined, attrs: DocumentAttributes): void {
32
+ const root = doc?.documentElement;
33
+ if (!root) return;
34
+ for (const [name, value] of Object.entries(attrs)) {
35
+ if (!/^[A-Za-z_:][\w:.-]*$/.test(name)) continue;
36
+ if (value == null) root.removeAttribute(name);
37
+ else if (root.getAttribute(name) !== value) root.setAttribute(name, value);
38
+ }
39
+ }
40
+
18
41
  interface PageFramePreviewProps {
19
42
  page: PageFrameLayout;
20
43
  /** 画面上の判定に使う容器(IntersectionObserver の root) */
21
44
  rootRef: React.RefObject<HTMLDivElement | null>;
45
+ /** キャンバスが大きく縮小されている(親が操作の終わりに決める。段ごとには変わらない) */
46
+ imageMode?: boolean;
47
+ /**
48
+ * 画像を裏で先に読んでおく。縮小の途中(倍率 0.4 未満)で iframe を止めて画像を前に出すのは
49
+ * CSS の仕事だが、そのとき画像が読めていないと空白のフレームになる。だから先に読ませる
50
+ */
51
+ preloadImage?: boolean;
22
52
  }
23
53
 
24
- export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }: PageFramePreviewProps) {
25
- const { editorMode, ensurePageHtml, setPageHeight, previewStyles, requestPreviewSlot, releasePreviewSlot } = useMultiPageCanvas();
54
+ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef, imageMode = false, preloadImage = false }: PageFramePreviewProps) {
55
+ const { editorMode, ensurePageHtml, setPageHeight, previewStyles, requestPreviewSlot, releasePreviewSlot, documentAttributes } = useMultiPageCanvas();
56
+ const documentAttributesRef = useRef(documentAttributes);
57
+ documentAttributesRef.current = documentAttributes;
26
58
  const hostRef = useRef<HTMLDivElement>(null);
27
59
  const iframeRef = useRef<HTMLIFrameElement>(null);
28
60
  const [nearViewport, setNearViewport] = useState(false);
29
61
  const [loaded, setLoaded] = useState(false);
30
62
 
63
+ // 画像の URL が変わったら、前の URL で失敗した記録は捨てる(直った画像が二度と出ないのを防ぐ)
64
+ const [imageFailed, setImageFailed] = useState(false);
65
+ useEffect(() => { setImageFailed(false); }, [page.thumbnail]);
66
+ const [imageLoaded, setImageLoaded] = useState(false);
67
+ useEffect(() => { setImageLoaded(false); }, [page.thumbnail]);
68
+ const useImage = imageMode && !!page.thumbnail && !imageFailed;
69
+
31
70
  // 画面に近づいたら描く(遠ざかっても消さない: 戻ったときに白く抜けないため)
32
71
  useEffect(() => {
33
72
  const host = hostRef.current;
@@ -44,8 +83,9 @@ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }
44
83
  }, [rootRef]);
45
84
 
46
85
  // webpage は高さの実測がレイアウトに要る(未測定だと仮の高さで並ぶ)ので、見えていなくても読む。
47
- // slide は寸法が決まっているので見えたときだけ。どちらも同時に読む枚数は絞る(順番待ち)
48
- const wantsMount = editorMode === 'webpage' || nearViewport;
86
+ // slide は寸法が決まっているので見えたときだけ。どちらも同時に読む枚数は絞る(順番待ち)。
87
+ // 画像で描いている間は本文を読まない(iframe も載せない)
88
+ const wantsMount = !useImage && (editorMode === 'webpage' || nearViewport);
49
89
  const [granted, setGranted] = useState(false);
50
90
  const grantedRef = useRef(false);
51
91
  useEffect(() => {
@@ -60,7 +100,17 @@ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }
60
100
  };
61
101
  }, [wantsMount, page.id, requestPreviewSlot, releasePreviewSlot]);
62
102
  useEffect(() => () => releasePreviewSlot(page.id), [page.id, releasePreviewSlot]);
63
- const shouldMount = granted;
103
+
104
+ // 画像に落ちたら iframe を外してメモリを返す(戻ってきたら順番待ちから取り直す)
105
+ useEffect(() => {
106
+ if (!useImage) return;
107
+ grantedRef.current = false;
108
+ setGranted(false);
109
+ setLoaded(false);
110
+ releasePreviewSlot(page.id);
111
+ }, [useImage, page.id, releasePreviewSlot]);
112
+
113
+ const shouldMount = granted && !useImage;
64
114
 
65
115
  useEffect(() => {
66
116
  if (shouldMount && page.html == null && !page.loading && !page.error) void ensurePageHtml(page.id);
@@ -73,10 +123,12 @@ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }
73
123
  setDoc(generatePreviewHtml(page.html, editorMode, page.size.width, previewStyles));
74
124
  }, [shouldMount, page.html, editorMode, page.size.width, previewStyles]);
75
125
 
76
- // 高さの実測(同一オリジンなので親から読む)
126
+ // 高さの実測(同一オリジンなので親から読む)。
127
+ // shouldMount を deps に入れるのは、画像から iframe に戻ったとき(doc は前のまま)にも
128
+ // 新しい iframe へ load を張り直すため
77
129
  useEffect(() => {
78
130
  const iframe = iframeRef.current;
79
- if (!iframe || doc == null) return;
131
+ if (!iframe || doc == null || !shouldMount) return;
80
132
  let observer: ResizeObserver | null = null;
81
133
  let disposed = false;
82
134
  const measure = () => {
@@ -92,6 +144,7 @@ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }
92
144
  const onLoad = () => {
93
145
  if (disposed) return;
94
146
  setLoaded(true);
147
+ applyDocumentAttributes(iframe.contentDocument, documentAttributesRef.current);
95
148
  measure();
96
149
  try {
97
150
  const artboard = iframe.contentDocument?.getElementById('artboard');
@@ -106,12 +159,24 @@ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }
106
159
  }
107
160
  };
108
161
  iframe.addEventListener('load', onLoad);
162
+ // 画像から戻ったときは srcdoc が同じなので load が来ないことがある。読めていればその場で測る
163
+ if (iframe.contentDocument?.readyState === 'complete') onLoad();
109
164
  return () => {
110
165
  disposed = true;
111
166
  iframe.removeEventListener('load', onLoad);
112
167
  observer?.disconnect();
113
168
  };
114
- }, [doc, page.id, setPageHeight]);
169
+ }, [doc, shouldMount, page.id, setPageHeight]);
170
+
171
+ // 文書の属性が変わったら、読み直さずに <html> へ付け替える(読み込み前なら onLoad が付ける)
172
+ useEffect(() => {
173
+ if (!loaded) return;
174
+ try {
175
+ applyDocumentAttributes(iframeRef.current?.contentDocument, documentAttributes);
176
+ } catch {
177
+ /* 読めなければ次の読み込みで付く */
178
+ }
179
+ }, [documentAttributes, loaded]);
115
180
 
116
181
  // 読み終わった(または読めない)ら枠を返して次へ。本文が来ない場合の安全弁つき
117
182
  useEffect(() => {
@@ -124,40 +189,84 @@ export const PageFramePreview = memo(function PageFramePreview({ page, rootRef }
124
189
  return () => clearTimeout(timer);
125
190
  }, [granted, loaded, page.error, page.id, releasePreviewSlot]);
126
191
 
192
+ /**
193
+ * 画像を出す(または iframe の下に敷いておく)か。敷いておく理由は 2 つ:
194
+ * - 画像 → iframe に戻る途中、本文が描けるまで下に見せる(26 枚が一度白く抜けない)
195
+ * - 縮小の途中で 0.4 を割ったとき、CSS が iframe を止めるだけで画像に切り替わる
196
+ * (読めていない画像は data-image-ready が付かないので、iframe を見せたまま)
197
+ */
198
+ const showImage = !!page.thumbnail && !imageFailed && (useImage || preloadImage || !loaded);
199
+
127
200
  return (
128
201
  <div
129
202
  ref={hostRef}
130
203
  className="absolute inset-0 overflow-hidden"
131
204
  data-page-preview={page.id}
205
+ data-preview-mode={useImage ? 'image' : 'iframe'}
206
+ // 画像が読めている = 縮小の途中でも iframe を止めて前に出せる(skin.css が拾う)
207
+ data-image-ready={showImage && imageLoaded ? '1' : undefined}
132
208
  style={{ background: '#fff' }}
133
209
  >
134
- {doc != null ? (
210
+ {showImage && (
211
+ <img
212
+ src={page.thumbnail}
213
+ alt=""
214
+ aria-hidden="true"
215
+ draggable={false}
216
+ decoding="async"
217
+ className="absolute inset-0 block"
218
+ style={{
219
+ width: '100%', height: '100%', objectFit: 'fill', pointerEvents: 'none',
220
+ // iframe と同じ理由。無いと倍率が変わるたびに 26 枚の画像がラスタライズし直され、
221
+ // 画像に落としたのに縮小 p95 が 19〜23ms のままになる(付けると 16ms 台。実測)
222
+ willChange: 'transform',
223
+ }}
224
+ onLoad={(e) => {
225
+ setImageLoaded(true);
226
+ // 画像しか出していないページは iframe が高さを測れない。画像はページと同じ縦横比
227
+ // (利用側との約束)なので、フレームの幅から高さを出して並びを正す
228
+ const img = e.currentTarget;
229
+ if (!img.naturalWidth || !img.naturalHeight) return;
230
+ setPageHeight(page.id, (page.size.width * img.naturalHeight) / img.naturalWidth, 'preview');
231
+ }}
232
+ onError={() => setImageFailed(true)}
233
+ />
234
+ )}
235
+ {useImage ? (
236
+ // 画像がまだ来ていない間は骨組みを下に敷く(白く抜けない)
237
+ !imageLoaded && <div className="ed-frame-skeleton" aria-hidden="true" />
238
+ ) : doc != null ? (
135
239
  <iframe
136
240
  ref={iframeRef}
137
241
  title={page.title || page.id}
138
242
  srcDoc={doc}
139
- sandbox="allow-same-origin"
140
243
  tabIndex={-1}
141
244
  aria-hidden="true"
142
- className="block border-0"
245
+ className="relative block border-0"
143
246
  style={{
144
247
  width: page.size.width,
145
248
  height: page.size.height,
146
249
  pointerEvents: 'none',
250
+ // ズームで重くならないように。iframe の中身は外側の transform で拡大縮小されるが、
251
+ // will-change が無いと倍率が変わるたびに 26 枚の紙面がラスタライズし直され、
252
+ // 縮小中に 100〜340ms のフレームが混じる(実測)。付けるとラスタが保たれ p95 117ms → 24ms
253
+ willChange: 'transform',
147
254
  }}
148
255
  />
149
256
  ) : (
150
- <div className="ed-frame-skeleton" aria-hidden="true">
151
- {page.error ? (
152
- <span className="ed-frame-skeleton-note">読み込めませんでした</span>
153
- ) : (
154
- <>
155
- <span className="ed-frame-skeleton-bar" style={{ width: '38%' }} />
156
- <span className="ed-frame-skeleton-bar" style={{ width: '72%' }} />
157
- <span className="ed-frame-skeleton-bar" style={{ width: '56%' }} />
158
- </>
159
- )}
160
- </div>
257
+ !showImage && (
258
+ <div className="ed-frame-skeleton" aria-hidden="true">
259
+ {page.error ? (
260
+ <span className="ed-frame-skeleton-note">読み込めませんでした</span>
261
+ ) : (
262
+ <>
263
+ <span className="ed-frame-skeleton-bar" style={{ width: '38%' }} />
264
+ <span className="ed-frame-skeleton-bar" style={{ width: '72%' }} />
265
+ <span className="ed-frame-skeleton-bar" style={{ width: '56%' }} />
266
+ </>
267
+ )}
268
+ </div>
269
+ )
161
270
  )}
162
271
  </div>
163
272
  );
@@ -1,3 +1,4 @@
1
1
  export { MultiPageCanvasView } from './MultiPageCanvasView';
2
2
  export { PageFramePreview } from './PageFramePreview';
3
3
  export { CanvasRulers } from './CanvasRulers';
4
+ export { CanvasFrameDecor } from './CanvasFrameDecor';
@@ -27,7 +27,7 @@ import {
27
27
  Undo2, Redo2, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight,
28
28
  Type, Square, Circle, Minus, MoveUpRight, Image as ImageIcon, LayoutGrid,
29
29
  Boxes, Trash2, Copy, X, Save, PenTool, Loader2, ZoomIn, ZoomOut, Maximize,
30
- Search, Sun, Moon, ChevronDown, Plus, MousePointer2, Pencil, Play,
30
+ Search, Sun, Moon, ChevronDown, Plus, MousePointer2, Pencil, Play, Crosshair,
31
31
  Palette, Sparkles, EyeOff, ArrowUp, ArrowDown, Film,
32
32
  MoreHorizontal, MessageSquare, ChevronLeft, ChevronRight, Crop, Monitor, MonitorUp,
33
33
  PaintBucket, PenLine, Eraser, Highlighter, Table, Shapes, Sticker, Wand2,
@@ -1010,7 +1010,7 @@ export function PptRibbon({
1010
1010
  onSwitchUi: () => void;
1011
1011
  page: number;
1012
1012
  deckTitle?: string;
1013
- comments: { open: boolean; toggle: () => void; newComment: () => void };
1013
+ comments: { open: boolean; toggle: () => void; newComment: () => void; pickRegion?: () => void };
1014
1014
  }) {
1015
1015
  const pal = PALETTES[theme];
1016
1016
  const [tab, setTab] = useState<TabId>('home');
@@ -1820,6 +1820,10 @@ export function PptRibbon({
1820
1820
  <>
1821
1821
  <BigButton icon={MessageSquare} label={'新しい\nコメント'} onClick={comments.newComment}
1822
1822
  title="コメントを追加(要素を選択していればその要素に添付)" pal={pal} />
1823
+ {comments.pickRegion && (
1824
+ <BigButton icon={Crosshair} label={'範囲を\n指定'} active={actions.activeTool === 'comment'} onClick={comments.pickRegion}
1825
+ title="スライドをドラッグして、コメントする範囲を指定します (C)" pal={pal} />
1826
+ )}
1823
1827
  <BigButton icon={MessageSquare} label={'コメント\nの表示'} active={comments.open} onClick={comments.toggle} pal={pal} />
1824
1828
  <Sep pal={pal} />
1825
1829
  {(() => {