@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.
- package/README.md +92 -0
- package/dist/editor.css +1 -1
- package/package.json +7 -2
- package/src/editor/FrontendVisualEditor.tsx +244 -17
- package/src/editor/collab/CollabPresence.tsx +172 -0
- package/src/editor/collab/binding.ts +269 -0
- package/src/editor/collab/color.ts +28 -0
- package/src/editor/collab/connection.ts +110 -0
- package/src/editor/collab/layer.ts +168 -0
- package/src/editor/collab/merge.ts +112 -0
- package/src/editor/collab/morph.ts +113 -0
- package/src/editor/collab/room.ts +82 -0
- package/src/editor/collab/runtime.ts +442 -0
- package/src/editor/collab/signals.ts +11 -0
- package/src/editor/collab/store.ts +192 -0
- package/src/editor/collab/useCollab.ts +266 -0
- package/src/editor/components/AiPromptPopover.tsx +3 -2
- package/src/editor/components/EditorCanvas.tsx +27 -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 +41 -19
- package/src/editor/components/ppt/PptChrome.tsx +46 -4
- package/src/editor/components/ppt/PptComments.tsx +3 -8
- package/src/editor/components/property-panel/VariableAwareSizeInput.tsx +94 -50
- package/src/editor/components/property-panel/VariableAwareUnitInput.tsx +12 -6
- package/src/editor/components/shell/EditorTopBar.tsx +13 -1
- package/src/editor/components/shell/PagesPanel.tsx +3 -0
- package/src/editor/constants.ts +24 -0
- package/src/editor/contexts/EditorAppearanceContext.tsx +5 -5
- package/src/editor/contexts/EditorHistoryContext.tsx +2 -0
- package/src/editor/contexts/MultiPageCanvasContext.tsx +120 -15
- 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/useGoogleFonts.ts +6 -8
- package/src/editor/hooks/useIframeSetup.ts +10 -1
- package/src/editor/hooks/useInlineTextSelection.ts +16 -0
- package/src/editor/hooks/useKeepArtboardInPlace.ts +87 -0
- package/src/editor/hooks/useLinkNavigation.ts +160 -0
- package/src/editor/hooks/useResizablePanel.ts +3 -2
- package/src/editor/parts.ts +2 -0
- package/src/editor/types.ts +4 -0
- package/src/editor/utils/debug.ts +2 -5
- package/src/editor/utils/dom-utils.ts +45 -0
- package/src/editor/utils/drop-target.ts +1 -1
- package/src/editor/utils/flex-reorder.ts +1 -1
- 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 +4 -1
- package/src/editor/utils/inline-text-style.ts +426 -0
- package/src/editor/utils/link-navigation.ts +474 -0
- package/src/editor/utils/restore-flow.ts +1 -1
- package/src/editor/utils/smart-guides.ts +1 -1
- package/src/editor/utils/storage.ts +47 -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/index.ts +15 -0
- package/src/io.ts +29 -0
- package/src/styles/skin.css +17 -0
- package/src/version.ts +1 -1
package/src/editor/constants.ts
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
31
|
+
writeStorage(THEME_KEY, next);
|
|
32
32
|
setTheme(next);
|
|
33
33
|
};
|
|
34
34
|
return { theme, toggleTheme };
|
|
@@ -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 = {
|
|
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
|
|
535
|
+
if (!key) return null;
|
|
508
536
|
try {
|
|
509
|
-
const raw =
|
|
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
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
|
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;
|
|
@@ -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 =
|
|
144
|
-
const cacheExpiry =
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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)。
|