@growgroup/visual-editor 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +45 -0
  2. package/dist/editor.css +1 -1
  3. package/package.json +1 -1
  4. package/src/editor/FrontendVisualEditor.tsx +173 -0
  5. package/src/editor/components/EditorCanvas.tsx +16 -0
  6. package/src/editor/components/EditorContextMenu.tsx +80 -0
  7. package/src/editor/components/EditorPropertyPanel.tsx +248 -80
  8. package/src/editor/components/EditorSidebar.tsx +2 -2
  9. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +38 -19
  10. package/src/editor/components/ppt/PptChrome.tsx +46 -4
  11. package/src/editor/components/property-panel/VariableAwareSizeInput.tsx +94 -50
  12. package/src/editor/components/property-panel/VariableAwareUnitInput.tsx +12 -6
  13. package/src/editor/constants.ts +24 -0
  14. package/src/editor/contexts/MultiPageCanvasContext.tsx +108 -6
  15. package/src/editor/hooks/index.ts +3 -0
  16. package/src/editor/hooks/useDragResize.ts +30 -5
  17. package/src/editor/hooks/useElementActions.ts +41 -9
  18. package/src/editor/hooks/useElementSelection.ts +4 -2
  19. package/src/editor/hooks/useIframeSetup.ts +8 -1
  20. package/src/editor/hooks/useInlineTextSelection.ts +16 -0
  21. package/src/editor/hooks/useKeepArtboardInPlace.ts +88 -0
  22. package/src/editor/hooks/useLinkNavigation.ts +160 -0
  23. package/src/editor/parts.ts +2 -0
  24. package/src/editor/types.ts +4 -0
  25. package/src/editor/utils/dom-utils.ts +45 -0
  26. package/src/editor/utils/free-layout-tree.ts +562 -0
  27. package/src/editor/utils/free-layout.ts +794 -0
  28. package/src/editor/utils/geometry.ts +41 -3
  29. package/src/editor/utils/html-utils.ts +2 -1
  30. package/src/editor/utils/inline-text-style.ts +426 -0
  31. package/src/editor/utils/link-navigation.ts +474 -0
  32. package/src/editor/utils/style-utils.ts +24 -0
  33. package/src/editor/utils/tailwind-utils.ts +12 -0
  34. package/src/editor/utils/text-line-break.ts +121 -0
  35. package/src/version.ts +1 -1
