@growgroup/visual-editor 0.4.1 → 0.7.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 (64) hide show
  1. package/README.md +92 -0
  2. package/dist/editor.css +1 -1
  3. package/package.json +7 -2
  4. package/src/editor/FrontendVisualEditor.tsx +244 -17
  5. package/src/editor/collab/CollabPresence.tsx +172 -0
  6. package/src/editor/collab/binding.ts +269 -0
  7. package/src/editor/collab/color.ts +28 -0
  8. package/src/editor/collab/connection.ts +110 -0
  9. package/src/editor/collab/layer.ts +168 -0
  10. package/src/editor/collab/merge.ts +112 -0
  11. package/src/editor/collab/morph.ts +113 -0
  12. package/src/editor/collab/room.ts +82 -0
  13. package/src/editor/collab/runtime.ts +442 -0
  14. package/src/editor/collab/signals.ts +11 -0
  15. package/src/editor/collab/store.ts +192 -0
  16. package/src/editor/collab/useCollab.ts +266 -0
  17. package/src/editor/components/AiPromptPopover.tsx +3 -2
  18. package/src/editor/components/EditorCanvas.tsx +27 -0
  19. package/src/editor/components/EditorContextMenu.tsx +80 -0
  20. package/src/editor/components/EditorPropertyPanel.tsx +248 -80
  21. package/src/editor/components/EditorSidebar.tsx +2 -2
  22. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +41 -19
  23. package/src/editor/components/ppt/PptChrome.tsx +46 -4
  24. package/src/editor/components/ppt/PptComments.tsx +3 -8
  25. package/src/editor/components/property-panel/VariableAwareSizeInput.tsx +94 -50
  26. package/src/editor/components/property-panel/VariableAwareUnitInput.tsx +12 -6
  27. package/src/editor/components/shell/EditorTopBar.tsx +13 -1
  28. package/src/editor/components/shell/PagesPanel.tsx +3 -0
  29. package/src/editor/constants.ts +24 -0
  30. package/src/editor/contexts/EditorAppearanceContext.tsx +5 -5
  31. package/src/editor/contexts/EditorHistoryContext.tsx +2 -0
  32. package/src/editor/contexts/MultiPageCanvasContext.tsx +120 -15
  33. package/src/editor/hooks/index.ts +3 -0
  34. package/src/editor/hooks/useDragResize.ts +30 -5
  35. package/src/editor/hooks/useElementActions.ts +41 -9
  36. package/src/editor/hooks/useElementSelection.ts +4 -2
  37. package/src/editor/hooks/useGoogleFonts.ts +6 -8
  38. package/src/editor/hooks/useIframeSetup.ts +10 -1
  39. package/src/editor/hooks/useInlineTextSelection.ts +16 -0
  40. package/src/editor/hooks/useKeepArtboardInPlace.ts +87 -0
  41. package/src/editor/hooks/useLinkNavigation.ts +160 -0
  42. package/src/editor/hooks/useResizablePanel.ts +3 -2
  43. package/src/editor/parts.ts +2 -0
  44. package/src/editor/types.ts +4 -0
  45. package/src/editor/utils/debug.ts +2 -5
  46. package/src/editor/utils/dom-utils.ts +45 -0
  47. package/src/editor/utils/drop-target.ts +1 -1
  48. package/src/editor/utils/flex-reorder.ts +1 -1
  49. package/src/editor/utils/free-layout-tree.ts +562 -0
  50. package/src/editor/utils/free-layout.ts +794 -0
  51. package/src/editor/utils/geometry.ts +41 -3
  52. package/src/editor/utils/html-utils.ts +4 -1
  53. package/src/editor/utils/inline-text-style.ts +426 -0
  54. package/src/editor/utils/link-navigation.ts +474 -0
  55. package/src/editor/utils/restore-flow.ts +1 -1
  56. package/src/editor/utils/smart-guides.ts +1 -1
  57. package/src/editor/utils/storage.ts +47 -0
  58. package/src/editor/utils/style-utils.ts +24 -0
  59. package/src/editor/utils/tailwind-utils.ts +12 -0
  60. package/src/editor/utils/text-line-break.ts +121 -0
  61. package/src/index.ts +15 -0
  62. package/src/io.ts +29 -0
  63. package/src/styles/skin.css +17 -0
  64. package/src/version.ts +1 -1
@@ -154,6 +154,11 @@ export const EDITOR_IFRAME_STYLES = `
154
154
  opacity: 0.7;
155
155
  cursor: grabbing !important;
156
156
  }
157
+ /* ⌘ / Ctrl を押してリンクの上にいる間は指のカーソル(utils/link-navigation.ts が <html> に印を付け外しする) */
158
+ html[data-gg-link-nav],
159
+ html[data-gg-link-nav] * {
160
+ cursor: pointer !important;
161
+ }
157
162
  ::selection {
158
163
  background-color: rgba(13, 153, 255, 0.3);
159
164
  }
@@ -415,6 +420,25 @@ export const EDITOR_IFRAME_STYLES = `
415
420
  z-index: 40;
416
421
  }
417
422
  .selection-box.selection-in-part .part-badge { background: rgba(123, 97, 255, 0.85); }
