@growgroup/visual-editor 0.10.0 → 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 +24 -0
- package/dist/editor.css +1 -1
- package/package.json +1 -1
- package/src/editor/FrontendVisualEditor.tsx +18 -12
- package/src/editor/components/EditorContextMenu.tsx +52 -2
- package/src/editor/components/EditorFooter.tsx +29 -6
- 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/ppt/PptChrome.tsx +8 -4
- 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 +16 -0
- package/src/editor/contexts/EditorHistoryContext.tsx +6 -1
- package/src/editor/contexts/MultiPageCanvasContext.tsx +49 -11
- package/src/editor/hooks/useCanvasControls.ts +41 -4
- package/src/editor/hooks/useDragResize.ts +205 -17
- package/src/editor/hooks/useElementActions.ts +119 -34
- 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-guides.ts +46 -0
- package/src/editor/utils/dom-utils.ts +12 -2
- 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/styles/skin.css +5 -0
- package/src/types/editor.ts +120 -1
- package/src/version.ts +1 -1
|
@@ -17,6 +17,13 @@
|
|
|
17
17
|
|
|
18
18
|
import { useCallback } from 'react';
|
|
19
19
|
import { refreshSelectionOverlay, getArtboardContent } from '../utils/dom-utils';
|
|
20
|
+
import { useEditorContext } from '../EditorContext';
|
|
21
|
+
import { COALESCE_WINDOW_MS } from '../utils/history-timing';
|
|
22
|
+
import { alignElements, distributeElements, mirrorPositions, type AlignMode, type DistributeAxis } from '../utils/align-elements';
|
|
23
|
+
import { applyTailwindStyles } from '../utils/tailwind-utils';
|
|
24
|
+
import { extractElementInfo } from '../utils/style-utils';
|
|
25
|
+
import type { ShortcutCallbacks } from '../../types/editor';
|
|
26
|
+
import { LOCK_ATTR, isLockedEl, withoutLocked } from '../utils/lock';
|
|
20
27
|
|
|
21
28
|
interface UseKeyboardShortcutsOptions {
|
|
22
29
|
/** グループ化関数 */
|
|
@@ -91,6 +98,8 @@ export function selectAllSiblingsIn(
|
|
|
91
98
|
return !ancestor || !artboard.contains(ancestor);
|
|
92
99
|
});
|
|
93
100
|
}
|
|
101
|
+
// ロック中の要素は紙面からは選ばない(⌘A。Figma の Select all もロック中を飛ばす。記憶)
|
|
102
|
+
siblings = siblings.filter((el) => !isLockedEl(el));
|
|
94
103
|
if (siblings.length === 0) return { ids: [], elements: [] };
|
|
95
104
|
|
|
96
105
|
iframeDoc.querySelectorAll('.selected').forEach((el) => el.classList.remove('selected'));
|
|
@@ -152,6 +161,8 @@ export function selectSiblingIn(
|
|
|
152
161
|
return !ancestor || !artboard.contains(ancestor);
|
|
153
162
|
});
|
|
154
163
|
}
|
|
164
|
+
// Tab の巡回もロック中の要素を飛ばす
|
|
165
|
+
siblings = siblings.filter((el) => !isLockedEl(el));
|
|
155
166
|
if (siblings.length === 0) return null;
|
|
156
167
|
|
|
157
168
|
const index = current ? siblings.indexOf(current) : -1;
|
|
@@ -214,3 +225,479 @@ export function useKeyboardShortcuts(
|
|
|
214
225
|
|
|
215
226
|
return { setupKeyboardShortcuts };
|
|
216
227
|
}
|
|
228
|
+
|
|
229
|
+
// ============================================================
|
|
230
|
+
// Figma の作法に合わせたキャンバス操作(Enter / Shift+Enter / 整列 / 表示・ロック / 不透明度 / 書式)
|
|
231
|
+
// ============================================================
|
|
232
|
+
|
|
233
|
+
/** 選択枠などオーバーレイの中の要素は「紙面の要素」として数えない */
|
|
234
|
+
function isOverlayNode(el: Element): boolean {
|
|
235
|
+
return !!el.closest('.selection-box, #gg-smart-guides, #gg-measure-layer');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* el の「1段内側」の要素。編集できない包み(レイアウト用の div 等)は透過して、
|
|
240
|
+
* 枝ごとに最初に当たる編集可能な要素を拾う(Figma のレイヤーパネルの1段下と同じ見え方)
|
|
241
|
+
*/
|
|
242
|
+
export function editableChildrenOf(el: HTMLElement): HTMLElement[] {
|
|
243
|
+
const out: HTMLElement[] = [];
|
|
244
|
+
const walk = (node: Element) => {
|
|
245
|
+
for (const child of Array.from(node.children)) {
|
|
246
|
+
if (isOverlayNode(child)) continue;
|
|
247
|
+
if (child.getAttribute('data-editable') === 'true' && child.getAttribute('data-element-id')) {
|
|
248
|
+
out.push(child as HTMLElement);
|
|
249
|
+
} else {
|
|
250
|
+
walk(child);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
walk(el);
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** el の1段外側の編集可能な要素。紙面(#artboard)の外へは出ない */
|
|
259
|
+
export function editableParentOf(el: HTMLElement): HTMLElement | null {
|
|
260
|
+
const artboard = el.ownerDocument.getElementById('artboard');
|
|
261
|
+
const parent = el.parentElement?.closest<HTMLElement>('[data-editable="true"][data-element-id]') ?? null;
|
|
262
|
+
if (!parent || !artboard || parent === artboard || !artboard.contains(parent)) return null;
|
|
263
|
+
// 紙面いっぱいのルート(スライドの面)は「親」として選ばない。
|
|
264
|
+
// 選べても全体が青くなるだけで、最上位の要素から Esc で抜けられなくなる
|
|
265
|
+
if (
|
|
266
|
+
parent.parentElement === artboard &&
|
|
267
|
+
parent.offsetWidth >= artboard.offsetWidth &&
|
|
268
|
+
parent.offsetHeight >= artboard.offsetHeight
|
|
269
|
+
) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
return parent;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 文字を直に持つ要素か(Enter で「子を選ぶ」ではなく「文字の編集に入る」対象)。
|
|
277
|
+
* <p>本文<span>強調</span></p> のような段落は、中の span を子として選ぶより
|
|
278
|
+
* 文字の編集に入るほうが Figma のテキストレイヤーの感覚に近い
|
|
279
|
+
*/
|
|
280
|
+
function hasOwnText(el: HTMLElement): boolean {
|
|
281
|
+
return Array.from(el.childNodes).some(
|
|
282
|
+
(n) => n.nodeType === 3 && (n.textContent ?? '').trim().length > 0,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function selectedEls(doc: Document, ids: string[]): HTMLElement[] {
|
|
287
|
+
return ids
|
|
288
|
+
.map((id) => doc.querySelector<HTMLElement>(`[data-element-id="${id}"]`))
|
|
289
|
+
.filter((el): el is HTMLElement => !!el && !isOverlayNode(el));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** DOM の選択表示を els に揃える(クラスと枠)。React 側の状態は呼び出し側で合わせる */
|
|
293
|
+
function markSelected(doc: Document, els: HTMLElement[]): void {
|
|
294
|
+
doc.querySelectorAll('.selected').forEach((el) => el.classList.remove('selected'));
|
|
295
|
+
els.forEach((el) => el.classList.add('selected'));
|
|
296
|
+
refreshSelectionOverlay(doc);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** 文字の編集に入る。既存のダブルクリック処理(選択済み+文字要素→編集)をそのまま通す */
|
|
300
|
+
function startTextEditingViaDblClick(el: HTMLElement): void {
|
|
301
|
+
const view = el.ownerDocument.defaultView;
|
|
302
|
+
if (!view) return;
|
|
303
|
+
const r = el.getBoundingClientRect();
|
|
304
|
+
el.dispatchEvent(
|
|
305
|
+
new view.MouseEvent('dblclick', {
|
|
306
|
+
bubbles: true,
|
|
307
|
+
cancelable: true,
|
|
308
|
+
clientX: r.left + Math.min(8, r.width / 2),
|
|
309
|
+
clientY: r.top + r.height / 2,
|
|
310
|
+
detail: 2,
|
|
311
|
+
}),
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* 数字を2つ続けて押したとみなす間隔(ms)。Figma の「4→5 で 45%」(記憶にもとづく・未確認)。
|
|
317
|
+
* 履歴が同じ要素への続けての変更を1つにまとめる間隔と揃える。こうすると2打目が1打目の履歴を
|
|
318
|
+
* 置き換え、⌘Z 1回で「押す前」に戻る(間隔がずれると1打目だけが別の履歴として残る)
|
|
319
|
+
*/
|
|
320
|
+
const OPACITY_TWO_DIGIT_MS = COALESCE_WINDOW_MS;
|
|
321
|
+
let lastOpacityDigit: { digit: number; at: number } | null = null;
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* transform の中の scaleX(-1) / scaleY(-1) を付け外しする。rotate など他の関数は順序ごと残す。
|
|
325
|
+
* 回転のドラッグ(useDragResize の setTransformFn)は scaleX / scaleY を消さないので両立する
|
|
326
|
+
*/
|
|
327
|
+
export function toggleFlipIn(transform: string, axis: 'x' | 'y'): string {
|
|
328
|
+
const fn = axis === 'x' ? 'scaleX(-1)' : 'scaleY(-1)';
|
|
329
|
+
const pattern = axis === 'x' ? /\s*scaleX\(-1\)/g : /\s*scaleY\(-1\)/g;
|
|
330
|
+
// 回転している要素は、画面の軸で鏡写しにする(Figma と同じ見え方)。
|
|
331
|
+
// 鏡写し × rotate(a) = rotate(-a) × 鏡写し なので、角度の符号を反転してから反転を付け外しする
|
|
332
|
+
const mirrored = transform.replace(/rotate\((-?[\d.]+)deg\)/, (_, deg: string) => {
|
|
333
|
+
const next = -Number(deg);
|
|
334
|
+
return `rotate(${Object.is(next, -0) ? 0 : next}deg)`;
|
|
335
|
+
});
|
|
336
|
+
const cleaned = mirrored.replace(pattern, '').trim();
|
|
337
|
+
if (cleaned !== mirrored.trim()) return cleaned;
|
|
338
|
+
return cleaned ? `${cleaned} ${fn}` : fn;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** ⌘R でレイヤー名の入力を開く合図。レイヤーパネル(と左パネルのタブ)が受け取る */
|
|
342
|
+
export const RENAME_LAYER_EVENT = 'gg:rename-layer';
|
|
343
|
+
|
|
344
|
+
/** ⌘\ で UI を隠すときにエディタの外枠に付けるクラス(skin.css が見る) */
|
|
345
|
+
const UI_HIDDEN_CLASS = 'gg-ui-hidden';
|
|
346
|
+
|
|
347
|
+
/** 文字を直に持つ要素(テキストノードの子がある要素)。⌘⇧> / ⌘⇧< の対象 */
|
|
348
|
+
function textBearers(root: HTMLElement): HTMLElement[] {
|
|
349
|
+
const all = [root, ...Array.from(root.querySelectorAll<HTMLElement>('*'))];
|
|
350
|
+
return all.filter(
|
|
351
|
+
(el) =>
|
|
352
|
+
!el.closest('.selection-box') &&
|
|
353
|
+
Array.from(el.childNodes).some((n) => n.nodeType === 3 && (n.textContent ?? '').trim()),
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* ⌘\: エディタの外枠(.gg-editor-ui)にクラスを付け外しして、上の帯・左右のパネル・下の帯・道具箱を隠す。
|
|
359
|
+
* 紙面(キャンバス)だけが残る。もう一度 ⌘\ で戻る(Figma の Hide/Show UI)
|
|
360
|
+
*/
|
|
361
|
+
function toggleEditorUi(): void {
|
|
362
|
+
const shell = document.querySelector('.gg-editor-ui');
|
|
363
|
+
if (!shell) return;
|
|
364
|
+
shell.classList.toggle(UI_HIDDEN_CLASS);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** ⌘⇧O のアウトライン表示。iframe の <html> にクラスを付ける(保存は #artboard の中身だけなので本文には残らない) */
|
|
368
|
+
const OUTLINE_CLASS = 'gg-outline-mode';
|
|
369
|
+
const OUTLINE_STYLE_ID = 'gg-outline-mode-style';
|
|
370
|
+
/**
|
|
371
|
+
* 輪郭表示でも消さないエディタの飾り(選択枠・ハンドル・寸法・吸着ガイド・計測など)。
|
|
372
|
+
* #artboard の中に描かれているので、除外しないと一緒に透明になって選択枠や寸法が見えなくなる
|
|
373
|
+
*/
|
|
374
|
+
const OUTLINE_KEEP =
|
|
375
|
+
'.selection-box,.marquee-selection-box,.resize-handle,.rotation-handle,.size-label,.element-breadcrumb,' +
|
|
376
|
+
'#gg-smart-guides,#gg-measure-layer,.gg-comment-layer,.gg-collab-layer,.gg-crop-ui';
|
|
377
|
+
const OUTLINE_TARGET = `html.${OUTLINE_CLASS} #artboard *:not(:is(${OUTLINE_KEEP})):not(:is(${OUTLINE_KEEP}) *)`;
|
|
378
|
+
const OUTLINE_CSS = `
|
|
379
|
+
html.${OUTLINE_CLASS} #artboard {
|
|
380
|
+
background: #fff !important;
|
|
381
|
+
}
|
|
382
|
+
${OUTLINE_TARGET} {
|
|
383
|
+
background: transparent !important;
|
|
384
|
+
box-shadow: none !important;
|
|
385
|
+
border-color: transparent !important;
|
|
386
|
+
outline: 1px solid rgba(0, 0, 0, 0.55) !important;
|
|
387
|
+
outline-offset: -1px;
|
|
388
|
+
-webkit-text-fill-color: transparent !important;
|
|
389
|
+
-webkit-text-stroke: 0.8px #222 !important;
|
|
390
|
+
}
|
|
391
|
+
${OUTLINE_TARGET}:is(img, video), ${OUTLINE_TARGET}:is(svg *) {
|
|
392
|
+
opacity: 0 !important;
|
|
393
|
+
}
|
|
394
|
+
`;
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* ⌘⇧O: 紙面の塗り・画像を消して、各要素の枠と文字の輪郭だけを見せる(Figma の Show outlines)。
|
|
398
|
+
* 重なって見えない要素や、透明な器の大きさを確かめるのに使う。もう一度押すと戻る
|
|
399
|
+
*/
|
|
400
|
+
function toggleOutlineIn(doc: Document): void {
|
|
401
|
+
if (!doc.getElementById(OUTLINE_STYLE_ID)) {
|
|
402
|
+
const style = doc.createElement('style');
|
|
403
|
+
style.id = OUTLINE_STYLE_ID;
|
|
404
|
+
style.textContent = OUTLINE_CSS;
|
|
405
|
+
doc.head.appendChild(style);
|
|
406
|
+
}
|
|
407
|
+
doc.documentElement.classList.toggle(OUTLINE_CLASS);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const OPACITY_KEYS = ['opacity1', 'opacity2', 'opacity3', 'opacity4', 'opacity5', 'opacity6', 'opacity7', 'opacity8', 'opacity9', 'opacity0'] as const;
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Figma 準拠のキャンバス操作をショートカットのコールバック表として返す。
|
|
414
|
+
* FrontendVisualEditor の shortcutCallbacks に展開して使う。
|
|
415
|
+
* 選択が無いときは undefined を返し、キーをブラウザ既定に通す(何も奪わない)。
|
|
416
|
+
*/
|
|
417
|
+
export function useCanvasShortcutActions(): ShortcutCallbacks {
|
|
418
|
+
const {
|
|
419
|
+
getIframeDoc,
|
|
420
|
+
selectedElement,
|
|
421
|
+
selectedElementIds,
|
|
422
|
+
setSelectedElement,
|
|
423
|
+
setSelectedElementIds,
|
|
424
|
+
notifyIframeChange,
|
|
425
|
+
restoreFocus,
|
|
426
|
+
} = useEditorContext();
|
|
427
|
+
|
|
428
|
+
const ids = selectedElementIds.length ? selectedElementIds : selectedElement ? [selectedElement.id] : [];
|
|
429
|
+
const hasSelection = ids.length > 0;
|
|
430
|
+
|
|
431
|
+
/** DOM の選択を React 側へ反映する(右パネル・レイヤーパネルが追従する) */
|
|
432
|
+
const commit = useCallback(
|
|
433
|
+
(doc: Document, els: HTMLElement[]) => {
|
|
434
|
+
markSelected(doc, els);
|
|
435
|
+
setSelectedElementIds(els.map((el) => el.getAttribute('data-element-id') || '').filter(Boolean));
|
|
436
|
+
const info = els[0] ? extractElementInfo(els[0], doc) : null;
|
|
437
|
+
setSelectedElement(info ?? null);
|
|
438
|
+
restoreFocus();
|
|
439
|
+
},
|
|
440
|
+
[setSelectedElementIds, setSelectedElement, restoreFocus],
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
/** 書き換えたあとの後始末(履歴へ積み、枠と右パネルを描き直す) */
|
|
444
|
+
const afterChange = useCallback(
|
|
445
|
+
(doc: Document, changed: HTMLElement[]) => {
|
|
446
|
+
if (!changed.length) return;
|
|
447
|
+
notifyIframeChange();
|
|
448
|
+
requestAnimationFrame(() => refreshSelectionOverlay(doc));
|
|
449
|
+
const first = selectedEls(doc, ids)[0];
|
|
450
|
+
const info = first ? extractElementInfo(first, doc) : null;
|
|
451
|
+
if (info) setSelectedElement(info);
|
|
452
|
+
},
|
|
453
|
+
// ids は毎レンダー作り直すので文字列で比べる
|
|
454
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
455
|
+
[notifyIframeChange, setSelectedElement, ids.join(',')],
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
/** Enter: 子を選ぶ。文字を直に持つ要素なら文字の編集に入る */
|
|
459
|
+
const selectChildren = useCallback(() => {
|
|
460
|
+
const doc = getIframeDoc();
|
|
461
|
+
if (!doc) return;
|
|
462
|
+
const els = selectedEls(doc, ids);
|
|
463
|
+
if (els.length === 1 && hasOwnText(els[0])) {
|
|
464
|
+
startTextEditingViaDblClick(els[0]);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const children = els.flatMap(editableChildrenOf);
|
|
468
|
+
if (children.length === 0) {
|
|
469
|
+
// 子が無い葉(画像・図形・文字だけの要素)。文字を持つなら編集に入る
|
|
470
|
+
if (els.length === 1 && (els[0].textContent ?? '').trim()) startTextEditingViaDblClick(els[0]);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
commit(doc, children);
|
|
474
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
475
|
+
}, [getIframeDoc, commit, ids.join(',')]);
|
|
476
|
+
|
|
477
|
+
/** Shift+Enter: 親を選ぶ(複数選択なら各要素の親をまとめて)。最上位では何もしない */
|
|
478
|
+
const selectParent = useCallback(() => {
|
|
479
|
+
const doc = getIframeDoc();
|
|
480
|
+
if (!doc) return;
|
|
481
|
+
const parents = Array.from(
|
|
482
|
+
new Set(selectedEls(doc, ids).map(editableParentOf).filter((p): p is HTMLElement => !!p)),
|
|
483
|
+
);
|
|
484
|
+
if (parents.length === 0) return;
|
|
485
|
+
commit(doc, parents);
|
|
486
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
487
|
+
}, [getIframeDoc, commit, ids.join(',')]);
|
|
488
|
+
|
|
489
|
+
const runAlign = useCallback(
|
|
490
|
+
(mode: AlignMode) => {
|
|
491
|
+
const doc = getIframeDoc();
|
|
492
|
+
if (!doc) return;
|
|
493
|
+
afterChange(doc, alignElements(withoutLocked(selectedEls(doc, ids)), mode));
|
|
494
|
+
},
|
|
495
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
496
|
+
[getIframeDoc, afterChange, ids.join(',')],
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
const runDistribute = useCallback(
|
|
500
|
+
(axis: DistributeAxis) => {
|
|
501
|
+
const doc = getIframeDoc();
|
|
502
|
+
if (!doc) return;
|
|
503
|
+
afterChange(doc, distributeElements(withoutLocked(selectedEls(doc, ids)), axis));
|
|
504
|
+
},
|
|
505
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
506
|
+
[getIframeDoc, afterChange, ids.join(',')],
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* ⌘⇧H: 隠す/表示する。書き方はレイヤーパネルの目のアイコンと同じ
|
|
511
|
+
* (visibility:hidden + opacity:0)にそろえる。違う書き方にすると、
|
|
512
|
+
* 片方で隠したものがもう片方で「表示中」に見える
|
|
513
|
+
*/
|
|
514
|
+
const toggleHidden = useCallback(() => {
|
|
515
|
+
const doc = getIframeDoc();
|
|
516
|
+
if (!doc) return;
|
|
517
|
+
const els = selectedEls(doc, ids);
|
|
518
|
+
const allHidden = els.length > 0 && els.every((el) => el.style.visibility === 'hidden');
|
|
519
|
+
els.forEach((el) => {
|
|
520
|
+
el.style.visibility = allHidden ? '' : 'hidden';
|
|
521
|
+
el.style.opacity = allHidden ? '' : '0';
|
|
522
|
+
});
|
|
523
|
+
afterChange(doc, els);
|
|
524
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
525
|
+
}, [getIframeDoc, afterChange, ids.join(',')]);
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* ⌘⇧L: ロック/解除。data-gg-locked="true" を付けると、紙面のクリックが下の要素へ
|
|
529
|
+
* 素通りする(constants.ts の EDITOR_STYLES)。選択はレイヤーパネルから行える(Figma と同じ)
|
|
530
|
+
*/
|
|
531
|
+
const toggleLocked = useCallback(() => {
|
|
532
|
+
const doc = getIframeDoc();
|
|
533
|
+
if (!doc) return;
|
|
534
|
+
const els = selectedEls(doc, ids);
|
|
535
|
+
const allLocked = els.length > 0 && els.every((el) => el.getAttribute(LOCK_ATTR) === 'true');
|
|
536
|
+
els.forEach((el) => {
|
|
537
|
+
if (allLocked) el.removeAttribute(LOCK_ATTR);
|
|
538
|
+
else el.setAttribute(LOCK_ATTR, 'true');
|
|
539
|
+
});
|
|
540
|
+
afterChange(doc, els);
|
|
541
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
542
|
+
}, [getIframeDoc, afterChange, ids.join(',')]);
|
|
543
|
+
|
|
544
|
+
/** 数字キー: 不透明度(1=10% … 9=90%, 0=100%) */
|
|
545
|
+
const setOpacity = useCallback(
|
|
546
|
+
(digit: number) => {
|
|
547
|
+
const doc = getIframeDoc();
|
|
548
|
+
if (!doc) return;
|
|
549
|
+
const els = withoutLocked(selectedEls(doc, ids));
|
|
550
|
+
// 素早く2つ続けたら2桁(4→5 で 45%、0→0 で 0%)。1つだけなら 1=10% … 9=90%、0=100%
|
|
551
|
+
const now = Date.now();
|
|
552
|
+
const prev = lastOpacityDigit && now - lastOpacityDigit.at < OPACITY_TWO_DIGIT_MS ? lastOpacityDigit : null;
|
|
553
|
+
let value: string;
|
|
554
|
+
if (prev) {
|
|
555
|
+
value = String((prev.digit * 10 + digit) / 100);
|
|
556
|
+
lastOpacityDigit = null;
|
|
557
|
+
} else {
|
|
558
|
+
value = digit === 0 ? '1' : String(digit / 10);
|
|
559
|
+
lastOpacityDigit = { digit, at: now };
|
|
560
|
+
}
|
|
561
|
+
els.forEach((el) => applyTailwindStyles(el, { opacity: value }));
|
|
562
|
+
afterChange(doc, els);
|
|
563
|
+
},
|
|
564
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
565
|
+
[getIframeDoc, afterChange, ids.join(',')],
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* ⇧H / ⇧V: 左右反転・上下反転(help.figma.com「Adjust alignment, rotation, position, and dimensions」の
|
|
570
|
+
* Flip layers と同じキー)。複数選択は Figma と同じく選択全体を 1 つの塊として反転する
|
|
571
|
+
* (各要素の位置を選択群の中心で鏡写しにし、各要素にも反転を付ける)。履歴は 1 回分
|
|
572
|
+
*/
|
|
573
|
+
const flip = useCallback(
|
|
574
|
+
(axis: 'x' | 'y') => {
|
|
575
|
+
const doc = getIframeDoc();
|
|
576
|
+
if (!doc) return;
|
|
577
|
+
const els = withoutLocked(selectedEls(doc, ids));
|
|
578
|
+
// 複数選択は塊として鏡写し: 位置を選択群の中心で反転してから、各要素にも反転を付ける
|
|
579
|
+
if (els.length > 1) mirrorPositions(els, axis);
|
|
580
|
+
els.forEach((el) => {
|
|
581
|
+
const next = toggleFlipIn(el.style.transform || '', axis);
|
|
582
|
+
if (next) el.style.transform = next;
|
|
583
|
+
else el.style.removeProperty('transform');
|
|
584
|
+
});
|
|
585
|
+
afterChange(doc, els);
|
|
586
|
+
},
|
|
587
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
588
|
+
[getIframeDoc, afterChange, ids.join(',')],
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* ⌘⇧> / ⌘⇧<: 文字の大きさを 1px 上げる/下げる。
|
|
593
|
+
* 刻みは記憶にもとづく(help.figma.com「Explore text properties」はキーだけで刻みを書いていない)。
|
|
594
|
+
* 選んだ要素の中で文字を直に持つ要素それぞれを、今の大きさから同じ量だけ動かす
|
|
595
|
+
* (見出しと本文の大きさの差は保つ)。複数選択でも1回の変更 = ⌘Z 1回で戻る。
|
|
596
|
+
*/
|
|
597
|
+
const stepFontSize = useCallback(
|
|
598
|
+
(delta: number) => {
|
|
599
|
+
const doc = getIframeDoc();
|
|
600
|
+
const view = doc?.defaultView;
|
|
601
|
+
if (!doc || !view) return;
|
|
602
|
+
const els = withoutLocked(selectedEls(doc, ids));
|
|
603
|
+
const targets = Array.from(new Set(els.flatMap(textBearers)));
|
|
604
|
+
if (!targets.length) return;
|
|
605
|
+
// 先に全部の今の大きさを読む(親を書き換えてから子を読むと継承で値が変わる)
|
|
606
|
+
const sizes = targets.map((el) => parseFloat(view.getComputedStyle(el).fontSize));
|
|
607
|
+
targets.forEach((el, i) => {
|
|
608
|
+
if (!Number.isFinite(sizes[i])) return;
|
|
609
|
+
const next = Math.max(1, Math.round(sizes[i]) + delta);
|
|
610
|
+
// tailwind-merge は text-[Npx] を入れると leading-* を消す(文字の大きさが行の高さも決めるため)。
|
|
611
|
+
// 行の高さは変えたくないので、付いていた leading-* を戻す
|
|
612
|
+
const leading = Array.from(el.classList).filter((c) => /^leading-/.test(c));
|
|
613
|
+
applyTailwindStyles(el, { fontSize: `${next}px` });
|
|
614
|
+
leading.forEach((c) => el.classList.add(c));
|
|
615
|
+
});
|
|
616
|
+
afterChange(doc, els);
|
|
617
|
+
},
|
|
618
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
619
|
+
[getIframeDoc, afterChange, ids.join(',')],
|
|
620
|
+
);
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* ⌘B / ⌘I / ⌘U: 要素の文字全体の書式を切り替える。
|
|
624
|
+
* 複数選択なら「全部が ON なら OFF、それ以外は全部 ON」(Figma と同じ揃え方)
|
|
625
|
+
*/
|
|
626
|
+
const toggleTextStyle = useCallback(
|
|
627
|
+
(kind: 'bold' | 'italic' | 'underline') => {
|
|
628
|
+
const doc = getIframeDoc();
|
|
629
|
+
const view = doc?.defaultView;
|
|
630
|
+
if (!doc || !view) return;
|
|
631
|
+
const els = withoutLocked(selectedEls(doc, ids)).filter((el) => (el.textContent ?? '').trim());
|
|
632
|
+
if (!els.length) return;
|
|
633
|
+
// ホバー中の文字要素にはエディタが青い下線(.text-editable-hover)を引いている。
|
|
634
|
+
// そのまま測ると「下線あり」と読んで ⌘U が毎回 OFF 側に倒れるので、外して測る
|
|
635
|
+
const HOVER_CLASSES = ['text-editable-hover', 'hover-preview'];
|
|
636
|
+
const isOn = (el: HTMLElement) => {
|
|
637
|
+
const had = HOVER_CLASSES.filter((c) => el.classList.contains(c));
|
|
638
|
+
el.classList.remove(...had);
|
|
639
|
+
const cs = view.getComputedStyle(el);
|
|
640
|
+
const on =
|
|
641
|
+
kind === 'bold'
|
|
642
|
+
? (parseInt(cs.fontWeight, 10) || 400) >= 600
|
|
643
|
+
: kind === 'italic'
|
|
644
|
+
? cs.fontStyle === 'italic'
|
|
645
|
+
: cs.textDecorationLine.includes('underline');
|
|
646
|
+
el.classList.add(...had);
|
|
647
|
+
return on;
|
|
648
|
+
};
|
|
649
|
+
const turnOn = !els.every(isOn);
|
|
650
|
+
const styles: Record<string, string> =
|
|
651
|
+
kind === 'bold'
|
|
652
|
+
? { fontWeight: turnOn ? '700' : '400' }
|
|
653
|
+
: kind === 'italic'
|
|
654
|
+
? { fontStyle: turnOn ? 'italic' : 'normal' }
|
|
655
|
+
: { textDecoration: turnOn ? 'underline' : 'none' };
|
|
656
|
+
els.forEach((el) => applyTailwindStyles(el, styles));
|
|
657
|
+
afterChange(doc, els);
|
|
658
|
+
},
|
|
659
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
660
|
+
[getIframeDoc, afterChange, ids.join(',')],
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
// ⌘\ は選択が無くても効く(UI を隠したまま選択を外しても戻せるように)
|
|
664
|
+
const always: ShortcutCallbacks = {
|
|
665
|
+
toggleUi: toggleEditorUi,
|
|
666
|
+
toggleOutline: () => {
|
|
667
|
+
const doc = getIframeDoc();
|
|
668
|
+
if (doc) toggleOutlineIn(doc);
|
|
669
|
+
},
|
|
670
|
+
};
|
|
671
|
+
if (!hasSelection) return always;
|
|
672
|
+
|
|
673
|
+
const callbacks: ShortcutCallbacks = {
|
|
674
|
+
...always,
|
|
675
|
+
flipH: () => flip('x'),
|
|
676
|
+
flipV: () => flip('y'),
|
|
677
|
+
renameLayer: () => {
|
|
678
|
+
const id = ids[0] ?? selectedElement?.id;
|
|
679
|
+
if (id) window.dispatchEvent(new CustomEvent(RENAME_LAYER_EVENT, { detail: { id } }));
|
|
680
|
+
},
|
|
681
|
+
selectChildren,
|
|
682
|
+
selectParent,
|
|
683
|
+
alignLeft: () => runAlign('left'),
|
|
684
|
+
alignRight: () => runAlign('right'),
|
|
685
|
+
alignTop: () => runAlign('top'),
|
|
686
|
+
alignBottom: () => runAlign('bottom'),
|
|
687
|
+
alignHCenter: () => runAlign('hcenter'),
|
|
688
|
+
alignVCenter: () => runAlign('vcenter'),
|
|
689
|
+
distributeH: () => runDistribute('h'),
|
|
690
|
+
distributeV: () => runDistribute('v'),
|
|
691
|
+
toggleHidden,
|
|
692
|
+
toggleLocked,
|
|
693
|
+
toggleBold: () => toggleTextStyle('bold'),
|
|
694
|
+
toggleItalic: () => toggleTextStyle('italic'),
|
|
695
|
+
toggleUnderline: () => toggleTextStyle('underline'),
|
|
696
|
+
fontSizeUp: () => stepFontSize(1),
|
|
697
|
+
fontSizeDown: () => stepFontSize(-1),
|
|
698
|
+
};
|
|
699
|
+
OPACITY_KEYS.forEach((key, i) => {
|
|
700
|
+
callbacks[key] = () => setOpacity(i === 9 ? 0 : i + 1);
|
|
701
|
+
});
|
|
702
|
+
return callbacks;
|
|
703
|
+
}
|
|
@@ -19,6 +19,7 @@ import { extractElementInfo } from '../utils/style-utils';
|
|
|
19
19
|
import { MARQUEE_DRAG_THRESHOLD } from '../constants';
|
|
20
20
|
import type { MarqueeState } from '../types';
|
|
21
21
|
import { debugLog } from '../utils/debug';
|
|
22
|
+
import { isLockedEl } from '../utils/lock';
|
|
22
23
|
|
|
23
24
|
interface UseMarqueeSelectionOptions {
|
|
24
25
|
/** マーキー選択の開始保留フラグ */
|
|
@@ -54,7 +55,10 @@ export function findElementsIntersectingMarquee(
|
|
|
54
55
|
el.classList.contains('selection-box') ||
|
|
55
56
|
el.classList.contains('marquee-selection-box') ||
|
|
56
57
|
el.classList.contains('drawing-preview') ||
|
|
57
|
-
el.closest('.selection-box')
|
|
58
|
+
el.closest('.selection-box') ||
|
|
59
|
+
// ロック中(⌘⇧L)・非表示(⌘⇧H / 目のアイコン)は範囲選択でも拾わない(Figma と同じ)
|
|
60
|
+
isLockedEl(el) ||
|
|
61
|
+
el.style.visibility === 'hidden'
|
|
58
62
|
) {
|
|
59
63
|
return;
|
|
60
64
|
}
|
|
@@ -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,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;
|