@@ -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,
@@ -207,6 +208,11 @@ export interface MultiPageCanvasContextValue {
207
208
  /** 倍率・位置。描画に使うなら useCanvasViewState() で購読する。ハンドラから読むだけなら get() */
208
209
  viewStore: CanvasViewStore;
209
210
  setCanvasOffset: (offset: { x: number; y: number }) => void;
211
+ /**
212
+ * 位置を (dx, dy) だけずらす。動いている途中のアニメーションは止めず、行き先も同じだけずらす。
213
+ * 容器の左端が動いた(左のパネルを出し入れした)とき、紙面を画面上の同じ位置に留める補正に使う
214
+ */
215
+ shiftView: (dx: number, dy: number) => void;
210
216
  setCanvasZoom: (zoom: number) => void;
211
217
  /** 倍率と位置を同時に決める(アニメーションも可) */
212
218
  setView: (view: { zoom: number; offset: { x: number; y: number } }, options?: { animate?: boolean }) => void;
@@ -247,6 +253,13 @@ export interface MultiPageCanvasContextValue {
247
253
  markInteracting: () => void;
248
254
  /** 見えている範囲を localStorage から復元できた(初回表示で全体表示を省く) */
249
255
  initialViewRestored: boolean;
256
+ /**
257
+ * 最初の視点を決める(1 回だけ。容器の大きさとフレームの並びが揃ってから MultiPageCanvasView が呼ぶ)。
258
+ * 保存した視点が編集中のページのものなら、そのフレームを保存したときと同じ画面上の位置・倍率に戻す。
259
+ * 別のページのもの・古い形式なら、編集中のフレームが画面に入っているときだけ位置のまま戻す。
260
+ * それ以外(保存が無い・入っていない)は編集中のページへ寄せる
261
+ */
262
+ applyInitialView: () => void;
250
263
  /** 定規 */
251
264
  rulersVisible: boolean;
252
265
  toggleRulers: () => void;
@@ -507,7 +520,16 @@ interface MultiPageCanvasProviderProps {
507
520
  storageKey?: string | null;
508
521
  }
509
522
 
510
- 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
+ };
511
533
 
512
534
  function readPersistedView(key: string | null | undefined): PersistedView | null {
513
535
  if (!key) return null;
@@ -516,6 +538,8 @@ function readPersistedView(key: string | null | undefined): PersistedView | null
516
538
  if (!raw) return null;
517
539
  const v = JSON.parse(raw) as PersistedView;
518
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;
519
543
  return v;
520
544
  } catch {
521
545
  return null;
@@ -659,6 +683,8 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
659
683
  const [rulersVisible, setRulersVisible] = useState<boolean>(persisted?.rulers ?? true);
660
684
  const rulersRef = useRef(rulersVisible);
661
685
  rulersRef.current = rulersVisible;
686
+ /** 最初の視点を決めたか(applyInitialView)。決める前は保存しない(戻す前の仮の視点で記憶を上書きしない) */
687
+ const initialViewAppliedRef = useRef(false);
662
688
 
663
689
  const setPageHeight = useCallback<MultiPageCanvasContextValue['setPageHeight']>(
664
690
  (id, height, source) => {
@@ -685,8 +711,13 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
685
711
  if (timer) clearTimeout(timer);
686
712
  timer = setTimeout(() => {
687
713
  timer = null;
688
- const { canvasZoom, canvasOffset } = viewStore.get();
714
+ if (!initialViewAppliedRef.current) return;
715
+ const { canvasZoom, canvasOffset, activePageId } = viewStore.get();
689
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 };
720
+ }
690
721
  writeStorage(`gg-editor:canvas-view:${storageKey}`, JSON.stringify(v));
691
722
  }, 250);
692
723
  };
@@ -711,6 +742,8 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
711
742
 
712
743
  // ---- アニメーション
713
744
  const animationRef = useRef<number | null>(null);
745
+ /** アニメーションの途中で shiftView された量。補間した位置に足す */
746
+ const animationShiftRef = useRef({ x: 0, y: 0 });
714
747
  const cancelAnimation = () => {
715
748
  if (animationRef.current != null) {
716
749
  cancelAnimationFrame(animationRef.current);
@@ -727,14 +760,16 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
727
760
  }
728
761
  const from = { zoom: viewStore.get().canvasZoom, offset: viewStore.get().canvasOffset };
729
762
  const start = performance.now();
763
+ animationShiftRef.current = { x: 0, y: 0 };
730
764
  const tick = (now: number) => {
731
765
  const t = Math.min(1, (now - start) / VIEW_ANIMATION_MS);
732
766
  const k = easeOutCubic(t);
733
767
  // 倍率は対数で補間する(拡大と縮小で速さが揃う)
734
768
  const zoom = Math.exp(Math.log(from.zoom) + (Math.log(target.zoom) - Math.log(from.zoom)) * k);
769
+ const shift = animationShiftRef.current;
735
770
  const offset = {
736
- x: from.offset.x + (target.offset.x - from.offset.x) * k,
737
- 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,
738
773
  };
739
774
  viewStore.set((prev) => ({ ...prev, canvasZoom: zoom, canvasOffset: offset }));
740
775
  if (t < 1) animationRef.current = requestAnimationFrame(tick);
@@ -749,6 +784,42 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
749
784
  viewStore.set((prev) => ({ ...prev, canvasOffset: offset }));
750
785
  }, [viewStore]);
751
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
+
752
823
  const setCanvasZoom = useCallback((zoom: number) => {
753
824
  cancelAnimation();
754
825
  viewStore.set((prev) => ({ ...prev, canvasZoom: clampZoom(zoom) }));
@@ -907,6 +978,35 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
907
978
  [frameScreenRect, revealPage, zoomToPage],
908
979
  );
909
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
+
910
1010
  /**
911
1011
  * 直前の activatePage(フレームのクリック・左パネル)の宛先。
912
1012
  * 下の同期 effect が「エディタ発かホスト発か」を見分けるのに使う。
@@ -1113,6 +1213,7 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
1113
1213
  previewStyles,
1114
1214
  viewStore,
1115
1215
  setCanvasOffset,
1216
+ shiftView,
1116
1217
  setCanvasZoom,
1117
1218
  setView,
1118
1219
  zoomAt,
@@ -1131,6 +1232,7 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
1131
1232
  isInteracting,
1132
1233
  markInteracting,
1133
1234
  initialViewRestored: !!persisted,
1235
+ applyInitialView,
1134
1236
  rulersVisible,
1135
1237
  toggleRulers,
1136
1238
  requestPreviewSlot,
@@ -1141,9 +1243,9 @@ export function MultiPageCanvasProvider({ children, enabled, storageKey }: Multi
1141
1243
  }),
1142
1244
  [
1143
1245
  enabled, editorMode, pages, bounds, layout, getPage, updatePageFrame, setPageHtml, invalidatePages, artboard.documentAttributes,
1144
- setPageHeight, ensurePageHtml, previewStyles, viewStore, setCanvasOffset, setCanvasZoom, setView, zoomAt, zoomTo, zoomIn, zoomOut, zoomToFit, zoomToPage,
1246
+ setPageHeight, ensurePageHtml, previewStyles, viewStore, setCanvasOffset, shiftView, setCanvasZoom, setView, zoomAt, zoomTo, zoomIn, zoomOut, zoomToFit, zoomToPage,
1145
1247
  zoomToActual, revealPage, focusPage, activatePage, activatingPageId, registerContainer, isInteracting, markInteracting,
1146
- persisted, rulersVisible, toggleRulers, requestPreviewSlot, releasePreviewSlot,
1248
+ persisted, applyInitialView, rulersVisible, toggleRulers, requestPreviewSlot, releasePreviewSlot,
1147
1249
  getPreviewImageZoomCap, reportThumbnailWidth, previewImageCapVersion,
1148
1250
  ],
1149
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;
@@ -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;
@@ -0,0 +1,16 @@
1
+ 'use client';
2
+
3
+ import { useSyncExternalStore } from 'react';
4
+ import {
5
+ getInlineTextSummary,
6
+ subscribeInlineTextSelection,
7
+ type InlineTextSummary,
8
+ } from '../utils/inline-text-style';
9
+
10
+ /**
11
+ * テキストの一部を選んでいるときの、その範囲の文字の見た目。選んでいなければ null。
12
+ * パネルが「選んだ文字の値」と「混在」を出すのに使う
13
+ */
14
+ export function useInlineTextSelection(): InlineTextSummary | null {
15
+ return useSyncExternalStore(subscribeInlineTextSelection, getInlineTextSummary, getInlineTextSummary);
16
+ }
@@ -0,0 +1,88 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * useKeepArtboardInPlace
5
+ *
6
+ * 1 ページ表示(マルチフレームでない EditorCanvas)で、キャンバスの容器の左端が動いても
7
+ * 紙面を画面上の同じ位置に留める(Figma と同じ。見える範囲が狭くなるだけで、中身は動かない)。
8
+ *
9
+ * 左のパネルを出し入れする(部品パネルに替わる・幅をドラッグする・利用側のレールが変わるも同じ)と、
10
+ * 容器(= iframe)の左端がパネルの幅だけ動く。紙面は iframe の中の #canvas-container のスクロールで
11
+ * 置かれていて、その基準は iframe の左端なので、そのままでは紙面も一緒に動く。
12
+ * 左端が動いた分だけ同じ向きへスクロールして打ち消す。
13
+ *
14
+ * 入口ごとには手当てせず、容器の位置の変化そのものを見る。左端は幅と一緒にしか動かないので
15
+ * ResizeObserver で拾える。呼ばれるのはレイアウトの後・描画の前なので、ずれた姿は描かれない。
16
+ *
17
+ * [容器が広がったときのスクロール位置]
18
+ * 広がるとスクロールできる上限が下がり、ブラウザはレイアウトの時点で(ResizeObserver より前に)
19
+ * 位置を上限へ詰める。詰められる前の位置はもう読めないので、スクロールのたびに覚えておく。
20
+ * 覚えるのは容器の幅が前回の ResizeObserver のときと同じ間だけ ── 幅が変わった後に届くスクロールは
21
+ * 詰められた結果で、それを覚えると戻す先を失う
22
+ *
23
+ * マルチフレームのキャンバス(埋め込み)では使わない。位置はキャンバスの転写が持ち(MultiPageCanvasView)、
24
+ * この容器は転写層の中でパンのたびに動く
25
+ */
26
+
27
+ import { useEffect, useRef, type RefObject } from 'react';
28
+ import { scrollCanvasLeftTo } from '../utils/dom-utils';
29
+
30
+ interface Last {
31
+ /** 容器の左端(親の画面の座標) */
32
+ left: number;
33
+ /** iframe の中の表示幅(documentElement.clientWidth)。スクロールを覚えてよいかの判定に使う */
34
+ viewportWidth: number;
35
+ /** #canvas-container の横のスクロール位置 */
36
+ scrollLeft: number;
37
+ }
38
+
39
+ export function useKeepArtboardInPlace(
40
+ containerRef: RefObject<HTMLDivElement | null>,
41
+ getIframeDoc: () => Document | null,
42
+ enabled: boolean,
43
+ iframeReady: boolean,
44
+ ): void {
45
+ const lastRef = useRef<Last | null>(null);
46
+
47
+ // 容器の位置
48
+ useEffect(() => {
49
+ if (!enabled) return;
50
+ const el = containerRef.current;
51
+ if (!el || typeof ResizeObserver === 'undefined') return;
52
+ const observer = new ResizeObserver(() => {
53
+ const left = el.getBoundingClientRect().left;
54
+ const doc = getIframeDoc();
55
+ const scroller = doc?.getElementById('canvas-container') ?? null;
56
+ const last = lastRef.current;
57
+ if (doc && scroller && last && left !== last.left) {
58
+ // 上限へ詰められていれば、覚えておいた位置から(詰められていなければ今の位置が正しい)
59
+ const max = scroller.scrollWidth - scroller.clientWidth;
60
+ const from = scroller.scrollLeft >= max - 1 && last.scrollLeft > scroller.scrollLeft ? last.scrollLeft : scroller.scrollLeft;
61
+ scrollCanvasLeftTo(doc, from + (left - last.left));
62
+ }
63
+ lastRef.current = {
64
+ left,
65
+ viewportWidth: doc?.documentElement?.clientWidth ?? 0,
66
+ scrollLeft: scroller?.scrollLeft ?? 0,
67
+ };
68
+ });
69
+ observer.observe(el);
70
+ return () => observer.disconnect();
71
+ }, [enabled, containerRef, getIframeDoc]);
72
+
73
+ // スクロール位置を覚える(文書ごと。ページを移ると iframe の文書が替わる)
74
+ useEffect(() => {
75
+ if (!enabled || !iframeReady) return;
76
+ const doc = getIframeDoc();
77
+ const scroller = doc?.getElementById('canvas-container');
78
+ if (!doc || !scroller) return;
79
+ const remember = () => {
80
+ const last = lastRef.current;
81
+ // ページを移る途中は文書が差し替わり、documentElement が無いことがある(⌘ クリックのリンク移動で実測)
82
+ if (last && doc.documentElement && doc.documentElement.clientWidth === last.viewportWidth) last.scrollLeft = scroller.scrollLeft;
83
+ };
84
+ remember();
85
+ scroller.addEventListener('scroll', remember, { passive: true });
86
+ return () => scroller.removeEventListener('scroll', remember);
87
+ }, [enabled, iframeReady, getIframeDoc]);
88
+ }