423
+ /* 自由配置(gg-freelayout)の器の札。部品の札は左上なので、ぶつからないよう右上に置く */
424
+ .selection-box .freelayout-badge {
425
+ position: absolute;
426
+ top: calc(-44px * var(--ov));
427
+ right: 0;
428
+ padding: calc(2px * var(--ov)) calc(6px * var(--ov));
429
+ border-radius: calc(3px * var(--ov));
430
+ background: #f2994a;
431
+ color: #fff;
432
+ font-size: calc(10px * var(--ov));
433
+ line-height: 1.4;
434
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Hiragino Sans', 'Noto Sans JP', sans-serif;
435
+ white-space: nowrap;
436
+ pointer-events: none;
437
+ z-index: 40;
438
+ }
439
+ /* 自由配置の器は、選んでいなくても薄い枠で範囲が分かるようにする
440
+ (中の要素だけを触っているとき、どこまでが自由配置かを見失わないため) */
441
+ .gg-freelayout.selected { outline: calc(1px * var(--overlay-scale, 1)) dashed rgba(242, 153, 74, 0.8); outline-offset: calc(-1px * var(--overlay-scale, 1)); }
418
442
  /* 選んでいる部品のスロット(編集できる範囲)は点線。中を選んでいるときは部品の外周を薄い点線で示す */
419
443
  [data-part].selected [data-slot] { outline: calc(1px * var(--overlay-scale, 1)) dashed rgba(123, 97, 255, 0.75); outline-offset: calc(-1px * var(--overlay-scale, 1)); }
420
444
  [data-part]:not(.selected):has(.selected) { outline: calc(1px * var(--overlay-scale, 1)) dashed rgba(123, 97, 255, 0.55); outline-offset: calc(2px * var(--overlay-scale, 1)); }
@@ -2,15 +2,15 @@
2
2
 
3
3
  import { createContext, useContext, useEffect, useState } from 'react';
4
4
  import type { PptTheme } from '../components/ppt/PptChrome';
5
+ import { readStorage, writeStorage } from '../utils/storage';
5
6
 
6
7
  const THEME_KEY = 'gg-editor:ppt-theme';
7
8
  export const EditorAppearanceContext = createContext<PptTheme | undefined>(undefined);
8
9
 
9
10
  export function initialEditorTheme(): PptTheme {
10
- try {
11
- const saved = localStorage.getItem(THEME_KEY);
12
- if (saved === 'light' || saved === 'dark') return saved;
13
- } catch { /* ストレージが使えなくてもOS設定で開く */ }
11
+ // ストレージが使えなくても(共有ドロップの opaque origin など)OS 設定で開く
12
+ const saved = readStorage(THEME_KEY);
13
+ if (saved === 'light' || saved === 'dark') return saved;
14
14
  return typeof matchMedia !== 'undefined' && matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
15
15
  }
16
16
 
@@ -28,7 +28,7 @@ export function useEditorTheme() {
28
28
  }, []);
29
29
  const toggleTheme = () => {
30
30
  const next = theme === 'dark' ? 'light' : 'dark';
31
- try { localStorage.setItem(THEME_KEY, next); } catch { /* 今回の表示には適用する */ }
31
+ writeStorage(THEME_KEY, next);
32
32
  setTheme(next);
33
33
  };
34
34
  return { theme, toggleTheme };
@@ -91,6 +91,8 @@ const EDITOR_CHROME_SELECTOR = [
91
91
  '.flex-drop-indicator',
92
92
  '.nesting-drop-indicator',
93
93
  '.auto-layout-drop-indicator',
94
+ // 共同編集の他人の選択枠・カーソル(io.collab があるときだけ描かれる)
95
+ '.gg-collab-layer',
94
96
  ].join(',');
95
97
 
96
98
  /**
@@ -29,6 +29,7 @@ import React, {
29
29
  useCallback,
30
30
  useContext,
31
31
  useEffect,
32
+ useLayoutEffect,
32
33
  useMemo,
33
34
  useRef,
34
35
  useState,
@@ -40,6 +41,7 @@ import { useEditorView } from './EditorViewContext';
40
41
  import { SLIDE_HEIGHT, SLIDE_WIDTH, WEBPAGE_WIDTH } from '../constants';
41
42
  import type { EditorMode } from './EditorToolContext';
42
43
  import { io, type PreviewStyle } from '../../io';
44
+ import { readStorage, writeStorage } from '../utils/storage';
43
45
 
44
46
  // ============================================================
45
47
  // 定数
@@ -206,6 +208,11 @@ export interface MultiPageCanvasContextValue {
206
208
  /** 倍率・位置。描画に使うなら useCanvasViewState() で購読する。ハンドラから読むだけなら get() */
207
209
  viewStore: CanvasViewStore;
208
210
  setCanvasOffset: (offset: { x: number; y: number }) => void;
211
+ /**
212
+ * 位置を (dx, dy) だけずらす。動いている途中のアニメーションは止めず、行き先も同じだけずらす。
213
+ * 容器の左端が動いた(左のパネルを出し入れした)とき、紙面を画面上の同じ位置に留める補正に使う
214
+ */
215
+ shiftView: (dx: number, dy: number) => void;
209
216
  setCanvasZoom: (zoom: number) => void;
210
217
  /** 倍率と位置を同時に決める(アニメーションも可) */
211
218
  setView: (view: { zoom: number; offset: { x: number; y: number } }, options?: { animate?: boolean }) => void;
