@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
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共同編集の表示用の状態(依存なし)。
|
|
3
|
+
*
|
|
4
|
+
* 接続の実体(runtime.ts: yjs / Hocuspocus / idiomorph)は io.collab があるときだけ動的に読む。
|
|
5
|
+
* ヘッダーのアバター・フレーム名の点・紙面の選択枠は、ここを購読するだけにして
|
|
6
|
+
* 重い依存を持たない(io.collab が無い利用側では何も読まれず、何も描かれない)。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useCallback, useRef, useSyncExternalStore } from 'react';
|
|
10
|
+
|
|
11
|
+
/** error = 部屋名がサーバーに保存されない形(room.ts の検査に外れた)ので繋いでいない */
|
|
12
|
+
export type CollabStatus = 'offline' | 'syncing' | 'synced' | 'error';
|
|
13
|
+
|
|
14
|
+
export type CollabUser = { id: string; name: string; color: string };
|
|
15
|
+
|
|
16
|
+
/** 案件の部屋に居るエディタ(自分以外)。contentId / route はそのエディタが編集中のページ */
|
|
17
|
+
export type CollabPeer = { clientId: number; user: CollabUser; contentId: string | null; route: string | null };
|
|
18
|
+
|
|
19
|
+
/** いまのページの部屋に居るエディタ(自分以外)。selection / editing は anchorValueOf の値、cursor は紙面の px */
|
|
20
|
+
export type CollabPagePeer = {
|
|
21
|
+
clientId: number;
|
|
22
|
+
user: CollabUser;
|
|
23
|
+
selection: string[];
|
|
24
|
+
cursor: { x: number; y: number } | null;
|
|
25
|
+
editing: string | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type CollabSnapshot = {
|
|
29
|
+
/** io.collab があり、接続の実体が動いている */
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
/** 案件の部屋と一度でも同期できた(書き戻し先の数が当てになる) */
|
|
32
|
+
ready: boolean;
|
|
33
|
+
self: CollabUser | null;
|
|
34
|
+
status: CollabStatus;
|
|
35
|
+
peers: CollabPeer[];
|
|
36
|
+
pagePeers: CollabPagePeer[];
|
|
37
|
+
/** 案件の部屋に居る書き戻し役の数 */
|
|
38
|
+
bridges: number;
|
|
39
|
+
requireBridge: boolean;
|
|
40
|
+
/** いま編集中のページの部屋に入っている(このあいだ onSave の自動保存は呼ばない) */
|
|
41
|
+
pageActive: boolean;
|
|
42
|
+
/** 打鍵中の要素に掛かる他人の変更を blur まで保留している */
|
|
43
|
+
pending: boolean;
|
|
44
|
+
/** 中継にまだ届いていない自分の変更の数 */
|
|
45
|
+
unsynced: number;
|
|
46
|
+
canUndo: boolean;
|
|
47
|
+
canRedo: boolean;
|
|
48
|
+
/** 部屋名が保存される形を外れている理由(null = 問題なし)。接続状態にエラーとして出す */
|
|
49
|
+
roomError: string | null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const COLLAB_DISABLED: CollabSnapshot = {
|
|
53
|
+
enabled: false,
|
|
54
|
+
ready: false,
|
|
55
|
+
self: null,
|
|
56
|
+
status: 'offline',
|
|
57
|
+
peers: [],
|
|
58
|
+
pagePeers: [],
|
|
59
|
+
bridges: 0,
|
|
60
|
+
requireBridge: false,
|
|
61
|
+
pageActive: false,
|
|
62
|
+
pending: false,
|
|
63
|
+
unsynced: 0,
|
|
64
|
+
canUndo: false,
|
|
65
|
+
canRedo: false,
|
|
66
|
+
roomError: null,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
let snapshot: CollabSnapshot = COLLAB_DISABLED;
|
|
70
|
+
const listeners = new Set<() => void>();
|
|
71
|
+
|
|
72
|
+
function sameValue(a: unknown, b: unknown): boolean {
|
|
73
|
+
if (a === b) return true;
|
|
74
|
+
if (typeof a !== 'object' || typeof b !== 'object' || !a || !b) return false;
|
|
75
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const collabStore = {
|
|
79
|
+
get: (): CollabSnapshot => snapshot,
|
|
80
|
+
/** 変わったキーがあるときだけ知らせる(カーソルの 50ms ごとの更新で関係ない部品を描き直さない) */
|
|
81
|
+
set(patch: Partial<CollabSnapshot>): void {
|
|
82
|
+
let changed = false;
|
|
83
|
+
for (const key of Object.keys(patch) as (keyof CollabSnapshot)[]) {
|
|
84
|
+
if (!sameValue(snapshot[key], patch[key])) {
|
|
85
|
+
changed = true;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (!changed) return;
|
|
90
|
+
snapshot = { ...snapshot, ...patch };
|
|
91
|
+
listeners.forEach((l) => l());
|
|
92
|
+
},
|
|
93
|
+
reset(): void {
|
|
94
|
+
if (snapshot === COLLAB_DISABLED) return;
|
|
95
|
+
snapshot = COLLAB_DISABLED;
|
|
96
|
+
listeners.forEach((l) => l());
|
|
97
|
+
},
|
|
98
|
+
subscribe(listener: () => void): () => void {
|
|
99
|
+
listeners.add(listener);
|
|
100
|
+
return () => {
|
|
101
|
+
listeners.delete(listener);
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 状態の一部だけを購読する。select はモジュールの関数など、描画ごとに変わらないものを渡す。
|
|
108
|
+
* 値は JSON で比べ、同じなら前の参照を返す(描き直さない)
|
|
109
|
+
*/
|
|
110
|
+
export function useCollabSelector<T>(select: (s: CollabSnapshot) => T): T {
|
|
111
|
+
const cache = useRef<{ snap: CollabSnapshot; value: T; key: string } | null>(null);
|
|
112
|
+
const read = useCallback(() => {
|
|
113
|
+
const snap = collabStore.get();
|
|
114
|
+
const c = cache.current;
|
|
115
|
+
if (c && c.snap === snap) return c.value;
|
|
116
|
+
const value = select(snap);
|
|
117
|
+
const key = JSON.stringify(value);
|
|
118
|
+
if (c && c.key === key) {
|
|
119
|
+
c.snap = snap;
|
|
120
|
+
return c.value;
|
|
121
|
+
}
|
|
122
|
+
cache.current = { snap, value, key };
|
|
123
|
+
return value;
|
|
124
|
+
}, [select]);
|
|
125
|
+
return useSyncExternalStore(collabStore.subscribe, read, read);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** いまのページの部屋に入っているか(このあいだ保存の状態の代わりに接続状態を出し、自動保存を止める) */
|
|
129
|
+
export const selectCollabPageActive = (s: CollabSnapshot): boolean => s.enabled && s.pageActive;
|
|
130
|
+
|
|
131
|
+
/** 中継へまだ送れていない変更があるか(離脱の警告に使う) */
|
|
132
|
+
export const selectCollabUnsynced = (s: CollabSnapshot): boolean => s.enabled && (s.unsynced > 0 || s.status === 'offline');
|
|
133
|
+
|
|
134
|
+
/** 共同編集中の取り消しの状態(Y.UndoManager) */
|
|
135
|
+
export const selectCollabUndo = (s: CollabSnapshot) => ({ canUndo: s.canUndo, canRedo: s.canRedo });
|
|
136
|
+
|
|
137
|
+
let flusher: (() => void) | null = null;
|
|
138
|
+
|
|
139
|
+
/** 編集中の紙面の束縛が「まだ送っていない変更を今すぐ送る」関数を登録する(離れるときは null) */
|
|
140
|
+
export function setCollabFlusher(flush: (() => void) | null): void {
|
|
141
|
+
flusher = flush;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** まだ送っていない変更を今すぐ送る(手動保存・ページ切替の直前)。共同編集していなければ何もしない */
|
|
145
|
+
export function flushCollab(): void {
|
|
146
|
+
flusher?.();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let sharedReader: ((contentId: string) => string | null) | null = null;
|
|
150
|
+
|
|
151
|
+
/** 接続の実体が「そのページの部屋に本文があれば返す」関数を登録する(抜けるときは null) */
|
|
152
|
+
export function setCollabSharedReader(reader: ((contentId: string) => string | null) | null): void {
|
|
153
|
+
sharedReader = reader;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* そのページの部屋に既に同期済みの本文があれば、エディタに渡す HTML(decode 済み)を返す。
|
|
158
|
+
* 共同編集中のページ切替で、ファイル(まだ書き戻されていないことがある)より部屋の本文を優先するために使う
|
|
159
|
+
*/
|
|
160
|
+
export function readCollabSharedHtml(contentId: string): string | null {
|
|
161
|
+
if (!sharedReader) return null;
|
|
162
|
+
try {
|
|
163
|
+
return sharedReader(contentId);
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export type CollabPresence = {
|
|
170
|
+
enabled: boolean;
|
|
171
|
+
status: CollabStatus;
|
|
172
|
+
self: CollabUser | null;
|
|
173
|
+
/** 自分以外のエディタ(同じ人が複数のタブで開いていても 1 人) */
|
|
174
|
+
peers: CollabPeer[];
|
|
175
|
+
bridges: number;
|
|
176
|
+
/** 部屋名が保存される形を外れている理由(status === 'error' のとき) */
|
|
177
|
+
roomError: string | null;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const selectPresence = (s: CollabSnapshot): CollabPresence => ({
|
|
181
|
+
enabled: s.enabled,
|
|
182
|
+
status: s.status,
|
|
183
|
+
self: s.self,
|
|
184
|
+
peers: s.peers,
|
|
185
|
+
bridges: s.bridges,
|
|
186
|
+
roomError: s.roomError,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
/** 誰がどのページに居るか。io.collab が無ければ enabled: false で peers は空 */
|
|
190
|
+
export function useCollabPresence(): CollabPresence {
|
|
191
|
+
return useCollabSelector(selectPresence);
|
|
192
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* エディタ本体(FrontendVisualEditorInner)と共同編集の実体をつなぐ。
|
|
5
|
+
*
|
|
6
|
+
* io.collab が無ければ何もしない(実体を読み込まず、リスナーも張らない)。
|
|
7
|
+
* あれば runtime.ts を動的に読み、
|
|
8
|
+
* - 編集中のページが変わったら部屋を付け替える
|
|
9
|
+
* - 初期化を終えた紙面をページの部屋の本文につなぐ(binding.ts)。履歴の present が変わるたびに送る
|
|
10
|
+
* - 選択(anchorValueOf の値)・カーソル(紙面の px)・打鍵中の要素を awareness に載せる
|
|
11
|
+
* - 他の人が送ったページの見るだけの紙面を、部屋の本文で読み直す(キャンバスのとき)
|
|
12
|
+
* - 他人の選択枠とカーソルを紙面に描く(layer.ts)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
16
|
+
import { io, type EditorCollab } from '../../io';
|
|
17
|
+
import { useEditorContext } from '../EditorContext';
|
|
18
|
+
import { useMultiPageCanvasOptional } from '../contexts/MultiPageCanvasContext';
|
|
19
|
+
import { anchorValueOf } from '../components/ppt/PptComments';
|
|
20
|
+
import { getCleanHtml } from '../utils/html-utils';
|
|
21
|
+
import type { CollabDocBinding, CollabRuntime } from './runtime';
|
|
22
|
+
import { COLLAB_DOCUMENT_READY_ATTR, COLLAB_DOCUMENT_READY_EVENT, type CollabDocumentReadyDetail } from './signals';
|
|
23
|
+
import { COLLAB_DISABLED, collabStore, setCollabFlusher } from './store';
|
|
24
|
+
import { checkCollabRoom } from './room';
|
|
25
|
+
import { colorFor } from './color';
|
|
26
|
+
import { useCollabLayer } from './layer';
|
|
27
|
+
|
|
28
|
+
/** 他の人の rev が続けて届いたとき、読み直しを 1 回にまとめる(ms) */
|
|
29
|
+
const REV_DEBOUNCE_MS = 500;
|
|
30
|
+
/** 部屋に本文が無いとき(書き戻し役の書いたファイルから読み直す)、書き戻しの 1.5 秒を待つ(ms) */
|
|
31
|
+
const REV_FILE_DELAY_MS = 2000;
|
|
32
|
+
/** 見るだけの紙面の本文を部屋から読む同時数 */
|
|
33
|
+
const READ_PARALLEL = 2;
|
|
34
|
+
|
|
35
|
+
export type CollabController = {
|
|
36
|
+
config: EditorCollab;
|
|
37
|
+
/** まだ送っていない変更を今すぐ送る */
|
|
38
|
+
flush: () => void;
|
|
39
|
+
undo: () => void;
|
|
40
|
+
redo: () => void;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type ReadyDocument = CollabDocumentReadyDetail & { initialShared: string };
|
|
44
|
+
|
|
45
|
+
export function useCollab({ contentId, selectedIds }: { contentId: string | null; selectedIds: string[] }): CollabController | null {
|
|
46
|
+
// エディタを開いている間は io.collab を読み直さない(途中で渡し直されても部屋を作り直さない)
|
|
47
|
+
const [config] = useState<EditorCollab | null>(() => io().collab ?? null);
|
|
48
|
+
const { getIframeDoc, html, setHtml, contentList } = useEditorContext();
|
|
49
|
+
const multiPageCanvas = useMultiPageCanvasOptional();
|
|
50
|
+
const [runtime, setRuntime] = useState<CollabRuntime | null>(null);
|
|
51
|
+
const [ready, setReady] = useState<ReadyDocument | null>(null);
|
|
52
|
+
const bindingRef = useRef<CollabDocBinding | null>(null);
|
|
53
|
+
const setHtmlRef = useRef(setHtml);
|
|
54
|
+
setHtmlRef.current = setHtml;
|
|
55
|
+
const canvasRef = useRef(multiPageCanvas);
|
|
56
|
+
canvasRef.current = multiPageCanvas;
|
|
57
|
+
const contentListRef = useRef(contentList);
|
|
58
|
+
contentListRef.current = contentList;
|
|
59
|
+
const contentIdRef = useRef(contentId);
|
|
60
|
+
contentIdRef.current = contentId;
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!config) return;
|
|
64
|
+
// 中継が保存しない部屋名だと、繋がっているのに中身が消える。繋ぐ前に検査して、外れたら始めない
|
|
65
|
+
const room = checkCollabRoom(config.projectRoom);
|
|
66
|
+
if (!room.ok) {
|
|
67
|
+
console.warn(`[collab] 部屋名が中継に保存されない形なので共同編集を始めません: ${room.reason}`);
|
|
68
|
+
collabStore.set({
|
|
69
|
+
enabled: true,
|
|
70
|
+
self: { id: config.user.id, name: config.user.name || config.user.id, color: colorFor(config.user.id, config.user.color) },
|
|
71
|
+
requireBridge: !!config.requireBridge,
|
|
72
|
+
status: 'error',
|
|
73
|
+
roomError: room.reason,
|
|
74
|
+
});
|
|
75
|
+
return () => collabStore.set(COLLAB_DISABLED);
|
|
76
|
+
}
|
|
77
|
+
let alive = true;
|
|
78
|
+
let created: CollabRuntime | null = null;
|
|
79
|
+
import('./runtime').then(
|
|
80
|
+
(m) => {
|
|
81
|
+
if (!alive) return;
|
|
82
|
+
created = m.createCollabRuntime(config);
|
|
83
|
+
setRuntime(created);
|
|
84
|
+
},
|
|
85
|
+
(e) => console.warn('[collab] 共同編集を始められませんでした:', e),
|
|
86
|
+
);
|
|
87
|
+
return () => {
|
|
88
|
+
alive = false;
|
|
89
|
+
created?.destroy();
|
|
90
|
+
};
|
|
91
|
+
}, [config]);
|
|
92
|
+
|
|
93
|
+
// 初期化を終えた文書を受け取り、その時点の姿(種まきに使う)を控える。
|
|
94
|
+
// 先に済んでいた(実体の読み込みより前だった)ときは印から拾う
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!config) return;
|
|
97
|
+
const snapshot = (doc: Document) => {
|
|
98
|
+
try {
|
|
99
|
+
return config.encode ? config.encode(getCleanHtml(doc)) : getCleanHtml(doc);
|
|
100
|
+
} catch {
|
|
101
|
+
return '';
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
const onReady = (e: Event) => {
|
|
105
|
+
const detail = (e as CustomEvent<CollabDocumentReadyDetail>).detail;
|
|
106
|
+
if (detail?.doc) setReady({ doc: detail.doc, contentId: detail.contentId, initialShared: snapshot(detail.doc) });
|
|
107
|
+
};
|
|
108
|
+
window.addEventListener(COLLAB_DOCUMENT_READY_EVENT, onReady);
|
|
109
|
+
const doc = getIframeDoc();
|
|
110
|
+
if (doc?.documentElement?.hasAttribute(COLLAB_DOCUMENT_READY_ATTR)) {
|
|
111
|
+
const readyId = doc.documentElement.getAttribute(COLLAB_DOCUMENT_READY_ATTR) || null;
|
|
112
|
+
setReady((prev) => (prev?.doc === doc ? prev : { doc, contentId: readyId, initialShared: snapshot(doc) }));
|
|
113
|
+
}
|
|
114
|
+
return () => window.removeEventListener(COLLAB_DOCUMENT_READY_EVENT, onReady);
|
|
115
|
+
}, [config, getIframeDoc]);
|
|
116
|
+
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
runtime?.setPage(contentId);
|
|
119
|
+
}, [runtime, contentId]);
|
|
120
|
+
|
|
121
|
+
// いま編集しているページの文書(ページ切替の途中は前の文書を使わない)
|
|
122
|
+
const doc = ready && ready.contentId === contentId ? ready.doc : null;
|
|
123
|
+
|
|
124
|
+
// 紙面 ⇄ ページの部屋の本文
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (!runtime || !ready || !doc || !contentId) return;
|
|
127
|
+
const binding = runtime.bind({
|
|
128
|
+
doc,
|
|
129
|
+
contentId,
|
|
130
|
+
initialShared: ready.initialShared,
|
|
131
|
+
onApplied: (artboardHtml) => setHtmlRef.current(artboardHtml),
|
|
132
|
+
});
|
|
133
|
+
if (!binding) return;
|
|
134
|
+
bindingRef.current = binding;
|
|
135
|
+
setCollabFlusher(() => binding.flush());
|
|
136
|
+
return () => {
|
|
137
|
+
setCollabFlusher(null);
|
|
138
|
+
// 離れるページの見るだけの紙面を、部屋の本文の最新にしておく(共同編集中は保存しないので、保存後の追従が効かない)
|
|
139
|
+
const shared = binding.sharedHtml();
|
|
140
|
+
binding.destroy();
|
|
141
|
+
if (bindingRef.current === binding) bindingRef.current = null;
|
|
142
|
+
if (shared != null) canvasRef.current?.setPageHtml(contentId, shared);
|
|
143
|
+
};
|
|
144
|
+
}, [runtime, ready, doc, contentId]);
|
|
145
|
+
|
|
146
|
+
// 自分の変更: 履歴の present が変わるたび(notifyIframeChange / pushHistory / setHtml)。打鍵は binding が input で拾う
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
bindingRef.current?.scheduleSend();
|
|
149
|
+
}, [html]);
|
|
150
|
+
|
|
151
|
+
// 他の人が本文を送ったページの、見るだけの紙面を読み直す(キャンバスのとき)
|
|
152
|
+
const hasCanvas = !!multiPageCanvas;
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
if (!runtime || !config || !hasCanvas) return;
|
|
155
|
+
let alive = true;
|
|
156
|
+
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
157
|
+
const queue: string[] = [];
|
|
158
|
+
let running = 0;
|
|
159
|
+
const pump = () => {
|
|
160
|
+
while (alive && running < READ_PARALLEL && queue.length > 0) {
|
|
161
|
+
const id = queue.shift()!;
|
|
162
|
+
running += 1;
|
|
163
|
+
void runtime
|
|
164
|
+
.readShared(id)
|
|
165
|
+
.then((shared) => {
|
|
166
|
+
const canvas = canvasRef.current;
|
|
167
|
+
if (!alive || !canvas || id === contentIdRef.current) return;
|
|
168
|
+
if (shared != null) {
|
|
169
|
+
canvas.setPageHtml(id, shared);
|
|
170
|
+
} else if (io().loadContent) {
|
|
171
|
+
// 部屋に本文が無い = 書き戻し役がファイルに書いたものを読む。書き終わるのを待ってから
|
|
172
|
+
timers.set(`file:${id}`, setTimeout(() => canvasRef.current?.updatePageFrame(id, { html: null, stale: true }), REV_FILE_DELAY_MS));
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
.finally(() => {
|
|
176
|
+
running -= 1;
|
|
177
|
+
pump();
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
const off = runtime.onRemoteRev((route) => {
|
|
182
|
+
const id = contentListRef.current.find((c) => config.routeFor(c.id) === route)?.id;
|
|
183
|
+
if (!id || id === contentIdRef.current) return;
|
|
184
|
+
const prev = timers.get(id);
|
|
185
|
+
if (prev) clearTimeout(prev);
|
|
186
|
+
timers.set(
|
|
187
|
+
id,
|
|
188
|
+
setTimeout(() => {
|
|
189
|
+
timers.delete(id);
|
|
190
|
+
if (!queue.includes(id)) queue.push(id);
|
|
191
|
+
pump();
|
|
192
|
+
}, REV_DEBOUNCE_MS),
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
return () => {
|
|
196
|
+
alive = false;
|
|
197
|
+
off();
|
|
198
|
+
timers.forEach((timer) => clearTimeout(timer));
|
|
199
|
+
};
|
|
200
|
+
}, [runtime, config, hasCanvas]);
|
|
201
|
+
|
|
202
|
+
// 選択 → awareness
|
|
203
|
+
const selectionKey = selectedIds.join('\n');
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
if (!runtime) return;
|
|
206
|
+
const artboard = doc?.getElementById('artboard');
|
|
207
|
+
if (!artboard) {
|
|
208
|
+
runtime.setSelection([]);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const anchors: string[] = [];
|
|
212
|
+
for (const id of selectedIds) {
|
|
213
|
+
const el = artboard.querySelector<HTMLElement>(`[data-element-id="${CSS.escape(id)}"]`);
|
|
214
|
+
const anchor = el ? anchorValueOf(artboard, el) : null;
|
|
215
|
+
if (anchor && !anchors.includes(anchor)) anchors.push(anchor);
|
|
216
|
+
}
|
|
217
|
+
runtime.setSelection(anchors);
|
|
218
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
219
|
+
}, [runtime, doc, selectionKey]);
|
|
220
|
+
|
|
221
|
+
// カーソル(紙面の px)と打鍵中の要素 → awareness
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
if (!runtime || !doc) return;
|
|
224
|
+
const artboard = doc.getElementById('artboard');
|
|
225
|
+
if (!artboard) return;
|
|
226
|
+
const onMove = (e: PointerEvent) => {
|
|
227
|
+
const r = artboard.getBoundingClientRect();
|
|
228
|
+
const scale = r.width / (artboard.offsetWidth || 1) || 1;
|
|
229
|
+
runtime.setCursor({ x: Math.round((e.clientX - r.left) / scale), y: Math.round((e.clientY - r.top) / scale) });
|
|
230
|
+
};
|
|
231
|
+
const onLeave = () => runtime.setCursor(null);
|
|
232
|
+
const updateEditing = () => {
|
|
233
|
+
const el = doc.querySelector<HTMLElement>('#artboard [contenteditable="true"]');
|
|
234
|
+
runtime.setEditing(el ? anchorValueOf(artboard, el) : null);
|
|
235
|
+
};
|
|
236
|
+
// focusout の時点ではまだ contenteditable が外れていないので、外れてから読む
|
|
237
|
+
const onFocusOut = () => setTimeout(updateEditing, 0);
|
|
238
|
+
doc.addEventListener('pointermove', onMove, { passive: true });
|
|
239
|
+
doc.documentElement.addEventListener('pointerleave', onLeave);
|
|
240
|
+
doc.addEventListener('focusin', updateEditing);
|
|
241
|
+
doc.addEventListener('focusout', onFocusOut);
|
|
242
|
+
return () => {
|
|
243
|
+
doc.removeEventListener('pointermove', onMove);
|
|
244
|
+
doc.documentElement.removeEventListener('pointerleave', onLeave);
|
|
245
|
+
doc.removeEventListener('focusin', updateEditing);
|
|
246
|
+
doc.removeEventListener('focusout', onFocusOut);
|
|
247
|
+
runtime.setCursor(null);
|
|
248
|
+
runtime.setEditing(null);
|
|
249
|
+
};
|
|
250
|
+
}, [runtime, doc]);
|
|
251
|
+
|
|
252
|
+
useCollabLayer(doc, !!runtime);
|
|
253
|
+
|
|
254
|
+
return useMemo<CollabController | null>(
|
|
255
|
+
() =>
|
|
256
|
+
config
|
|
257
|
+
? {
|
|
258
|
+
config,
|
|
259
|
+
flush: () => bindingRef.current?.flush(),
|
|
260
|
+
undo: () => bindingRef.current?.undo(),
|
|
261
|
+
redo: () => bindingRef.current?.redo(),
|
|
262
|
+
}
|
|
263
|
+
: null,
|
|
264
|
+
[config],
|
|
265
|
+
);
|
|
266
|
+
}
|
|
@@ -11,6 +11,7 @@ import { Sparkles, X, Loader2, Send, Paperclip, FileImage, FileText, Trash2 } fr
|
|
|
11
11
|
import { Button } from '../../components/ui/button';
|
|
12
12
|
import { Textarea } from '../../components/ui/textarea';
|
|
13
13
|
import type { AttachedFile } from '../../lib/agent/slide-agent/types';
|
|
14
|
+
import { readStorage, writeStorage } from '../utils/storage';
|
|
14
15
|
|
|
15
16
|
// サポートするファイルタイプ
|
|
16
17
|
const SUPPORTED_FILE_TYPES = {
|
|
@@ -44,7 +45,7 @@ export function AiPromptPopover({
|
|
|
44
45
|
const [prompt, setPrompt] = useState('');
|
|
45
46
|
// [移植時の追加] 生成に使うAIエンジン(ローカルCLI)を選択する
|
|
46
47
|
const [engine, setEngine] = useState<'codex' | 'claude'>(() => {
|
|
47
|
-
const saved =
|
|
48
|
+
const saved = readStorage('gg-ai-engine');
|
|
48
49
|
return saved === 'claude' ? 'claude' : 'codex';
|
|
49
50
|
});
|
|
50
51
|
const [attachedFiles, setAttachedFiles] = useState<AttachedFile[]>([]);
|
|
@@ -333,7 +334,7 @@ export function AiPromptPopover({
|
|
|
333
334
|
type="button"
|
|
334
335
|
onClick={() => {
|
|
335
336
|
setEngine(e);
|
|
336
|
-
|
|
337
|
+
writeStorage('gg-ai-engine', e);
|
|
337
338
|
}}
|
|
338
339
|
className={`rounded px-1.5 py-0.5 text-[10px] transition-colors ${
|
|
339
340
|
engine === e
|
|
@@ -12,6 +12,7 @@ import { useDragResize } from "../hooks/useDragResize";
|
|
|
12
12
|
import { useContextMenuHandler } from "../hooks/useContextMenuHandler";
|
|
13
13
|
import { useIframeSetup } from "../hooks/useIframeSetup";
|
|
14
14
|
import { useFocusManagement } from "../hooks/useFocusManagement";
|
|
15
|
+
import { useKeepArtboardInPlace } from "../hooks/useKeepArtboardInPlace";
|
|
15
16
|
import {
|
|
16
17
|
SLIDE_WIDTH,
|
|
17
18
|
SLIDE_HEIGHT,
|
|
@@ -27,13 +28,18 @@ import {
|
|
|
27
28
|
} from "../utils/dom-utils";
|
|
28
29
|
import { generateEditableHtml } from "../utils/html-utils";
|
|
29
30
|
import { setupInlineFormatToolbar } from "../utils/inline-format";
|
|
31
|
+
import { setupLineBreakOnEnter } from "../utils/text-line-break";
|
|
32
|
+
import { setupInlineTextSelection } from "../utils/inline-text-style";
|
|
30
33
|
import { recalculateViewportUnits } from "../utils/viewport-utils";
|
|
31
34
|
import { useMultiPageCanvasOptional } from "../contexts/MultiPageCanvasContext";
|
|
32
35
|
import { applyDocumentAttributes } from "./multi-page/PageFramePreview";
|
|
33
36
|
import { setupEmbeddedCanvasBridge } from "../utils/embedded-canvas-bridge";
|
|
34
37
|
import { endContentSwitch } from "../autosave";
|
|
38
|
+
import { io } from "../../io";
|
|
39
|
+
import { COLLAB_DOCUMENT_READY_ATTR, COLLAB_DOCUMENT_READY_EVENT } from "../collab/signals";
|
|
35
40
|
import { showPartDropIndicator, clearPartDropIndicator, findFlowInsertion } from "../utils/drop-target";
|
|
36
41
|
import { debugLog } from '../utils/debug';
|
|
42
|
+
import { useLinkNavigation } from "../hooks/useLinkNavigation";
|
|
37
43
|
import { useEditorComponents } from "../contexts/EditorComponentsContext";
|
|
38
44
|
import type {
|
|
39
45
|
DOMTreeNode,
|
|
@@ -127,6 +133,7 @@ export function EditorCanvas() {
|
|
|
127
133
|
editorMode,
|
|
128
134
|
setShowLayoutHint,
|
|
129
135
|
viewportWidth,
|
|
136
|
+
iframeReady,
|
|
130
137
|
setIframeReady,
|
|
131
138
|
documentAttributes,
|
|
132
139
|
currentContentId,
|
|
@@ -158,6 +165,8 @@ export function EditorCanvas() {
|
|
|
158
165
|
|
|
159
166
|
// Figmaライクなキャンバス操作
|
|
160
167
|
useCanvasControls();
|
|
168
|
+
// 左のパネルを出し入れしても紙面を画面上の同じ位置に留める(1 ページ表示。マルチフレームは MultiPageCanvasView)
|
|
169
|
+
useKeepArtboardInPlace(containerRef, getIframeDoc, !isInMultiPageMode, iframeReady);
|
|
161
170
|
|
|
162
171
|
// グループ化/グループ解除/スタイルコピー&ペースト/Figmaエクスポート
|
|
163
172
|
const { groupElements, ungroupElements, copyStyle, pasteStyle, copyToFigma } = useElementActions();
|
|
@@ -264,6 +273,9 @@ export function EditorCanvas() {
|
|
|
264
273
|
marqueeGeomRef,
|
|
265
274
|
});
|
|
266
275
|
|
|
276
|
+
// ⌘ / Ctrl + クリックでリンク先へ移る(選択の mousedown には手を出さず、クリックが確定してから移る)
|
|
277
|
+
const { setupLinkNavigation } = useLinkNavigation();
|
|
278
|
+
|
|
267
279
|
// マーキー選択
|
|
268
280
|
const {
|
|
269
281
|
marqueeBoxRef: _marqueeBoxRef, // used internally by hook
|
|
@@ -529,8 +541,14 @@ export function EditorCanvas() {
|
|
|
529
541
|
|
|
530
542
|
// 選択リスナー(mousedown, dblclick)
|
|
531
543
|
cleanupFunctions.push(setupSelectionListeners(iframeDoc));
|
|
544
|
+
// ⌘ / Ctrl + クリックでリンク先へ(ページ・アンカー・外部サイト)
|
|
545
|
+
cleanupFunctions.push(setupLinkNavigation(iframeDoc));
|
|
532
546
|
// テキスト編集中の範囲選択に、マーカー・太字のツールバーを出す
|
|
533
547
|
cleanupFunctions.push(setupInlineFormatToolbar(iframeDoc));
|
|
548
|
+
// 範囲選択を覚えておき、パネルで変えた文字の大きさ・太さ・色・字間を選んだ所だけに当てる
|
|
549
|
+
cleanupFunctions.push(setupInlineTextSelection(iframeDoc));
|
|
550
|
+
// テキスト編集中の Enter は段落を分けず、同じ要素の中の改行(<br>)にする
|
|
551
|
+
cleanupFunctions.push(setupLineBreakOnEnter(iframeDoc));
|
|
534
552
|
|
|
535
553
|
// コンテキストメニュー
|
|
536
554
|
cleanupFunctions.push(setupContextMenuListener(iframeDoc));
|
|
@@ -937,6 +955,15 @@ export function EditorCanvas() {
|
|
|
937
955
|
"[Canvas] Webpage mode: content height tracking initialized on artboard"
|
|
938
956
|
);
|
|
939
957
|
}
|
|
958
|
+
// 共同編集(io.collab)に「この文書は読み込みと初期化を終えた」と知らせる。
|
|
959
|
+
// 種まき・他人の変更の当て込みはここから(初期化の途中の姿で撒くと、人によって本文が食い違う)
|
|
960
|
+
if (io().collab) {
|
|
961
|
+
const readyContentId = currentContentIdRef.current ?? "";
|
|
962
|
+
iframeDoc.documentElement.setAttribute(COLLAB_DOCUMENT_READY_ATTR, readyContentId);
|
|
963
|
+
window.dispatchEvent(
|
|
964
|
+
new CustomEvent(COLLAB_DOCUMENT_READY_EVENT, { detail: { doc: iframeDoc, contentId: readyContentId || null } })
|
|
965
|
+
);
|
|
966
|
+
}
|
|
940
967
|
});
|
|
941
968
|
|
|
942
969
|
// 重要: クリーンアップ関数をrefに保存して、
|
|
@@ -40,6 +40,12 @@ import {
|
|
|
40
40
|
BetweenVerticalEnd,
|
|
41
41
|
Rows3,
|
|
42
42
|
Columns3,
|
|
43
|
+
Move,
|
|
44
|
+
AlignVerticalJustifyStart,
|
|
45
|
+
LayoutGrid,
|
|
46
|
+
AlignJustify,
|
|
47
|
+
ListTree,
|
|
48
|
+
ListCollapse,
|
|
43
49
|
} from 'lucide-react';
|
|
44
50
|
|
|
45
51
|
export interface ContextMenuPosition {
|
|
@@ -104,6 +110,21 @@ export interface ContextMenuProps {
|
|
|
104
110
|
// 部品操作
|
|
105
111
|
onDetachPart?: () => void;
|
|
106
112
|
onUpdatePart?: () => void;
|
|
113
|
+
// 自由配置(webpage のみ。渡されたものだけ出す)
|
|
114
|
+
/** 選んだ要素から下の箱を、入れ子まですべて絶対配置にする */
|
|
115
|
+
onFreeLayoutTreeOn?: () => void;
|
|
116
|
+
/** 選んだ要素から下の自由配置を、入れ子まですべて流し込みへ戻す */
|
|
117
|
+
onFreeLayoutTreeOff?: () => void;
|
|
118
|
+
/** 選んだ要素を器にして、直下の子だけを絶対配置にする */
|
|
119
|
+
onFreeLayoutOn?: () => void;
|
|
120
|
+
/** 1 段だけの器を流し込みへ戻す */
|
|
121
|
+
onFreeLayoutOff?: () => void;
|
|
122
|
+
/** ページの最上位セクションをまとめて自由配置にする */
|
|
123
|
+
onPageFreeLayoutOn?: () => void;
|
|
124
|
+
/** ページの自由配置をまとめて解除する */
|
|
125
|
+
onPageFreeLayoutOff?: () => void;
|
|
126
|
+
/** ページのどこかに自由配置の器があるか(解除項目の出し分け) */
|
|
127
|
+
pageHasFreeLayout?: boolean;
|
|
107
128
|
}
|
|
108
129
|
|
|
109
130
|
interface MenuItemProps {
|
|
@@ -191,6 +212,13 @@ export function EditorContextMenu({
|
|
|
191
212
|
onDetachInstance,
|
|
192
213
|
onResetOverrides,
|
|
193
214
|
onPushOverridesToMain,
|
|
215
|
+
onFreeLayoutTreeOn,
|
|
216
|
+
onFreeLayoutTreeOff,
|
|
217
|
+
onFreeLayoutOn,
|
|
218
|
+
onFreeLayoutOff,
|
|
219
|
+
onPageFreeLayoutOn,
|
|
220
|
+
onPageFreeLayoutOff,
|
|
221
|
+
pageHasFreeLayout,
|
|
194
222
|
}: ContextMenuProps) {
|
|
195
223
|
const { restoreFocus } = useEditorContext();
|
|
196
224
|
if (!position) return null;
|
|
@@ -380,6 +408,58 @@ export function EditorContextMenu({
|
|
|
380
408
|
disabled={!hasSelection || selectionCount > 1}
|
|
381
409
|
/>
|
|
382
410
|
|
|
411
|
+
{/* 自由配置(webpage のみ渡される)。
|
|
412
|
+
広い範囲から順に「ツリーごと」→「直下の子だけ」→「ページ全体」と並べ、
|
|
413
|
+
戻す道は同じ粒度の項目と入れ替わりで出す(戻せないと怖くて使えない) */}
|
|
414
|
+
{(onFreeLayoutTreeOn || onFreeLayoutTreeOff || onFreeLayoutOn || onFreeLayoutOff || onPageFreeLayoutOn || onPageFreeLayoutOff) && (
|
|
415
|
+
<>
|
|
416
|
+
<MenuDivider />
|
|
417
|
+
<div className="px-3 pb-0.5 pt-1 text-[10px] text-gray-500">配置</div>
|
|
418
|
+
{onFreeLayoutTreeOn && (
|
|
419
|
+
<MenuItem
|
|
420
|
+
icon={<ListTree className="w-3.5 h-3.5" />}
|
|
421
|
+
label="このツリーをすべて絶対配置にする"
|
|
422
|
+
onClick={() => handleAction(onFreeLayoutTreeOn)}
|
|
423
|
+
/>
|
|
424
|
+
)}
|
|
425
|
+
{onFreeLayoutTreeOff && (
|
|
426
|
+
<MenuItem
|
|
427
|
+
icon={<ListCollapse className="w-3.5 h-3.5" />}
|
|
428
|
+
label="このツリーの絶対配置を解除する"
|
|
429
|
+
onClick={() => handleAction(onFreeLayoutTreeOff)}
|
|
430
|
+
/>
|
|
431
|
+
)}
|
|
432
|
+
{onFreeLayoutOn && (
|
|
433
|
+
<MenuItem
|
|
434
|
+
icon={<Move className="w-3.5 h-3.5" />}
|
|
435
|
+
label="直下の子だけ絶対配置にする"
|
|
436
|
+
onClick={() => handleAction(onFreeLayoutOn)}
|
|
437
|
+
/>
|
|
438
|
+
)}
|
|
439
|
+
{onFreeLayoutOff && (
|
|
440
|
+
<MenuItem
|
|
441
|
+
icon={<AlignVerticalJustifyStart className="w-3.5 h-3.5" />}
|
|
442
|
+
label="流し込みに戻す"
|
|
443
|
+
onClick={() => handleAction(onFreeLayoutOff)}
|
|
444
|
+
/>
|
|
445
|
+
)}
|
|
446
|
+
<MenuItem
|
|
447
|
+
icon={<LayoutGrid className="w-3.5 h-3.5" />}
|
|
448
|
+
label="このページを自由配置にする…"
|
|
449
|
+
onClick={() => handleAction(onPageFreeLayoutOn)}
|
|
450
|
+
disabled={!onPageFreeLayoutOn}
|
|
451
|
+
/>
|
|
452
|
+
{pageHasFreeLayout && (
|
|
453
|
+
<MenuItem
|
|
454
|
+
icon={<AlignJustify className="w-3.5 h-3.5" />}
|
|
455
|
+
label="ページを流し込みに戻す…"
|
|
456
|
+
onClick={() => handleAction(onPageFreeLayoutOff)}
|
|
457
|
+
disabled={!onPageFreeLayoutOff}
|
|
458
|
+
/>
|
|
459
|
+
)}
|
|
460
|
+
</>
|
|
461
|
+
)}
|
|
462
|
+
|
|
383
463
|
<MenuDivider />
|
|
384
464
|
|
|
385
465
|
{/* レイヤー操作 */}
|