@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.
- package/README.md +45 -0
- package/dist/editor.css +1 -1
- package/package.json +1 -1
- package/src/editor/FrontendVisualEditor.tsx +173 -0
- package/src/editor/components/EditorCanvas.tsx +16 -0
- package/src/editor/components/EditorContextMenu.tsx +80 -0
- package/src/editor/components/EditorPropertyPanel.tsx +248 -80
- package/src/editor/components/EditorSidebar.tsx +2 -2
- package/src/editor/components/multi-page/MultiPageCanvasView.tsx +38 -19
- package/src/editor/components/ppt/PptChrome.tsx +46 -4
- package/src/editor/components/property-panel/VariableAwareSizeInput.tsx +94 -50
- package/src/editor/components/property-panel/VariableAwareUnitInput.tsx +12 -6
- package/src/editor/constants.ts +24 -0
- package/src/editor/contexts/MultiPageCanvasContext.tsx +108 -6
- package/src/editor/hooks/index.ts +3 -0
- package/src/editor/hooks/useDragResize.ts +30 -5
- package/src/editor/hooks/useElementActions.ts +41 -9
- package/src/editor/hooks/useElementSelection.ts +4 -2
- package/src/editor/hooks/useIframeSetup.ts +8 -1
- package/src/editor/hooks/useInlineTextSelection.ts +16 -0
- package/src/editor/hooks/useKeepArtboardInPlace.ts +88 -0
- package/src/editor/hooks/useLinkNavigation.ts +160 -0
- package/src/editor/parts.ts +2 -0
- package/src/editor/types.ts +4 -0
- package/src/editor/utils/dom-utils.ts +45 -0
- package/src/editor/utils/free-layout-tree.ts +562 -0
- package/src/editor/utils/free-layout.ts +794 -0
- package/src/editor/utils/geometry.ts +41 -3
- package/src/editor/utils/html-utils.ts +2 -1
- package/src/editor/utils/inline-text-style.ts +426 -0
- package/src/editor/utils/link-navigation.ts +474 -0
- package/src/editor/utils/style-utils.ts +24 -0
- package/src/editor/utils/tailwind-utils.ts +12 -0
- package/src/editor/utils/text-line-break.ts +121 -0
- 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 = {
|
|
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
|
-
|
|
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
|
|
1253
|
-
el.
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
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
|
+
}
|