@@ -220,6 +227,11 @@ export interface MultiPageCanvasContextValue {
220
227
  zoomToActual: () => void;
221
228
  /** 指定のフレームが画面に入っていなければ、見える位置まで寄せる(倍率は変えない) */
222
229
  revealPage: (id: string) => void;
230
+ /**
231
+ * そのページへ視点を寄せる。見えていなければ寄せ、点にしか見えない倍率ならページに合わせる
232
+ * (ホスト発のページ切替・共同編集のアバターのクリックで使う)
233
+ */
234
+ focusPage: (id: string) => void;
223
235
 
224
236
  // ---- ページ切替
225
237
  /**
@@ -241,6 +253,13 @@ export interface MultiPageCanvasContextValue {
241
253
  markInteracting: () => void;
242
254
  /** 見えている範囲を localStorage から復元できた(初回表示で全体表示を省く) */
243
255
  initialViewRestored: boolean;
256
+ /**
257
+ * 最初の視点を決める(1 回だけ。容器の大きさとフレームの並びが揃ってから MultiPageCanvasView が呼ぶ)。
258
+ * 保存した視点が編集中のページのものなら、そのフレームを保存したときと同じ画面上の位置・倍率に戻す。
259
+ * 別のページのもの・古い形式なら、編集中のフレームが画面に入っているときだけ位置のまま戻す。
260
+ * それ以外(保存が無い・入っていない)は編集中のページへ寄せる
261
+ */
262
+ applyInitialView: () => void;
244
263
  /** 定規 */
245
264
  rulersVisible: boolean;
246
265
  toggleRulers: () => void;
@@ -501,15 +520,26 @@ interface MultiPageCanvasProviderProps {
501
520
  storageKey?: string | null;
502
521
  }
503
522
 
504
- type PersistedView = { zoom: number; offset: { x: number; y: number }; rulers?: boolean };
523
+ type PersistedView = {
524
+ zoom: number;
525
+ offset: { x: number; y: number };
526
+ rulers?: boolean;
527
+ /**
528
+ * 保存したときに編集していたページと、そのフレームの左上の画面上の位置(容器の座標)。
529
+ * 開き直した直後はフレームの高さを測る前で並びが保存したときと違うので、offset ではなくこれでフレームに合わせて戻す
530
+ */
531
+ anchor?: { pageId: string; x: number; y: number };
532
+ };
505
533
 
506
534
  function readPersistedView(key: string | null | undefined): PersistedView | null {
507
- if (!key || typeof localStorage === 'undefined') return null;
535
+ if (!key) return null;
508
536
  try {
509
- const raw = localStorage.getItem(`gg-editor:canvas-view:${key}`);
537
+ const raw = readStorage(`gg-editor:canvas-view:${key}`);
510
538
  if (!raw) return null;
511
539
  const v = JSON.parse(raw) as PersistedView;
512
540
  if (!Number.isFinite(v.zoom) || !v.offset || !Number.isFinite(v.offset.x) || !Number.isFinite(v.offset.y)) return null;
541
+ const a = v.anchor;
542
+ if (a && (typeof a.pageId !== 'string' || !Number.isFinite(a.x) || !Number.isFinite(a.y))) delete v.anchor;
513
543
  return v;
514
544
  } catch {
515
545
  return null;
@@ -653,6 +683,8 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
653
683
  const [rulersVisible, setRulersVisible] = useState<boolean>(persisted?.rulers ?? true);
654
684
  const rulersRef = useRef(rulersVisible);
655
685
  rulersRef.current = rulersVisible;
686
+ /** 最初の視点を決めたか(applyInitialView)。決める前は保存しない(戻す前の仮の視点で記憶を上書きしない) */
687
+ const initialViewAppliedRef = useRef(false);
656
688
 
657
689
  const setPageHeight = useCallback<MultiPageCanvasContextValue['setPageHeight']>(
658
690
  (id, height, source) => {
@@ -673,19 +705,20 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
673
705
 
674
706
  // 見えている範囲の保存(変化が止まってから)
675
707
  useEffect(() => {
676
- if (!storageKey || typeof localStorage === 'undefined') return;
708
+ if (!storageKey) return;
677
709
  let timer: ReturnType<typeof setTimeout> | null = null;
678
710
  const persist = () => {
679
711
  if (timer) clearTimeout(timer);
680
712
  timer = setTimeout(() => {
681
713
  timer = null;
682
- try {
683
- const { canvasZoom, canvasOffset } = viewStore.get();
684
- const v: PersistedView = { zoom: canvasZoom, offset: canvasOffset, rulers: rulersRef.current };
685
- localStorage.setItem(`gg-editor:canvas-view:${storageKey}`, JSON.stringify(v));
686
- } catch {
687
- /* 保存できなくても表示には影響しない */
714
+ if (!initialViewAppliedRef.current) return;
715
+ const { canvasZoom, canvasOffset, activePageId } = viewStore.get();
716
+ const v: PersistedView = { zoom: canvasZoom, offset: canvasOffset, rulers: rulersRef.current };
717
+ const page = activePageId ? pagesRef.current.find((p) => p.id === activePageId) : undefined;
718
+ if (page) {
719
+ v.anchor = { pageId: page.id, x: canvasOffset.x + page.position.x * canvasZoom, y: canvasOffset.y + page.position.y * canvasZoom };
688
720
  }
721
+ writeStorage(`gg-editor:canvas-view:${storageKey}`, JSON.stringify(v));
689
722
  }, 250);
690
723
  };
691
724
  const unsubscribe = viewStore.subscribe(persist);
@@ -709,6 +742,8 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
709
742
 
710
743
  // ---- アニメーション
711
744
  const animationRef = useRef<number | null>(null);
745
+ /** アニメーションの途中で shiftView された量。補間した位置に足す */
746
+ const animationShiftRef = useRef({ x: 0, y: 0 });
712
747
  const cancelAnimation = () => {
713
748
  if (animationRef.current != null) {
714
749
  cancelAnimationFrame(animationRef.current);
@@ -725,14 +760,16 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
725
760
  }
726
761
  const from = { zoom: viewStore.get().canvasZoom, offset: viewStore.get().canvasOffset };
727
762
  const start = performance.now();
763
+ animationShiftRef.current = { x: 0, y: 0 };
728
764
  const tick = (now: number) => {
729
765
  const t = Math.min(1, (now - start) / VIEW_ANIMATION_MS);
730
766
  const k = easeOutCubic(t);
731
767
  // 倍率は対数で補間する(拡大と縮小で速さが揃う)
732
768
  const zoom = Math.exp(Math.log(from.zoom) + (Math.log(target.zoom) - Math.log(from.zoom)) * k);
769
+ const shift = animationShiftRef.current;
733
770
  const offset = {
734
- x: from.offset.x + (target.offset.x - from.offset.x) * k,
735
- y: from.offset.y + (target.offset.y - from.offset.y) * k,
771
+ x: from.offset.x + (target.offset.x - from.offset.x) * k + shift.x,
772
+ y: from.offset.y + (target.offset.y - from.offset.y) * k + shift.y,
736
773
  };
737
774
  viewStore.set((prev) => ({ ...prev, canvasZoom: zoom, canvasOffset: offset }));
738
775
  if (t < 1) animationRef.current = requestAnimationFrame(tick);
@@ -747,6 +784,42 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
747
784
  viewStore.set((prev) => ({ ...prev, canvasOffset: offset }));
748
785
  }, [viewStore]);
749
786
 
787
+ const shiftView = useCallback((dx: number, dy: number) => {
788
+ if (!dx && !dy) return;
789
+ const shift = animationShiftRef.current;
790
+ animationShiftRef.current = { x: shift.x + dx, y: shift.y + dy };
791
+ viewStore.set((prev) => ({ ...prev, canvasOffset: { x: prev.canvasOffset.x + dx, y: prev.canvasOffset.y + dy } }));
792
+ }, [viewStore]);
793
+
794
+ // 編集中のフレームは、並びが変わっても画面上で動かさない。
795
+ // フレームの高さは紙面(または遠景の画像)を読んで測るまで仮の値で、測れるたびにツリーの行の高さが変わり、
796
+ // 深い段のフレームほど大きくずれる(トップが 5800px なら、2 段目の子は数千 px 下へ)。視点はそのページに
797
+ // 合わせて決めたもの(開いた直後の寄せ・前回の視点の復元・ページ切替の寄せ)なので、フレームが動いた分だけ視点も動かす。
798
+ // useLayoutEffect なのは、フレームの位置が変わった描画と同じフレームで転写層を書き換えるため
799
+ const activeAnchorRef = useRef<{ id: string; x: number; y: number } | null>(null);
800
+ useLayoutEffect(() => {
801
+ if (!enabled) return;
802
+ const id = viewStore.get().activePageId;
803
+ const page = id ? pages.find((p) => p.id === id) : undefined;
804
+ const last = activeAnchorRef.current;
805
+ if (page && last && last.id === page.id && (last.x !== page.position.x || last.y !== page.position.y)) {
806
+ const z = viewStore.get().canvasZoom;
807
+ shiftView((last.x - page.position.x) * z, (last.y - page.position.y) * z);
808
+ }
809
+ activeAnchorRef.current = page ? { id: page.id, x: page.position.x, y: page.position.y } : null;
810
+ }, [enabled, pages, viewStore, shiftView]);
811
+ // 編集するページが替わったら、そのページの今の位置から追い始める
812
+ useEffect(
813
+ () =>
814
+ viewStore.subscribe(() => {
815
+ const id = viewStore.get().activePageId;
816
+ if ((activeAnchorRef.current?.id ?? null) === id) return;
817
+ const page = id ? pagesRef.current.find((p) => p.id === id) : undefined;
818
+ activeAnchorRef.current = page ? { id: page.id, x: page.position.x, y: page.position.y } : null;
819
+ }),
820
+ [viewStore],
821
+ );
822
+
750
823
  const setCanvasZoom = useCallback((zoom: number) => {
751
824
  cancelAnimation();
752
825
  viewStore.set((prev) => ({ ...prev, canvasZoom: clampZoom(zoom) }));
@@ -905,6 +978,35 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
905
978
  [frameScreenRect, revealPage, zoomToPage],
906
979
  );
907
980
 
981
+ const applyInitialView = useCallback(() => {
982
+ if (initialViewAppliedRef.current) return;
983
+ initialViewAppliedRef.current = true;
984
+ const id = viewStore.get().activePageId;
985
+ const page = id ? pagesRef.current.find((p) => p.id === id) : undefined;
986
+ if (!page) {
987
+ // 編集中のページが無い: 保存した視点が何かを映していればそのまま、でなければ全体
988
+ if (persisted) {
989
+ const { canvasOffset: o, canvasZoom: z } = viewStore.get();
990
+ const b = boundsRef.current;
991
+ const { width, height } = containerSize();
992
+ if (o.x + b.maxX * z > 0 && o.x + b.minX * z < width && o.y + b.maxY * z > 0 && o.y + b.minY * z < height) return;
993
+ }
994
+ zoomToFit();
995
+ return;
996
+ }
997
+ if (persisted) {
998
+ // 同じページの視点なら、フレームを保存したときと同じ画面上の位置・倍率に置く(並びは後から動くが、上の追従が保つ)
999
+ if (persisted.anchor?.pageId === page.id) {
1000
+ const zoom = clampZoom(persisted.zoom);
1001
+ setView({ zoom, offset: { x: persisted.anchor.x - page.position.x * zoom, y: persisted.anchor.y - page.position.y * zoom } });
1002
+ }
1003
+ // 別のページの視点・古い形式(anchor 無し)は位置のまま。どちらも編集中のフレームが画面に入っていれば戻す
1004
+ if (frameScreenRect(page.id)?.visible) return;
1005
+ }
1006
+ zoomToPage(page.id);
1007
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1008
+ }, [persisted, viewStore, setView, zoomToFit, zoomToPage, frameScreenRect]);
1009
+
908
1010
  /**
909
1011
  * 直前の activatePage(フレームのクリック・左パネル)の宛先。
910
1012
  * 下の同期 effect が「エディタ発かホスト発か」を見分けるのに使う。
@@ -1111,6 +1213,7 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
1111
1213
  previewStyles,
1112
1214
  viewStore,
1113
1215
  setCanvasOffset,
1216
+ shiftView,
1114
1217
  setCanvasZoom,
1115
1218
  setView,
1116
1219
  zoomAt,
@@ -1121,6 +1224,7 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
1121
1224
  zoomToPage,
1122
1225
  zoomToActual,
1123
1226
  revealPage,
1227
+ focusPage,
1124
1228
  activatePage,
1125
1229
  activatingPageId,
1126
1230
  registerContainer,
@@ -1128,6 +1232,7 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
1128
1232
  isInteracting,
1129
1233
  markInteracting,
1130
1234
  initialViewRestored: !!persisted,
1235
+ applyInitialView,
1131
1236
  rulersVisible,
1132
1237
  toggleRulers,
1133
1238
  requestPreviewSlot,
@@ -1138,9 +1243,9 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
1138
1243
  }),
1139
1244
  [
1140
1245
  enabled, editorMode, pages, bounds, layout, getPage, updatePageFrame, setPageHtml, invalidatePages, artboard.documentAttributes,
1141
- setPageHeight, ensurePageHtml, previewStyles, viewStore, setCanvasOffset, setCanvasZoom, setView, zoomAt, zoomTo, zoomIn, zoomOut, zoomToFit, zoomToPage,
1142
- zoomToActual, revealPage, activatePage, activatingPageId, registerContainer, isInteracting, markInteracting,
1143
- persisted, rulersVisible, toggleRulers, requestPreviewSlot, releasePreviewSlot,
1246
+ setPageHeight, ensurePageHtml, previewStyles, viewStore, setCanvasOffset, shiftView, setCanvasZoom, setView, zoomAt, zoomTo, zoomIn, zoomOut, zoomToFit, zoomToPage,
1247
+ zoomToActual, revealPage, focusPage, activatePage, activatingPageId, registerContainer, isInteracting, markInteracting,
1248
+ persisted, applyInitialView, rulersVisible, toggleRulers, requestPreviewSlot, releasePreviewSlot,
1144
1249
  getPreviewImageZoomCap, reportThumbnailWidth, previewImageCapVersion,
1145
1250
  ],
1146
1251
  );
@@ -24,6 +24,9 @@ export { useDragResize } from './useDragResize';
24
24
  export { useContextMenuHandler } from './useContextMenuHandler';
25
25
  export { useIframeSetup } from './useIframeSetup';
26
26
  export { useFocusManagement } from './useFocusManagement';
27
+ export { useInlineTextSelection } from './useInlineTextSelection';
28
+ export { useLinkNavigation } from './useLinkNavigation';
29
+ export { useKeepArtboardInPlace } from './useKeepArtboardInPlace';
27
30
  export { useResizablePanel } from './useResizablePanel';
28
31
 
29
32
  // Component system hooks
@@ -28,6 +28,8 @@ import {
28
28
  isOutOfFlowPosition,
29
29
  measureStyleSize,
30
30
  roundPx,
31
+ readSizeLimits,
32
+ type SizeLimits,
31
33
  } from '../utils/geometry';
32
34
  import {
33
35
  shouldReorder,
@@ -49,6 +51,7 @@ import {
49
51
  type GuideCandidates,
50
52
  type MovingRect,
51
53
  } from '../utils/smart-guides';
54
+ import { computeFreeBounds, keepsInlineGeometry } from '../utils/free-layout';
52
55
  import { extractElementInfo } from '../utils/style-utils';
53
56
  import { convertInlineStylesToTailwind } from '../utils/tailwind-utils';
54
57
  import { isAspectRatioLocked } from '../utils/aspect-lock';
@@ -219,6 +222,8 @@ interface ResizeSnapshot {
219
222
  */
220
223
  wroteWidth: boolean;
221
224
  wroteHeight: boolean;
225
+ /** 要素の min / max-width・height。ハンドルでもここで止める(開始時に読む) */
226
+ limits: SizeLimits;
222
227
  }
223
228
 
224
229
  /**
@@ -335,6 +340,13 @@ export function useDragResize(
335
340
  // 案内はセッション中1回だけ。ドラッグのたびに帯が出ると操作の邪魔になる
336
341
  const reorderHintShownRef = useRef(false);
337
342
 
343
+ // 自由配置(gg-freelayout)の器の中で動かせる範囲。ドラッグ開始時に1回だけ採る。
344
+ // 「器の外には出さない」という約束をここで守る。器の外へ出た要素は、器の高さが
345
+ // 固定されているぶん見えなくなる(overflow)ことがあり、消えたように見えるため。
346
+ // 群で掴んだときは各要素の許容範囲の**共通部分**を使う。個別に丸めると
347
+ // 群の相対位置が崩れる(端に着いた要素だけ止まり、隊列がばらける)
348
+ const freeBoundsRef = useRef<{ dxMin: number; dxMax: number; dyMin: number; dyMax: number } | null>(null);
349
+
338
350
  // editorMode も zoom と同じ理由で ref に写す。
339
351
  // iframe に張ったリスナーのクロージャは古い値を掴んだままになるため
340
352
  const editorModeRef = useRef(editorMode);
@@ -378,6 +390,7 @@ export function useDragResize(
378
390
  reorderRef.current = null;
379
391
  reorderIndexRef.current = -1;
380
392
  dragBlockedRef.current = false;
393
+ freeBoundsRef.current = null;
381
394
  }, [dragStateRef]);
382
395
 
383
396
  /**
@@ -641,6 +654,7 @@ export function useDragResize(
641
654
  heightInert: false,
642
655
  wroteWidth: false,
643
656
  wroteHeight: false,
657
+ limits: readSizeLimits(element, iframeDoc),
644
658
  };
645
659
  },
646
660
  [layoutModeRef]
@@ -794,7 +808,7 @@ export function useDragResize(
794
808
  handle,
795
809
  deltaX,
796
810
  deltaY,
797
- { shiftKey: mods.shiftKey, altKey: mods.altKey }
811
+ { shiftKey: mods.shiftKey, altKey: mods.altKey, limits: snapshot.limits }
798
812
  );
799
813
 
800
814
  // 掴んだハンドルが実際に動かす軸だけを書く。
@@ -988,6 +1002,8 @@ export function useDragResize(
988
1002
  // 以後のフレームは基準に delta を足すだけなので、要素数に関係なく軽い
989
1003
  dragBaseRectRef.current = unionOverlayRect(iframeDoc, movingElements);
990
1004
  guideCandidatesRef.current = collectGuideCandidates(iframeDoc, movingElements);
1005
+ // 自由配置の器の中なら、器からはみ出さない範囲を先に決めておく
1006
+ freeBoundsRef.current = isWebpage ? computeFreeBounds(targets, iframeDoc) : null;
991
1007
  }
992
1008
  }
993
1009
 
@@ -1059,6 +1075,14 @@ export function useDragResize(
1059
1075
  clearSmartGuides(iframeDoc);
1060
1076
  }
1061
1077
 
1078
+ // 自由配置の器の中では、器の外へ出さない。
1079
+ // スナップのあとに丸めるのは、吸着で器の外へ引っ張られるのを防ぐため
1080
+ const bounds = freeBoundsRef.current;
1081
+ if (bounds) {
1082
+ snappedDx = Math.min(Math.max(snappedDx, bounds.dxMin), Math.max(bounds.dxMin, bounds.dxMax));
1083
+ snappedDy = Math.min(Math.max(snappedDy, bounds.dyMin), Math.max(bounds.dyMin, bounds.dyMax));
1084
+ }
1085
+
1062
1086
  // 単一選択も複数選択も、全要素に「同一の delta」を加算する(群ごと移動)。
1063
1087
  // origin は mousedown 時に offsetLeft/offsetTop で採ってあり、
1064
1088
  // 書き込む直前に Math.round を通すので、要素ごとに移動量がズレない。
@@ -1242,9 +1266,9 @@ export function useDragResize(
1242
1266
  const resizeProperties = ['width', 'height', 'left', 'top', 'borderRadius'];
1243
1267
  if (resizeState.elements && resizeState.elements.length > 0) {
1244
1268
  resizeState.elements.forEach((el) => {
1245
- convertInlineStylesToTailwind(el, resizeProperties);
1269
+ if (!keepsInlineGeometry(el)) convertInlineStylesToTailwind(el, resizeProperties);
1246
1270
  });
1247
- } else {
1271
+ } else if (!keepsInlineGeometry(resizeState.element)) {
1248
1272
  convertInlineStylesToTailwind(resizeState.element, resizeProperties);
1249
1273
  }
1250
1274
 
@@ -1345,13 +1369,14 @@ export function useDragResize(
1345
1369
  // インラインスタイルをTailwindクラスに変換
1346
1370
  // ドラッグ中はパフォーマンスのためインラインスタイルを使用し、
1347
1371
  // 終了時にTailwindクラスに変換する
1372
+ // 自由配置で倒した要素(gg-freelayout-item)はインラインのまま残す(解除で外すため)
1348
1373
  if (dragState.hasMoved) {
1349
1374
  const dragProperties = ['left', 'top'];
1350
1375
  if (dragState.elements.length > 1) {
1351
1376
  dragState.elements.forEach((el) => {
1352
- convertInlineStylesToTailwind(el, dragProperties);
1377
+ if (!keepsInlineGeometry(el)) convertInlineStylesToTailwind(el, dragProperties);
1353
1378
  });
1354
- } else {
1379
+ } else if (!keepsInlineGeometry(dragState.element)) {
1355
1380
  convertInlineStylesToTailwind(dragState.element, dragProperties);
1356
1381
  }
1357
1382
 
@@ -14,6 +14,7 @@ import {
14
14
  canUngroup,
15
15
  hasEditableChildren,
16
16
  prepareElementDragOrigin,
17
+ isNonEditableTag,
17
18
  } from '../utils/dom-utils';
18
19
  import {
19
20
  resolveWebpageDrag,
@@ -22,6 +23,8 @@ import {
22
23
  } from '../utils/flex-reorder';
23
24
  import { extractElementInfo } from '../utils/style-utils';
24
25
  import { applyTailwindStyles, convertInlineStylesToTailwind } from '../utils/tailwind-utils';
26
+ import { keepsInlineGeometry } from '../utils/free-layout';
27
+ import { INLINE_TEXT_PROPERTIES, applyInlineTextStyle, getInlineTextRange } from '../utils/inline-text-style';
25
28
  import { copyElementsToFigma, isFigmaExportAvailable } from '../utils/figma-export';
26
29
  import {
27
30
  hasViewportUnit,
@@ -473,7 +476,7 @@ export function useElementActions() {
473
476
  // インラインスタイルをTailwindクラスに変換
474
477
  const positionProperties = ['left', 'top'];
475
478
  elements.forEach(el => {
476
- convertInlineStylesToTailwind(el, positionProperties);
479
+ if (!keepsInlineGeometry(el)) convertInlineStylesToTailwind(el, positionProperties);
477
480
  });
478
481
 
479
482
  notifyIframeChange();
@@ -588,6 +591,34 @@ export function useElementActions() {
588
591
  }
589
592
  }, [selectedElement, selectedElementIds, getIframeDoc, notifyIframeChange, setSelectedElement, lastUsedStylesRef, getCanvasDimensions, editorMode]);
590
593
 
594
+ /**
595
+ * 文字の属性(大きさ・太さ・色・字間など)を変える。テキストの一部を選んでいれば、その範囲だけに当てる。
596
+ * 範囲が無い・要素の文字を全部選んでいる・文字の属性以外を含む・複数選択のときは
597
+ * updateElementStyle と同じ(要素全体)
598
+ */
599
+ const updateTextStyle = useCallback((styles: Record<string, string>) => {
600
+ const iframeDoc = getIframeDoc();
601
+ const single =
602
+ !!selectedElement &&
603
+ (selectedElementIds.length === 0 ||
604
+ (selectedElementIds.length === 1 && selectedElementIds[0] === selectedElement.id));
605
+ const target =
606
+ iframeDoc && selectedElement && single && Object.keys(styles).every((k) => INLINE_TEXT_PROPERTIES.has(k))
607
+ ? getInlineTextRange(iframeDoc, selectedElement.id)
608
+ : null;
609
+ if (!iframeDoc || !target) {
610
+ updateElementStyle(styles);
611
+ return;
612
+ }
613
+ applyInlineTextStyle(target, styles);
614
+ notifyIframeChange();
615
+ const info = extractElementInfo(target.host, iframeDoc);
616
+ if (info) setSelectedElement(info);
617
+ requestAnimationFrame(() => {
618
+ refreshSelectionOverlay(iframeDoc);
619
+ });
620
+ }, [selectedElement, selectedElementIds, getIframeDoc, notifyIframeChange, setSelectedElement, updateElementStyle]);
621
+
591
622
  // 要素削除(複数選択対応)
592
623
  const deleteElement = useCallback(() => {
593
624
  const iframeDoc = getIframeDoc();
@@ -1248,14 +1279,14 @@ export function useElementActions() {
1248
1279
  children.forEach(child => {
1249
1280
  const el = child as HTMLElement;
1250
1281
 
1251
- // data-element-idがない場合は付与
1252
- if (!el.getAttribute('data-element-id')) {
1253
- el.setAttribute('data-element-id', generateElementId('ungrouped'));
1254
- }
1255
-
1256
- // data-editableを付与
1257
- if (!el.getAttribute('data-editable')) {
1258
- el.setAttribute('data-editable', 'true');
1282
+ // data-element-id / data-editable を付与(改行 <br> / <wbr> には付けない)
1283
+ if (!isNonEditableTag(el)) {
1284
+ if (!el.getAttribute('data-element-id')) {
1285
+ el.setAttribute('data-element-id', generateElementId('ungrouped'));
1286
+ }
1287
+ if (!el.getAttribute('data-editable')) {
1288
+ el.setAttribute('data-editable', 'true');
1289
+ }
1259
1290
  }
1260
1291
 
1261
1292
  // 子要素の現在の位置を取得
@@ -1783,6 +1814,7 @@ export function useElementActions() {
1783
1814
 
1784
1815
  return {
1785
1816
  updateElementStyle,
1817
+ updateTextStyle,
1786
1818
  updateLinkAttribute,
1787
1819
  updateElementAttribute,
1788
1820
  deleteElement,
@@ -20,6 +20,7 @@ import {
20
20
  refreshSelectionOverlay,
21
21
  syncSelectionOverlayRects,
22
22
  lockCaretScroll,
23
+ isNonEditableTag,
23
24
  } from "../utils/dom-utils";
24
25
  import { extractElementInfo } from "../utils/style-utils";
25
26
  import { MARQUEE_DRAG_THRESHOLD } from "../constants";
@@ -303,8 +304,8 @@ export function useElementSelection(
303
304
  return null;
304
305
  }
305
306
 
306
- // 直接data-editableを持っているか
307
- if (el.getAttribute("data-editable") === "true") {
307
+ // 直接data-editableを持っているか(改行 <br> / <wbr> は持っていても親の文字要素を選ぶ)
308
+ if (el.getAttribute("data-editable") === "true" && !isNonEditableTag(el)) {
308
309
  // 真のインライン要素(テキストフロー内)の場合は親のブロック要素を探す
309
310
  if (isTrueInlineInTextFlow(el, iframeDoc)) {
310
311
  let parent = el.parentElement;
@@ -328,6 +329,7 @@ export function useElementSelection(
328
329
  while (current && current !== iframeDoc.body) {
329
330
  if (
330
331
  current.getAttribute("data-editable") === "true" &&
332
+ !isNonEditableTag(current) &&
331
333
  !isTrueInlineInTextFlow(current, iframeDoc)
332
334
  ) {
333
335
  return current;
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useState, useEffect, useCallback, useMemo } from 'react';
4
4
  import { debugLog } from '../utils/debug';
5
+ import { readStorage, writeStorage } from '../utils/storage';
5
6
 
6
7
  /**
7
8
  * Google Fontの情報
@@ -140,8 +141,8 @@ export function useGoogleFonts() {
140
141
  const fetchGoogleFonts = useCallback(async () => {
141
142
  // キャッシュをチェック
142
143
  try {
143
- const cachedFonts = localStorage.getItem(FONTS_CACHE_KEY);
144
- const cacheExpiry = localStorage.getItem(FONTS_CACHE_EXPIRY_KEY);
144
+ const cachedFonts = readStorage(FONTS_CACHE_KEY);
145
+ const cacheExpiry = readStorage(FONTS_CACHE_EXPIRY_KEY);
145
146
 
146
147
  if (cachedFonts && cacheExpiry && Date.now() < parseInt(cacheExpiry, 10)) {
147
148
  const parsed = JSON.parse(cachedFonts) as GoogleFont[];
@@ -197,12 +198,9 @@ export function useGoogleFonts() {
197
198
  }));
198
199
 
199
200
  // キャッシュに保存
200
- try {
201
- localStorage.setItem(FONTS_CACHE_KEY, JSON.stringify(googleFonts));
202
- localStorage.setItem(FONTS_CACHE_EXPIRY_KEY, String(Date.now() + CACHE_DURATION));
203
- } catch {
204
- // ストレージが一杯の場合は無視
205
- }
201
+ // ストレージが一杯・使えない場合は黙って諦める
202
+ writeStorage(FONTS_CACHE_KEY, JSON.stringify(googleFonts));
203
+ writeStorage(FONTS_CACHE_EXPIRY_KEY, String(Date.now() + CACHE_DURATION));
206
204
 
207
205
  setFonts([...SYSTEM_FONTS, ...googleFonts]);
208
206
  } catch (err) {
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { useCallback, useRef, useEffect } from 'react';
10
10
  import { useEditorContext } from '../EditorContext';
11
- import { buildDomTree, isInlineElement } from '../utils/dom-utils';
11
+ import { buildDomTree, isInlineElement, isNonEditableTag } from '../utils/dom-utils';
12
12
  import { EDITOR_IFRAME_STYLES } from '../constants';
13
13
  import { isLockedInsidePart } from '../parts';
14
14
  import { debugLog } from '../utils/debug';
@@ -100,6 +100,13 @@ export function useIframeSetup(): UseIframeSetupReturn {
100
100
  element.tagName === 'HEAD'
101
101
  )
102
102
  return;
103
+ // 改行(<br> / <wbr>)は文字の一部。印も ID も付けない(前に付いていたら外す)
104
+ if (isNonEditableTag(element)) {
105
+ element.removeAttribute('data-editable');
106
+ element.removeAttribute('data-element-id');
107
+ element.removeAttribute('data-inline');
108
+ return;
109
+ }
103
110
  if (element.closest('script, style, svg path, svg g')) return;
104
111
  if (element.classList.contains('material-icons')) return;
105
112
  if (element.classList.contains('material-icons-outlined')) return;
@@ -112,6 +119,8 @@ export function useIframeSetup(): UseIframeSetupReturn {
112
119
  if (element.classList.contains('rotation-handle')) return;
113
120
  // selection-boxの子孫要素をすべてスキップ(念のため)
114
121
  if (element.closest('.selection-box')) return;
122
+ // 共同編集の他人の選択枠・カーソル(表示専用の層)
123
+ if (element.closest('.gg-collab-layer')) return;
115
124
 
116
125
  // 部品のインスタンス(data-part)の中で、スロット(data-slot)の外にある要素は編集対象にしない。
117
126
  // ルート自身とスロットの中は通常どおり(src/editor/parts.ts)。