@growgroup/visual-editor 0.9.1 → 0.11.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 +90 -1
- package/dist/editor.css +1 -1
- package/package.json +1 -1
- package/src/editor/EditorContext.tsx +5 -1
- package/src/editor/FrontendVisualEditor.tsx +26 -12
- package/src/editor/components/EditorCanvas.tsx +11 -0
- package/src/editor/components/EditorContextMenu.tsx +52 -2
- package/src/editor/components/EditorFooter.tsx +29 -6
- package/src/editor/components/EditorHeader.tsx +15 -2
- package/src/editor/components/EditorLayerPanel.tsx +86 -0
- package/src/editor/components/LayerTreeItem.tsx +107 -3
- package/src/editor/components/canvas-guides/CanvasGuides.tsx +247 -0
- package/src/editor/components/multi-page/CanvasRulers.tsx +3 -0
- package/src/editor/components/multi-page/MultiPageCanvasView.tsx +12 -4
- package/src/editor/components/ppt/PptChrome.tsx +8 -4
- package/src/editor/components/ppt/PptComments.tsx +98 -1
- package/src/editor/components/shell/LeftPanel.tsx +8 -1
- package/src/editor/components/shell/PagesPanel.tsx +7 -1
- package/src/editor/components/shell/useRowsNearViewport.ts +64 -0
- package/src/editor/constants.ts +25 -0
- package/src/editor/contexts/EditorHistoryContext.tsx +6 -1
- package/src/editor/contexts/MultiPageCanvasContext.tsx +106 -19
- package/src/editor/hooks/useBrowserZoomPrevention.ts +3 -3
- package/src/editor/hooks/useCanvasControls.ts +43 -9
- package/src/editor/hooks/useDragResize.ts +205 -17
- package/src/editor/hooks/useElementActions.ts +119 -34
- package/src/editor/hooks/useInfiniteCanvas.ts +29 -5
- package/src/editor/hooks/useKeyboardShortcuts.ts +487 -0
- package/src/editor/hooks/useMarqueeSelection.ts +5 -1
- package/src/editor/utils/align-elements.ts +49 -0
- package/src/editor/utils/canvas-gestures.ts +210 -0
- package/src/editor/utils/canvas-guides.ts +46 -0
- package/src/editor/utils/dom-utils.ts +12 -2
- package/src/editor/utils/embedded-canvas-bridge.ts +6 -11
- package/src/editor/utils/history-timing.ts +9 -0
- package/src/editor/utils/html-utils.ts +4 -0
- package/src/editor/utils/lock.ts +24 -0
- package/src/editor/utils/smart-duplicate.ts +51 -0
- package/src/editor/utils/smart-guides.ts +232 -2
- package/src/hooks/useEditorShortcuts.ts +46 -2
- package/src/index.ts +5 -1
- package/src/io.ts +54 -0
- package/src/styles/skin.css +5 -0
- package/src/types/editor.ts +120 -1
- package/src/version.ts +1 -1
|
@@ -71,6 +71,8 @@ function artboardOffset(el: HTMLElement): { x: number; y: number } {
|
|
|
71
71
|
*/
|
|
72
72
|
function measure(els: HTMLElement[]): Box[] {
|
|
73
73
|
return els.map((el) => {
|
|
74
|
+
// <svg> などは offset* を持たない(undefined → NaN で選択群の枠ごと壊れる)。画面の矩形から測る
|
|
75
|
+
if (typeof el.offsetWidth !== 'number') return measureByRect(el);
|
|
74
76
|
const { x, y } = artboardOffset(el);
|
|
75
77
|
return {
|
|
76
78
|
el,
|
|
@@ -85,6 +87,33 @@ function measure(els: HTMLElement[]): Box[] {
|
|
|
85
87
|
});
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
/**
|
|
91
|
+
* offset* を持たない要素(<svg> など)の実測。アートボードの画面上の矩形と拡縮率から
|
|
92
|
+
* アートボード座標へ直す。原点は computed の left/top から逆算する
|
|
93
|
+
*/
|
|
94
|
+
function measureByRect(el: HTMLElement): Box {
|
|
95
|
+
const doc = el.ownerDocument;
|
|
96
|
+
const artboard = doc.getElementById('artboard');
|
|
97
|
+
const r = el.getBoundingClientRect();
|
|
98
|
+
const a = artboard?.getBoundingClientRect();
|
|
99
|
+
const scale = artboard && a && artboard.offsetWidth ? a.width / artboard.offsetWidth : 1;
|
|
100
|
+
const x = (r.left - (a?.left ?? 0)) / scale;
|
|
101
|
+
const y = (r.top - (a?.top ?? 0)) / scale;
|
|
102
|
+
const cs = doc.defaultView?.getComputedStyle(el);
|
|
103
|
+
const left = parseFloat(cs?.left ?? '') || 0;
|
|
104
|
+
const top = parseFloat(cs?.top ?? '') || 0;
|
|
105
|
+
return {
|
|
106
|
+
el,
|
|
107
|
+
x,
|
|
108
|
+
y,
|
|
109
|
+
width: r.width / scale,
|
|
110
|
+
height: r.height / scale,
|
|
111
|
+
originX: x - left,
|
|
112
|
+
originY: y - top,
|
|
113
|
+
movable: isMovable(el),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
88
117
|
/** アートボード座標で指定した位置へ置く(その要素の原点に直してから書く) */
|
|
89
118
|
function setX(b: Box, x: number): void {
|
|
90
119
|
b.el.style.left = `${Math.round(x - b.originX)}px`;
|
|
@@ -167,3 +196,23 @@ export function distributeElements(els: HTMLElement[], axis: DistributeAxis): HT
|
|
|
167
196
|
}
|
|
168
197
|
return moved;
|
|
169
198
|
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 複数選択の反転(⇧H / ⇧V)の位置の側。選択群の外接枠の中心を軸に、各要素の位置を鏡写しにする
|
|
202
|
+
* (Figma は選択全体を 1 つの塊として反転する)。要素そのものの反転(transform)は呼ぶ側が付ける。
|
|
203
|
+
* 中心で計算するので、回転している要素(回転は中心まわり)でも位置がずれない。
|
|
204
|
+
* 戻り値は位置を動かした要素。動かせない要素(絶対配置でない)は位置を変えない
|
|
205
|
+
*/
|
|
206
|
+
export function mirrorPositions(els: HTMLElement[], axis: 'x' | 'y'): HTMLElement[] {
|
|
207
|
+
const boxes = measure(els);
|
|
208
|
+
if (boxes.length < 2) return [];
|
|
209
|
+
const ref = referenceRect(boxes);
|
|
210
|
+
const moved: HTMLElement[] = [];
|
|
211
|
+
boxes.forEach((b) => {
|
|
212
|
+
if (!b.movable) return;
|
|
213
|
+
if (axis === 'x') setX(b, ref.left + ref.right - (b.x + b.width));
|
|
214
|
+
else setY(b, ref.top + ref.bottom - (b.y + b.height));
|
|
215
|
+
moved.push(b.el);
|
|
216
|
+
});
|
|
217
|
+
return moved;
|
|
218
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* マルチフレームのキャンバスの拡大縮小を Figma にそろえる部品(0.10.0)。
|
|
3
|
+
*
|
|
4
|
+
* - Safari・WKWebView のトラックパッドのピンチは gesturestart / gesturechange / gestureend で届く
|
|
5
|
+
* (Chrome・Edge・Electron は ctrl 付きの wheel で届く)。以前は止めるだけで拡大縮小に使っていなかった。
|
|
6
|
+
* scale は gesturestart からの累積なので、始まりの倍率に掛ける。紙面(iframe)の中で起きたものは親へ転送する
|
|
7
|
+
* - ⌘+ / ⌘− とヘッダーの +/− は 2 の累乗の段(…50% → 100% → 200%…。段の間からは向きの側の段へ)
|
|
8
|
+
* - IME の変換中のキーは奪わない
|
|
9
|
+
* - 選んでいる要素に合わせる(⌘2 / ⇧2)ための矩形
|
|
10
|
+
*
|
|
11
|
+
* DOM と数値だけを扱い、React やキャンバスの Context には触らない(受け手が zoomAt 等を渡す)。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface ZoomRange {
|
|
15
|
+
min: number;
|
|
16
|
+
max: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* マルチフレームのキャンバスの拡大縮小の設定(`canvasZoomOptions`)。どれも省略時は従来の挙動。
|
|
21
|
+
* Figma にそろえるなら `{ min: 0.02, max: 256, steps: 'powers-of-two', fitShortcut: 'selection' }`
|
|
22
|
+
*/
|
|
23
|
+
export interface CanvasZoomOptions {
|
|
24
|
+
/** 倍率の下限(省略時 0.02 = 2%) */
|
|
25
|
+
min?: number;
|
|
26
|
+
/** 倍率の上限(省略時 4 = 400%。Figma は 256 = 25600%) */
|
|
27
|
+
max?: number;
|
|
28
|
+
/** ⌘+ / ⌘− とヘッダーの +/− の刻み。'ratio' = 1.25 倍ずつ(省略時)、'powers-of-two' = 2 の累乗の段(Figma) */
|
|
29
|
+
steps?: 'ratio' | 'powers-of-two';
|
|
30
|
+
/** ⌘2 / ⇧2 の合わせ先。'page' = 編集中のページ(省略時)、'selection' = 選んでいる要素(無ければページ。Figma) */
|
|
31
|
+
fitShortcut?: 'page' | 'selection';
|
|
32
|
+
}
|
|
33
|
+
export type ResolvedCanvasZoomOptions = ZoomRange & Required<Pick<CanvasZoomOptions, 'steps' | 'fitShortcut'>>;
|
|
34
|
+
|
|
35
|
+
const clamp = (zoom: number, range: ZoomRange) => Math.max(range.min, Math.min(range.max, zoom));
|
|
36
|
+
|
|
37
|
+
/** ⌘+ / ⌘− の次の倍率。2 の累乗の段へ寄せる(今が段の上なら 1 段、段の間なら向きの側の段へ) */
|
|
38
|
+
export function nextZoomLevel(current: number, direction: 1 | -1, range: ZoomRange): number {
|
|
39
|
+
if (!Number.isFinite(current) || current <= 0) return clamp(1, range);
|
|
40
|
+
const exponent = Math.log2(current);
|
|
41
|
+
const EPSILON = 1e-6;
|
|
42
|
+
const next = direction > 0 ? Math.floor(exponent + EPSILON) + 1 : Math.ceil(exponent - EPSILON) - 1;
|
|
43
|
+
return clamp(2 ** next, range);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** IME の変換中のキーか(Safari・Chrome は変換中の keydown に keyCode 229 を出す) */
|
|
47
|
+
export function isImeKeyEvent(event: KeyboardEvent): boolean {
|
|
48
|
+
return event.isComposing === true || event.keyCode === 229;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ------------------------------------------------------------------ ピンチ(GestureEvent)
|
|
52
|
+
|
|
53
|
+
/** WebKit の GestureEvent(型定義は標準の lib に無い) */
|
|
54
|
+
export type CanvasGestureEvent = Event & { scale?: number; clientX?: number; clientY?: number };
|
|
55
|
+
export type CanvasGesturePhase = 'start' | 'change' | 'end';
|
|
56
|
+
export interface EmbeddedGestureMessage {
|
|
57
|
+
type: 'EMBEDDED_GESTURE';
|
|
58
|
+
coords: 'parent';
|
|
59
|
+
phase: CanvasGesturePhase;
|
|
60
|
+
scale: number;
|
|
61
|
+
clientX: number;
|
|
62
|
+
clientY: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface GestureZoomTarget {
|
|
66
|
+
getZoom: () => number;
|
|
67
|
+
/** client 座標の点を動かさずに倍率を変える(倍率の範囲は受け手が収める) */
|
|
68
|
+
zoomAt: (zoom: number, clientX: number, clientY: number) => void;
|
|
69
|
+
markInteracting?: () => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** これだけピンチの続きが来なければ、終わったものとみなす(ms) */
|
|
73
|
+
const GESTURE_IDLE_MS = 1000;
|
|
74
|
+
const PHASES: Array<[CanvasGesturePhase, string]> = [['start', 'gesturestart'], ['change', 'gesturechange'], ['end', 'gestureend']];
|
|
75
|
+
const LISTEN = { passive: false, capture: true } as const;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* ピンチの状態。始まりの倍率を覚え、scale(累積)を掛ける。
|
|
79
|
+
* `active` の間は ctrl 付き wheel での拡大をしない(両方届く環境で二重に拡大しない)。
|
|
80
|
+
* end を取りこぼしたまま止まったら GESTURE_IDLE_MS で解く(ctrl 付き wheel の拡大が戻らなくならない)
|
|
81
|
+
*/
|
|
82
|
+
export function createGestureZoom(target: () => GestureZoomTarget | null, now: () => number = () => Date.now()) {
|
|
83
|
+
let base: number | null = null;
|
|
84
|
+
let lastAt = 0;
|
|
85
|
+
const isLive = () => base !== null && now() - lastAt < GESTURE_IDLE_MS;
|
|
86
|
+
return {
|
|
87
|
+
get active() { return isLive(); },
|
|
88
|
+
handle(phase: CanvasGesturePhase, scale: number, clientX: number, clientY: number) {
|
|
89
|
+
const canvas = target();
|
|
90
|
+
if (!canvas) return;
|
|
91
|
+
if (phase === 'start') { base = canvas.getZoom(); lastAt = now(); return; }
|
|
92
|
+
// start を取りこぼしても(iframe の読み込み直し等)、今の倍率を始まりにして続ける
|
|
93
|
+
const start = isLive() && base !== null ? base : canvas.getZoom();
|
|
94
|
+
base = start;
|
|
95
|
+
lastAt = now();
|
|
96
|
+
if (Number.isFinite(scale) && scale > 0) {
|
|
97
|
+
canvas.markInteracting?.();
|
|
98
|
+
canvas.zoomAt(start * scale, clientX, clientY);
|
|
99
|
+
}
|
|
100
|
+
if (phase === 'end') base = null;
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 親(キャンバスの容器がある文書)でピンチを受ける。容器の上で起きたものと、紙面の iframe から転送されてきたもの。
|
|
107
|
+
* 容器の外(ヘッダー・パネル)のピンチは拡大しない(ブラウザ自体の拡大は useBrowserZoomPrevention が止める)。
|
|
108
|
+
* GestureEvent に座標が無い環境では、最後にポインタがあった所を中心にする。
|
|
109
|
+
*/
|
|
110
|
+
export function listenCanvasGestures(input: {
|
|
111
|
+
win: Window;
|
|
112
|
+
container: HTMLElement;
|
|
113
|
+
target: () => GestureZoomTarget | null;
|
|
114
|
+
}): { readonly active: boolean; dispose: () => void } {
|
|
115
|
+
const { win, container } = input;
|
|
116
|
+
const zoom = createGestureZoom(input.target);
|
|
117
|
+
let pointer: { x: number; y: number } | null = null;
|
|
118
|
+
const onPointer = (event: MouseEvent) => { pointer = { x: event.clientX, y: event.clientY }; };
|
|
119
|
+
const pointOf = (event: CanvasGestureEvent) => {
|
|
120
|
+
if (Number.isFinite(event.clientX) && Number.isFinite(event.clientY)) return { x: event.clientX as number, y: event.clientY as number };
|
|
121
|
+
if (pointer) return pointer;
|
|
122
|
+
const rect = container.getBoundingClientRect();
|
|
123
|
+
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
|
124
|
+
};
|
|
125
|
+
const handlers = PHASES.map(([phase, type]) => {
|
|
126
|
+
const handler = (event: Event) => {
|
|
127
|
+
const target = event.target as Node | null;
|
|
128
|
+
if (!target || typeof target.nodeType !== 'number' || !container.contains(target)) return;
|
|
129
|
+
event.preventDefault();
|
|
130
|
+
const gesture = event as CanvasGestureEvent;
|
|
131
|
+
const point = pointOf(gesture);
|
|
132
|
+
zoom.handle(phase, Number(gesture.scale ?? 1), point.x, point.y);
|
|
133
|
+
};
|
|
134
|
+
return [type, handler] as const;
|
|
135
|
+
});
|
|
136
|
+
const onMessage = (event: MessageEvent) => {
|
|
137
|
+
const data = event.data as Partial<EmbeddedGestureMessage> | null;
|
|
138
|
+
if (!data || typeof data !== 'object' || data.type !== 'EMBEDDED_GESTURE' || data.coords !== 'parent') return;
|
|
139
|
+
if (data.phase !== 'start' && data.phase !== 'change' && data.phase !== 'end') return;
|
|
140
|
+
zoom.handle(data.phase, Number(data.scale), Number(data.clientX), Number(data.clientY));
|
|
141
|
+
};
|
|
142
|
+
for (const [type, handler] of handlers) win.addEventListener(type, handler, LISTEN);
|
|
143
|
+
win.addEventListener('mousemove', onPointer, { passive: true });
|
|
144
|
+
win.addEventListener('message', onMessage);
|
|
145
|
+
return {
|
|
146
|
+
get active() { return zoom.active; },
|
|
147
|
+
dispose() {
|
|
148
|
+
for (const [type, handler] of handlers) win.removeEventListener(type, handler, LISTEN);
|
|
149
|
+
win.removeEventListener('mousemove', onPointer);
|
|
150
|
+
win.removeEventListener('message', onMessage);
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 紙面(生きているエディタの iframe)で起きたピンチを親へ転送する。座標は送る前に親の座標へ直す
|
|
157
|
+
* (親の realm で作った関数なので、受け手からは送り元の iframe が分からない。ホイールの転送と同じ)。
|
|
158
|
+
*/
|
|
159
|
+
export function forwardEmbeddedGestures(input: {
|
|
160
|
+
doc: Document;
|
|
161
|
+
toParent: (x: number, y: number) => { clientX: number; clientY: number };
|
|
162
|
+
post: (message: EmbeddedGestureMessage) => void;
|
|
163
|
+
}): () => void {
|
|
164
|
+
const { doc } = input;
|
|
165
|
+
let pointer: { x: number; y: number } | null = null;
|
|
166
|
+
const onPointer = (event: MouseEvent) => { pointer = { x: event.clientX, y: event.clientY }; };
|
|
167
|
+
const handlers = PHASES.map(([phase, type]) => {
|
|
168
|
+
const handler = (event: Event) => {
|
|
169
|
+
const gesture = event as CanvasGestureEvent;
|
|
170
|
+
event.preventDefault();
|
|
171
|
+
event.stopPropagation();
|
|
172
|
+
const hasPoint = Number.isFinite(gesture.clientX) && Number.isFinite(gesture.clientY);
|
|
173
|
+
const local = hasPoint
|
|
174
|
+
? { x: gesture.clientX as number, y: gesture.clientY as number }
|
|
175
|
+
: pointer ?? { x: (doc.defaultView?.innerWidth ?? 0) / 2, y: (doc.defaultView?.innerHeight ?? 0) / 2 };
|
|
176
|
+
input.post({ type: 'EMBEDDED_GESTURE', coords: 'parent', phase, scale: Number(gesture.scale ?? 1), ...input.toParent(local.x, local.y) });
|
|
177
|
+
};
|
|
178
|
+
return [type, handler] as const;
|
|
179
|
+
});
|
|
180
|
+
for (const [type, handler] of handlers) doc.addEventListener(type, handler, LISTEN);
|
|
181
|
+
doc.addEventListener('mousemove', onPointer, { passive: true, capture: true });
|
|
182
|
+
return () => {
|
|
183
|
+
for (const [type, handler] of handlers) doc.removeEventListener(type, handler, LISTEN);
|
|
184
|
+
doc.removeEventListener('mousemove', onPointer, { capture: true });
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ------------------------------------------------------------------ 選択範囲に合わせる
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 紙面で選んでいる要素(#artboard の .selected)を囲む矩形を、キャンバスの座標で返す。無ければ null。
|
|
192
|
+
* 埋め込みの紙面は iframe の中では等倍なので、iframe の中の矩形にフレームの位置を足せばよい。
|
|
193
|
+
*/
|
|
194
|
+
export function selectionCanvasRect(
|
|
195
|
+
iframe: HTMLIFrameElement | null | undefined,
|
|
196
|
+
framePosition: { x: number; y: number } | null | undefined,
|
|
197
|
+
): { minX: number; minY: number; maxX: number; maxY: number } | null {
|
|
198
|
+
const doc = iframe?.contentDocument;
|
|
199
|
+
if (!doc || !framePosition) return null;
|
|
200
|
+
const rects = Array.from(doc.querySelectorAll<HTMLElement>('#artboard .selected'))
|
|
201
|
+
.map((element) => element.getBoundingClientRect())
|
|
202
|
+
.filter((rect) => rect.width > 0 || rect.height > 0);
|
|
203
|
+
if (!rects.length) return null;
|
|
204
|
+
return {
|
|
205
|
+
minX: framePosition.x + Math.min(...rects.map((rect) => rect.left)),
|
|
206
|
+
minY: framePosition.y + Math.min(...rects.map((rect) => rect.top)),
|
|
207
|
+
maxX: framePosition.x + Math.max(...rects.map((rect) => rect.right)),
|
|
208
|
+
maxY: framePosition.y + Math.max(...rects.map((rect) => rect.bottom)),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 定規から引いたガイド(キャンバス表示)の置き場。
|
|
3
|
+
*
|
|
4
|
+
* ページごとに、そのページの紙面の座標(アートボードの px)で持つ。保存はしない(読み込み直すと消える)。
|
|
5
|
+
* 描くのは CanvasGuides、吸着の相手に加えるのは smart-guides の collectGuideCandidates。
|
|
6
|
+
* 両者は別の木(殻とエディタ)にいるので、props ではなくこのモジュールで繋ぐ。
|
|
7
|
+
* 吸着が見るのは「いま定規を出している、編集中のページ」のガイドだけ。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface PageGuides {
|
|
11
|
+
/** 縦のガイドの x(紙面の px) */
|
|
12
|
+
xs: number[];
|
|
13
|
+
/** 横のガイドの y(紙面の px) */
|
|
14
|
+
ys: number[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const EMPTY: PageGuides = { xs: [], ys: [] };
|
|
18
|
+
const byPage = new Map<string, PageGuides>();
|
|
19
|
+
const listeners = new Set<() => void>();
|
|
20
|
+
let activePageId: string | null = null;
|
|
21
|
+
|
|
22
|
+
export function getPageGuides(pageId: string | null): PageGuides {
|
|
23
|
+
return (pageId && byPage.get(pageId)) || EMPTY;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function setPageGuides(pageId: string, guides: PageGuides): void {
|
|
27
|
+
byPage.set(pageId, guides);
|
|
28
|
+
listeners.forEach((fn) => fn());
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 定規を出している編集中のページ。定規を隠したら null(ガイドも見えないので吸着させない) */
|
|
32
|
+
export function setActiveGuidePage(pageId: string | null): void {
|
|
33
|
+
activePageId = pageId;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 吸着の相手にするガイド(編集中のページのもの) */
|
|
37
|
+
export function getActiveCanvasGuides(): PageGuides {
|
|
38
|
+
return getPageGuides(activePageId);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function subscribeGuides(fn: () => void): () => void {
|
|
42
|
+
listeners.add(fn);
|
|
43
|
+
return () => {
|
|
44
|
+
listeners.delete(fn);
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -11,6 +11,7 @@ import { debugLog } from './debug';
|
|
|
11
11
|
// 目印クラスの定義は free-layout.ts 側が正本。ここでは選択枠の札を出すためだけに読む
|
|
12
12
|
// (循環importになるが、使うのは関数の中だけなので評価順の問題は起きない)
|
|
13
13
|
import { FREELAYOUT_CLASS } from './free-layout';
|
|
14
|
+
import { isLockedEl } from './lock';
|
|
14
15
|
|
|
15
16
|
// ========================================
|
|
16
17
|
// buildDomTree キャッシュ(Phase 2 最適化)
|
|
@@ -1225,6 +1226,8 @@ const HANDLE_HIT_SCREEN = 12;
|
|
|
1225
1226
|
* 角丸ハンドル(±6px)の中心が 14px 内側にあれば両者は重ならない。
|
|
1226
1227
|
*/
|
|
1227
1228
|
const RADIUS_MIN_INSET_SCREEN = 14;
|
|
1229
|
+
/** 角丸ハンドルを出す要素の短い辺の下限(画面px)。これ未満では本体の掴み代を優先する */
|
|
1230
|
+
const RADIUS_MIN_SIDE_SCREEN = 56;
|
|
1228
1231
|
|
|
1229
1232
|
/**
|
|
1230
1233
|
* オーバーレイの寸法をズーム非依存にするための倍率を <html> に書き込む
|
|
@@ -1345,8 +1348,13 @@ function updateRadiusHandle(
|
|
|
1345
1348
|
const minInset = RADIUS_MIN_INSET_SCREEN * overlayScale;
|
|
1346
1349
|
const maxInset = Math.min(rect.width, rect.height) / 2;
|
|
1347
1350
|
|
|
1348
|
-
//
|
|
1349
|
-
|
|
1351
|
+
// 下限すら確保できない小さな要素では角丸ハンドルを出さない。
|
|
1352
|
+
// さらに、短い辺が画面上 RADIUS_MIN_SIDE_SCREEN 未満の要素でも出さない。
|
|
1353
|
+
// 40×28px ほどの矢印やアイコンでは角丸ハンドル(当たり12px)が要素の中央帯を覆い、
|
|
1354
|
+
// 選択済みの小さな要素を掴んで動かそうとすると角丸の操作に化けていた(実測)。
|
|
1355
|
+
// Figma も小さな図形では角丸ハンドルを出さない
|
|
1356
|
+
const minSide = Math.min(rect.width, rect.height) / (overlayScale || 1);
|
|
1357
|
+
if (!Number.isFinite(maxInset) || maxInset < minInset || minSide < RADIUS_MIN_SIDE_SCREEN) {
|
|
1350
1358
|
handle?.remove();
|
|
1351
1359
|
return;
|
|
1352
1360
|
}
|
|
@@ -1432,6 +1440,8 @@ function drawSelectionBox(
|
|
|
1432
1440
|
selectionBox.className =
|
|
1433
1441
|
mode === 'member' ? 'selection-box selection-member' : 'selection-box';
|
|
1434
1442
|
selectionBox.setAttribute('data-for-element', elementId);
|
|
1443
|
+
// ロック中(⌘⇧L)の要素は枠だけ出してハンドルを隠す(Figma と同じく紙面では動かせない)
|
|
1444
|
+
if (isLockedEl(element)) selectionBox.classList.add('selection-locked');
|
|
1435
1445
|
// 部品(data-part)のインスタンスは枠を紫にして、名前と版を枠の上に出す(部品だと一目で分かる)。
|
|
1436
1446
|
// 部品の中のスロットを選んでいるときも、どの部品の中かを出す
|
|
1437
1447
|
const partRoot = element.hasAttribute('data-part') ? element : (element.parentElement?.closest('[data-part]') as HTMLElement | null) ?? null;
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* 座標は iframe の座標のまま送る(受け手が送り元 iframe の実測矩形で親の座標へ直す)
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { forwardEmbeddedGestures, isImeKeyEvent } from './canvas-gestures';
|
|
12
|
+
|
|
11
13
|
const ZOOM_KEYS = new Set(['=', '+', '-', '_', ';']);
|
|
12
14
|
|
|
13
15
|
function isTypingTarget(target: EventTarget | null): boolean {
|
|
@@ -50,11 +52,8 @@ export function setupEmbeddedCanvasBridge(iframeDoc: Document, iframe: HTMLIFram
|
|
|
50
52
|
);
|
|
51
53
|
};
|
|
52
54
|
|
|
53
|
-
// ---- Safari の gesture(ピンチ)
|
|
54
|
-
const
|
|
55
|
-
e.preventDefault();
|
|
56
|
-
e.stopPropagation();
|
|
57
|
-
};
|
|
55
|
+
// ---- Safari・WKWebView の gesture(ピンチ)も外へ(親が拡大縮小する。ブラウザの拡大は止める)
|
|
56
|
+
const disposeGestures = forwardEmbeddedGestures({ doc: iframeDoc, toParent, post: (message) => parent.postMessage(message, '*') });
|
|
58
57
|
|
|
59
58
|
// ---- 2 本指タッチ = ピンチ
|
|
60
59
|
let lastDistance: number | null = null;
|
|
@@ -90,6 +89,7 @@ export function setupEmbeddedCanvasBridge(iframeDoc: Document, iframe: HTMLIFram
|
|
|
90
89
|
|
|
91
90
|
// ---- キー: Space(パン)、Cmd+/- (ズーム)、Shift+0/1/2(100%・全体・このページ)、Shift+R(定規)
|
|
92
91
|
const forwardKey = (kind: 'down' | 'up') => (e: KeyboardEvent) => {
|
|
92
|
+
if (isImeKeyEvent(e)) return; // IME の変換中のキーは奪わない
|
|
93
93
|
const cmd = e.metaKey || e.ctrlKey;
|
|
94
94
|
const typing = isTypingTarget(e.target);
|
|
95
95
|
const isSpace = e.code === 'Space';
|
|
@@ -128,9 +128,6 @@ export function setupEmbeddedCanvasBridge(iframeDoc: Document, iframe: HTMLIFram
|
|
|
128
128
|
|
|
129
129
|
const options: AddEventListenerOptions = { passive: false, capture: true };
|
|
130
130
|
iframeDoc.addEventListener('wheel', handleWheel, options);
|
|
131
|
-
iframeDoc.addEventListener('gesturestart', handleGesture, options);
|
|
132
|
-
iframeDoc.addEventListener('gesturechange', handleGesture, options);
|
|
133
|
-
iframeDoc.addEventListener('gestureend', handleGesture, options);
|
|
134
131
|
iframeDoc.addEventListener('touchstart', handleTouchStart, options);
|
|
135
132
|
iframeDoc.addEventListener('touchmove', handleTouchMove, options);
|
|
136
133
|
iframeDoc.addEventListener('touchend', handleTouchEnd, options);
|
|
@@ -140,10 +137,8 @@ export function setupEmbeddedCanvasBridge(iframeDoc: Document, iframe: HTMLIFram
|
|
|
140
137
|
iframeDoc.addEventListener('mousedown', handleMouseDown, options);
|
|
141
138
|
|
|
142
139
|
return () => {
|
|
140
|
+
disposeGestures();
|
|
143
141
|
iframeDoc.removeEventListener('wheel', handleWheel, options);
|
|
144
|
-
iframeDoc.removeEventListener('gesturestart', handleGesture, options);
|
|
145
|
-
iframeDoc.removeEventListener('gesturechange', handleGesture, options);
|
|
146
|
-
iframeDoc.removeEventListener('gestureend', handleGesture, options);
|
|
147
142
|
iframeDoc.removeEventListener('touchstart', handleTouchStart, options);
|
|
148
143
|
iframeDoc.removeEventListener('touchmove', handleTouchMove, options);
|
|
149
144
|
iframeDoc.removeEventListener('touchend', handleTouchEnd, options);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 履歴が「同じ要素への続けての変更」を1つにまとめる間隔(ms)。
|
|
3
|
+
* EditorHistoryContext と、数字キーの不透明度(2桁入力)の両方が使う。
|
|
4
|
+
*
|
|
5
|
+
* EditorHistoryContext.tsx から直接 export しないのは、コンポーネント以外の値を export すると
|
|
6
|
+
* React Fast Refresh がそのファイルを差し替えられず、編集のたびにページ全体が読み込み直される
|
|
7
|
+
* (開いている編集の ⌘Z 履歴が消える)ため。
|
|
8
|
+
*/
|
|
9
|
+
export const COALESCE_WINDOW_MS = 400;
|
|
@@ -371,6 +371,10 @@ export function getCleanHtml(doc: Document): string {
|
|
|
371
371
|
// ドラッグ中の整列ガイド(表示専用)も保存しない
|
|
372
372
|
tempContainer.querySelectorAll('#gg-smart-guides').forEach(el => el.remove());
|
|
373
373
|
|
|
374
|
+
// レイヤーパネルの行に乗せたときの紙面側の強調(表示専用)。
|
|
375
|
+
// 行の目・鍵のボタンを押すと、マウスが行に乗ったまま保存が走るので、ここで外さないと HTML に残る(実測)
|
|
376
|
+
tempContainer.querySelectorAll('[data-layer-hover]').forEach(el => el.removeAttribute('data-layer-hover'));
|
|
377
|
+
|
|
374
378
|
// トリミングモードのUI(枠・ハンドル)。確定/中断で消えるが、保険で除去
|
|
375
379
|
tempContainer.querySelectorAll('.gg-crop-ui').forEach(el => el.remove());
|
|
376
380
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ユーザーのロック(⌘⇧L・レイヤーパネルの鍵)。
|
|
3
|
+
*
|
|
4
|
+
* 属性名は `data-gg-locked`。部品定義の `element.locked` が以前から `data-locked="true"` を
|
|
5
|
+
* 付けている(component-renderer.ts)ので、同じ名前にするとロックの CSS(pointer-events:none)や
|
|
6
|
+
* 鍵の印が部品にまで効いてしまう。`data-gg-*` は部品の定義を作るとき(parts.ts の stripEditorAttrs)に
|
|
7
|
+
* 落ちるので、ロックが部品の定義に持ち込まれることもない。
|
|
8
|
+
*
|
|
9
|
+
* ロック中(本人か祖先がロック)の要素は、選ぶことはできるが(レイヤーパネル・Figma と同じ)、
|
|
10
|
+
* 位置・大きさ・見た目を変える操作の対象からは一律に外す(isLockedEl / withoutLocked)。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const LOCK_ATTR = 'data-gg-locked';
|
|
14
|
+
export const LOCK_SELECTOR = `[${LOCK_ATTR}="true"]`;
|
|
15
|
+
|
|
16
|
+
/** 本人か祖先がロックされているか */
|
|
17
|
+
export function isLockedEl(el: Element): boolean {
|
|
18
|
+
return typeof el.closest === 'function' && !!el.closest(LOCK_SELECTOR);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 編集の対象からロック中の要素を外す */
|
|
22
|
+
export function withoutLocked<T extends Element>(els: T[]): T[] {
|
|
23
|
+
return els.filter((el) => !isLockedEl(el));
|
|
24
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⌘D の「直前のずれを繰り返す」複製(Figma の Duplicate / PowerPoint の Ctrl+D と同じ作法)。
|
|
3
|
+
*
|
|
4
|
+
* Figma(help.figma.com「Copy and paste objects」): ⌘D は元の真上に複製する(最上位フレームを除く)。
|
|
5
|
+
* ⌥ドラッグで複製したあとに ⌘D を続けると、同じ距離で複製を続ける。
|
|
6
|
+
* ⌘D で複製 → 動かす → ⌘D でも、同じずれを繰り返す(PowerPoint の Ctrl+D と同じ)。
|
|
7
|
+
*
|
|
8
|
+
* 「どの要素を、どこから複製したか」を1組だけ覚え、次の ⌘D で選択がその要素のままなら
|
|
9
|
+
* 「今の位置 − 複製元の位置」をずれとして使う。選択が変わっていれば真上(ずれ 0)。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
interface Point {
|
|
13
|
+
left: number;
|
|
14
|
+
top: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let memory: { key: string; from: Point } | null = null;
|
|
18
|
+
|
|
19
|
+
const keyOf = (ids: string[]) => [...ids].sort().join(',');
|
|
20
|
+
|
|
21
|
+
/** 位置指定の要素の left/top(使用値)。フロー要素など読めないときは null */
|
|
22
|
+
export function readPosition(el: HTMLElement): Point | null {
|
|
23
|
+
const view = el.ownerDocument.defaultView;
|
|
24
|
+
if (!view) return null;
|
|
25
|
+
const cs = view.getComputedStyle(el);
|
|
26
|
+
if (cs.position !== 'absolute' && cs.position !== 'fixed') return null;
|
|
27
|
+
const left = parseFloat(cs.left);
|
|
28
|
+
const top = parseFloat(cs.top);
|
|
29
|
+
return Number.isFinite(left) && Number.isFinite(top) ? { left, top } : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 複製した(または ⌥ドラッグで複製を置いた)要素と、その複製元の位置を覚える */
|
|
33
|
+
export function rememberDuplicate(ids: string[], from: Point | null): void {
|
|
34
|
+
memory = ids.length && from ? { key: keyOf(ids), from } : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function forgetDuplicate(): void {
|
|
38
|
+
memory = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 次の ⌘D のずれ。覚えた要素が今も選ばれていて、動いていればそのずれ。それ以外は 0
|
|
43
|
+
* @param ids 今の選択
|
|
44
|
+
* @param first 今の選択の先頭の要素(位置を読む)
|
|
45
|
+
*/
|
|
46
|
+
export function repeatOffsetFor(ids: string[], first: HTMLElement | null): { dx: number; dy: number } {
|
|
47
|
+
if (!memory || !first || memory.key !== keyOf(ids)) return { dx: 0, dy: 0 };
|
|
48
|
+
const now = readPosition(first);
|
|
49
|
+
if (!now) return { dx: 0, dy: 0 };
|
|
50
|
+
return { dx: now.left - memory.from.left, dy: now.top - memory.from.top };
|
|
51
|
+
}
|