@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
|
@@ -34,6 +34,8 @@ import {
|
|
|
34
34
|
import type { SerializedElement, BoundingBox, StyleClipboard, ClipboardData } from '../types';
|
|
35
35
|
import type { globalLastUsedStylesRef } from '../contexts/EditorRefsContext';
|
|
36
36
|
import { debugLog } from '../utils/debug';
|
|
37
|
+
import { readPosition, rememberDuplicate, repeatOffsetFor } from '../utils/smart-duplicate';
|
|
38
|
+
import { withoutLocked } from '../utils/lock';
|
|
37
39
|
|
|
38
40
|
/**
|
|
39
41
|
* 要素の種類を判定(shape / text / line)
|
|
@@ -111,8 +113,6 @@ function translateOutOfFlowElement(view: Window, el: HTMLElement, dx: number, dy
|
|
|
111
113
|
return true;
|
|
112
114
|
}
|
|
113
115
|
|
|
114
|
-
/** 複製時にずらす量(px) */
|
|
115
|
-
const DUPLICATE_OFFSET = 20;
|
|
116
116
|
|
|
117
117
|
/**
|
|
118
118
|
* 複製した要素を「元要素の見た目の位置 + offset」に置く。
|
|
@@ -153,12 +153,32 @@ function offsetDuplicate(
|
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/** 器として扱わない要素(文字・画像・図形など、子を持たせても意味をなさない) */
|
|
157
|
+
const NON_CONTAINER_TAGS = new Set([
|
|
158
|
+
'P', 'SPAN', 'A', 'BUTTON', 'IMG', 'SVG', 'VIDEO', 'CANVAS', 'IFRAME', 'INPUT', 'TEXTAREA', 'SELECT',
|
|
159
|
+
'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LABEL', 'STRONG', 'EM', 'B', 'I', 'U', 'BR', 'HR', 'TABLE', 'TR', 'TD', 'TH',
|
|
160
|
+
'UL', 'OL', 'LI',
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 貼り付けの器になれるか(Figma のフレーム / グループにあたるか)。
|
|
165
|
+
* 位置指定のある箱で、文字を直に持たない要素。position:static の箱に絶対配置の子を入れると、
|
|
166
|
+
* 子の座標が別の祖先基準になって飛ぶので器にしない
|
|
167
|
+
*/
|
|
168
|
+
function isPasteContainer(el: HTMLElement): boolean {
|
|
169
|
+
if (NON_CONTAINER_TAGS.has(el.tagName.toUpperCase())) return false;
|
|
170
|
+
if (el.hasAttribute('data-component-instance')) return false;
|
|
171
|
+
const view = el.ownerDocument.defaultView;
|
|
172
|
+
if (!view || view.getComputedStyle(el).position === 'static') return false;
|
|
173
|
+
return !Array.from(el.childNodes).some((n) => n.nodeType === 3 && (n.textContent ?? '').trim());
|
|
174
|
+
}
|
|
175
|
+
|
|
156
176
|
/** 矢印キー移動の結果。呼び出し側が「なぜ動かないか」を提示できるようにする */
|
|
157
177
|
export interface MoveElementResult {
|
|
158
178
|
/** 実際に動かした要素数 */
|
|
159
179
|
moved: number;
|
|
160
180
|
/** 動かせなかった理由。null なら成功 */
|
|
161
|
-
blocked: null | 'no-selection' | 'flow';
|
|
181
|
+
blocked: null | 'no-selection' | 'flow' | 'locked';
|
|
162
182
|
}
|
|
163
183
|
|
|
164
184
|
/**
|
|
@@ -754,17 +774,22 @@ export function useElementActions() {
|
|
|
754
774
|
if (selectedElementIds.length > 0) {
|
|
755
775
|
const newIds: string[] = [];
|
|
756
776
|
const newElements: HTMLElement[] = [];
|
|
777
|
+
// Figma と同じく真上に複製。直前の複製を動かしてから押したときは同じずれを繰り返す
|
|
778
|
+
const firstSource = getIframeElement(iframeDoc, selectedElementIds[0]);
|
|
779
|
+
const { dx, dy } = repeatOffsetFor(selectedElementIds, firstSource);
|
|
780
|
+
const firstFrom = firstSource ? readPosition(firstSource) : null;
|
|
757
781
|
selectedElementIds.forEach(id => {
|
|
758
782
|
const el = getIframeElement(iframeDoc, id);
|
|
759
783
|
if (el) {
|
|
760
784
|
const { clone, newId } = cloneElementWithInstance(el);
|
|
761
785
|
// 位置指定は「DOMに入れてから」元要素の実測位置を基準に行う(後述の理由)
|
|
762
786
|
el.parentNode?.insertBefore(clone, el.nextSibling);
|
|
763
|
-
offsetDuplicate(iframeDoc, el, clone,
|
|
787
|
+
offsetDuplicate(iframeDoc, el, clone, dx, dy);
|
|
764
788
|
newIds.push(newId);
|
|
765
789
|
newElements.push(clone);
|
|
766
790
|
}
|
|
767
791
|
});
|
|
792
|
+
rememberDuplicate(newIds, firstFrom);
|
|
768
793
|
|
|
769
794
|
// 新しい要素を選択
|
|
770
795
|
setSelectedElementIds(newIds);
|
|
@@ -785,9 +810,12 @@ export function useElementActions() {
|
|
|
785
810
|
if (!selectedElement) return;
|
|
786
811
|
const el = getIframeElement(iframeDoc, selectedElement.id);
|
|
787
812
|
if (el) {
|
|
813
|
+
const { dx, dy } = repeatOffsetFor([selectedElement.id], el);
|
|
814
|
+
const from = readPosition(el);
|
|
788
815
|
const { clone, newId } = cloneElementWithInstance(el);
|
|
789
816
|
el.parentNode?.insertBefore(clone, el.nextSibling);
|
|
790
|
-
offsetDuplicate(iframeDoc, el, clone,
|
|
817
|
+
offsetDuplicate(iframeDoc, el, clone, dx, dy);
|
|
818
|
+
rememberDuplicate([newId], from);
|
|
791
819
|
|
|
792
820
|
// 新しい要素を選択
|
|
793
821
|
clone.classList.add('selected');
|
|
@@ -808,6 +836,7 @@ export function useElementActions() {
|
|
|
808
836
|
attributes[attr.name] = attr.value;
|
|
809
837
|
}
|
|
810
838
|
}
|
|
839
|
+
const pos = readPosition(el);
|
|
811
840
|
return {
|
|
812
841
|
tagName: el.tagName.toLowerCase(),
|
|
813
842
|
id: el.getAttribute('data-element-id') || '',
|
|
@@ -815,6 +844,7 @@ export function useElementActions() {
|
|
|
815
844
|
style: el.getAttribute('style') || '',
|
|
816
845
|
innerHTML: el.innerHTML,
|
|
817
846
|
attributes,
|
|
847
|
+
...(pos ? { box: { ...pos, width: el.offsetWidth, height: el.offsetHeight } } : {}),
|
|
818
848
|
};
|
|
819
849
|
}, []);
|
|
820
850
|
|
|
@@ -890,7 +920,7 @@ export function useElementActions() {
|
|
|
890
920
|
if (serialized.length > 0) {
|
|
891
921
|
clipboardRef.current = {
|
|
892
922
|
elements: serialized,
|
|
893
|
-
offset: { x:
|
|
923
|
+
offset: { x: 0, y: 0 },
|
|
894
924
|
};
|
|
895
925
|
writeToOsClipboard(clipboardRef.current);
|
|
896
926
|
}
|
|
@@ -904,7 +934,7 @@ export function useElementActions() {
|
|
|
904
934
|
|
|
905
935
|
clipboardRef.current = {
|
|
906
936
|
elements: [serializeElement(el)],
|
|
907
|
-
offset: { x:
|
|
937
|
+
offset: { x: 0, y: 0 },
|
|
908
938
|
};
|
|
909
939
|
writeToOsClipboard(clipboardRef.current);
|
|
910
940
|
}, [selectedElement, selectedElementIds, getIframeDoc, serializeElement, clipboardRef, writeToOsClipboard]);
|
|
@@ -920,38 +950,58 @@ export function useElementActions() {
|
|
|
920
950
|
* 内部クリップボード(同一エディタ内)と、OSクリップボード経由
|
|
921
951
|
* (スライド跨ぎ。pasteイベントがペイロードを取り出して渡す)の両方から使う。
|
|
922
952
|
*/
|
|
923
|
-
const pasteSerializedElements = useCallback((list: SerializedElement[]
|
|
953
|
+
const pasteSerializedElements = useCallback((list: SerializedElement[]) => {
|
|
924
954
|
const iframeDoc = getIframeDoc();
|
|
925
955
|
if (!iframeDoc || list.length === 0) return;
|
|
926
|
-
|
|
956
|
+
|
|
957
|
+
// 貼り付け先(Figma「Copy and paste objects」の Paste placement と同じ考え方):
|
|
958
|
+
// - 器(子を持てる要素)を選んでいれば、その中へ。元の器の中と同じ x / y に置き、入りきらない軸は中央
|
|
959
|
+
// - 器でない要素を選んでいれば、その要素の親へ(その要素のすぐ手前=1つ前面)
|
|
960
|
+
// - 何も選んでいなければスライドのルートへ
|
|
961
|
+
// どの場合も「ずらさない」。同じスライドで元が見えていても、元の真上に重なる(Figma と同じ)
|
|
962
|
+
const selected = selectedElement ? getIframeElement(iframeDoc, selectedElement.id) : null;
|
|
963
|
+
const sourceIds = new Set(list.map((item) => item.id));
|
|
964
|
+
const isSelfPaste = !!selected && sourceIds.has(selected.getAttribute('data-element-id') || '');
|
|
965
|
+
// 選んだ器の中へ入れるのは、スライド(絶対配置)で、貼る要素がすべて位置(box)を持つときだけ。
|
|
966
|
+
// webpage は流し込みを保つ約束なので従来どおり選択の直後に入れる。box の無いフローの要素も同じ
|
|
967
|
+
const canNest = editorMode !== 'webpage' && list.every((item) => !!item.box);
|
|
968
|
+
const container = canNest && selected && !isSelfPaste && isPasteContainer(selected) ? selected : null;
|
|
969
|
+
let insertAfterElement: HTMLElement | null = container ? null : selected;
|
|
927
970
|
|
|
928
971
|
// 既存の選択ボックスをクリア
|
|
929
972
|
iframeDoc.querySelectorAll('.selection-box').forEach(box => box.remove());
|
|
930
973
|
iframeDoc.querySelectorAll('.selected').forEach(el => el.classList.remove('selected'));
|
|
931
974
|
|
|
932
|
-
// 挿入先を決定: 選択要素がある場合はその直後、なければbodyの末尾
|
|
933
|
-
let insertAfterElement: HTMLElement | null = null;
|
|
934
|
-
if (selectedElement) {
|
|
935
|
-
insertAfterElement = getIframeElement(iframeDoc, selectedElement.id);
|
|
936
|
-
}
|
|
937
|
-
|
|
938
975
|
const pastedIds: string[] = [];
|
|
939
976
|
const pastedElements: HTMLElement[] = [];
|
|
940
977
|
|
|
941
978
|
list.forEach(serialized => {
|
|
942
979
|
const el = deserializeElement(serialized);
|
|
943
980
|
if (!el) return;
|
|
944
|
-
|
|
981
|
+
|
|
982
|
+
// ルートだけでなく中の要素の ID も作り直す(元と同じ ID が2つできると選択・履歴が混線する)
|
|
945
983
|
const newId = generateElementId('paste');
|
|
946
984
|
el.setAttribute('data-element-id', newId);
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
el.
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
if (
|
|
954
|
-
|
|
985
|
+
el.querySelectorAll('[data-element-id]').forEach((child) => {
|
|
986
|
+
child.setAttribute('data-element-id', generateElementId('paste'));
|
|
987
|
+
});
|
|
988
|
+
el.classList.remove('selected', 'hover-preview', 'text-editable-hover', 'marquee-hover', 'dragging');
|
|
989
|
+
el.removeAttribute('data-layer-hover');
|
|
990
|
+
|
|
991
|
+
if (container) {
|
|
992
|
+
container.appendChild(el);
|
|
993
|
+
// 元の器の中と同じ x / y。入りきらない軸は器の中央(Figma と同じ)
|
|
994
|
+
const box = serialized.box;
|
|
995
|
+
if (box) {
|
|
996
|
+
const cw = container.clientWidth;
|
|
997
|
+
const ch = container.clientHeight;
|
|
998
|
+
const fitsX = box.left >= 0 && box.left + box.width <= cw;
|
|
999
|
+
const fitsY = box.top >= 0 && box.top + box.height <= ch;
|
|
1000
|
+
el.style.left = `${Math.round(fitsX ? box.left : (cw - box.width) / 2)}px`;
|
|
1001
|
+
el.style.top = `${Math.round(fitsY ? box.top : (ch - box.height) / 2)}px`;
|
|
1002
|
+
}
|
|
1003
|
+
} else if (insertAfterElement && insertAfterElement.parentNode) {
|
|
1004
|
+
// 選択要素の直後(1つ前面)に。位置はコピーしたときのまま
|
|
955
1005
|
insertAfterElement.parentNode.insertBefore(el, insertAfterElement.nextSibling);
|
|
956
1006
|
// 次のペースト要素のために更新
|
|
957
1007
|
insertAfterElement = el;
|
|
@@ -963,13 +1013,13 @@ export function useElementActions() {
|
|
|
963
1013
|
iframeDoc.body;
|
|
964
1014
|
root.appendChild(el);
|
|
965
1015
|
}
|
|
966
|
-
|
|
1016
|
+
|
|
967
1017
|
pastedIds.push(newId);
|
|
968
1018
|
pastedElements.push(el);
|
|
969
1019
|
});
|
|
970
1020
|
|
|
971
1021
|
notifyIframeChange();
|
|
972
|
-
|
|
1022
|
+
|
|
973
1023
|
// ペーストした要素を選択し、選択ボックスを更新
|
|
974
1024
|
if (pastedIds.length > 0) {
|
|
975
1025
|
setSelectedElementIds(pastedIds);
|
|
@@ -983,15 +1033,43 @@ export function useElementActions() {
|
|
|
983
1033
|
if (info) setSelectedElement(info);
|
|
984
1034
|
}
|
|
985
1035
|
}
|
|
986
|
-
}, [getIframeDoc, deserializeElement, notifyIframeChange, selectedElement, setSelectedElementIds, setSelectedElement]);
|
|
1036
|
+
}, [getIframeDoc, deserializeElement, notifyIframeChange, selectedElement, setSelectedElementIds, setSelectedElement, editorMode]);
|
|
987
1037
|
|
|
988
1038
|
// 内部クリップボードからのペースト(pasteイベント側のフォールバック用)
|
|
989
1039
|
const pasteElements = useCallback(() => {
|
|
990
1040
|
const clip = clipboardRef.current;
|
|
991
1041
|
if (!clip) return;
|
|
992
|
-
|
|
993
|
-
clip.
|
|
994
|
-
|
|
1042
|
+
// Figma と同じく何度貼ってもずらさない
|
|
1043
|
+
pasteSerializedElements(clip.elements);
|
|
1044
|
+
}, [clipboardRef, pasteSerializedElements]);
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* ⌘⇧V(Figma の Paste over selection / その場に貼る)。
|
|
1048
|
+
* ⌘V も同じ位置に貼るので動きは同じ。⌘⇧V はブラウザが paste イベントを出さないことがあるため、
|
|
1049
|
+
* OS のクリップボードを直接読み、読めなければ(権限が無い等)このエディタの内部クリップボードを使う
|
|
1050
|
+
*/
|
|
1051
|
+
const pasteInPlace = useCallback(async () => {
|
|
1052
|
+
try {
|
|
1053
|
+
const items = await navigator.clipboard.read();
|
|
1054
|
+
for (const item of items) {
|
|
1055
|
+
if (!item.types.includes('text/html')) continue;
|
|
1056
|
+
const html = await (await item.getType('text/html')).text();
|
|
1057
|
+
const payload = html.match(/data-gg-payload="([^"]+)"/);
|
|
1058
|
+
if (payload) {
|
|
1059
|
+
pasteSerializedElements(JSON.parse(decodeURIComponent(escape(atob(payload[1])))));
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
// 読めたのに要素の印が無い = 後から別のもの(文字・画像など)をコピーした。
|
|
1064
|
+
// 内部に残っている古い要素は貼らない(何もしない)
|
|
1065
|
+
return;
|
|
1066
|
+
} catch {
|
|
1067
|
+
// 権限が無い・未対応のブラウザ。下で内部クリップボードを見る
|
|
1068
|
+
}
|
|
1069
|
+
// 読めなかったときだけ内部クリップボードへ。それも通常の ⌘V(pasteFromInternalIfFresh)と同じく、
|
|
1070
|
+
// OS への書き込みが成功していた(= OS 側に新しいものがあるかもしれない)なら貼らない
|
|
1071
|
+
const clip = clipboardRef.current;
|
|
1072
|
+
if (clip && !clip.osWritten) pasteSerializedElements(clip.elements);
|
|
995
1073
|
}, [clipboardRef, pasteSerializedElements]);
|
|
996
1074
|
|
|
997
1075
|
/**
|
|
@@ -1394,10 +1472,13 @@ export function useElementActions() {
|
|
|
1394
1472
|
: [];
|
|
1395
1473
|
if (ids.length === 0) return { moved: 0, blocked: 'no-selection' };
|
|
1396
1474
|
|
|
1397
|
-
const
|
|
1475
|
+
const found = ids
|
|
1398
1476
|
.map(id => getIframeElement(iframeDoc, id))
|
|
1399
1477
|
.filter((el): el is HTMLElement => el !== null);
|
|
1400
|
-
if (
|
|
1478
|
+
if (found.length === 0) return { moved: 0, blocked: 'no-selection' };
|
|
1479
|
+
// ロック中(本人か祖先)は矢印キーでも動かさない・大きさも変えない(Figma と同じ。記憶)
|
|
1480
|
+
const elements = withoutLocked(found);
|
|
1481
|
+
if (elements.length === 0) return { moved: 0, blocked: 'locked' };
|
|
1401
1482
|
|
|
1402
1483
|
// webpage はフロー内の要素を倒さない。矢印キーは並べ替えを1段動かす
|
|
1403
1484
|
if (editorMode === 'webpage') {
|
|
@@ -1458,10 +1539,13 @@ export function useElementActions() {
|
|
|
1458
1539
|
: selectedElement
|
|
1459
1540
|
? [selectedElement.id]
|
|
1460
1541
|
: [];
|
|
1461
|
-
const
|
|
1542
|
+
const found = ids
|
|
1462
1543
|
.map(id => getIframeElement(iframeDoc, id))
|
|
1463
1544
|
.filter((el): el is HTMLElement => el !== null);
|
|
1464
|
-
if (
|
|
1545
|
+
if (found.length === 0) return { moved: 0, blocked: 'no-selection' };
|
|
1546
|
+
// ロック中(本人か祖先)は矢印キーでも動かさない・大きさも変えない(Figma と同じ。記憶)
|
|
1547
|
+
const elements = withoutLocked(found);
|
|
1548
|
+
if (elements.length === 0) return { moved: 0, blocked: 'locked' };
|
|
1465
1549
|
|
|
1466
1550
|
// フロー内の要素は移動と同じ遅延変換で絶対配置へ倒してから触る
|
|
1467
1551
|
elements.forEach(el => {
|
|
@@ -1824,6 +1908,7 @@ export function useElementActions() {
|
|
|
1824
1908
|
pasteElements,
|
|
1825
1909
|
pasteSerializedElements,
|
|
1826
1910
|
pasteFromInternalIfFresh,
|
|
1911
|
+
pasteInPlace,
|
|
1827
1912
|
copyStyle,
|
|
1828
1913
|
pasteStyle,
|
|
1829
1914
|
hasStyleInClipboard,
|