@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.
Files changed (34) hide show
  1. package/README.md +24 -0
  2. package/dist/editor.css +1 -1
  3. package/package.json +1 -1
  4. package/src/editor/FrontendVisualEditor.tsx +18 -12
  5. package/src/editor/components/EditorContextMenu.tsx +52 -2
  6. package/src/editor/components/EditorFooter.tsx +29 -6
  7. package/src/editor/components/EditorLayerPanel.tsx +86 -0
  8. package/src/editor/components/LayerTreeItem.tsx +107 -3
  9. package/src/editor/components/canvas-guides/CanvasGuides.tsx +247 -0
  10. package/src/editor/components/multi-page/CanvasRulers.tsx +3 -0
  11. package/src/editor/components/ppt/PptChrome.tsx +8 -4
  12. package/src/editor/components/shell/LeftPanel.tsx +8 -1
  13. package/src/editor/components/shell/PagesPanel.tsx +7 -1
  14. package/src/editor/components/shell/useRowsNearViewport.ts +64 -0
  15. package/src/editor/constants.ts +16 -0
  16. package/src/editor/contexts/EditorHistoryContext.tsx +6 -1
  17. package/src/editor/contexts/MultiPageCanvasContext.tsx +49 -11
  18. package/src/editor/hooks/useCanvasControls.ts +41 -4
  19. package/src/editor/hooks/useDragResize.ts +205 -17
  20. package/src/editor/hooks/useElementActions.ts +119 -34
  21. package/src/editor/hooks/useKeyboardShortcuts.ts +487 -0
  22. package/src/editor/hooks/useMarqueeSelection.ts +5 -1
  23. package/src/editor/utils/align-elements.ts +49 -0
  24. package/src/editor/utils/canvas-guides.ts +46 -0
  25. package/src/editor/utils/dom-utils.ts +12 -2
  26. package/src/editor/utils/history-timing.ts +9 -0
  27. package/src/editor/utils/html-utils.ts +4 -0
  28. package/src/editor/utils/lock.ts +24 -0
  29. package/src/editor/utils/smart-duplicate.ts +51 -0
  30. package/src/editor/utils/smart-guides.ts +232 -2
  31. package/src/hooks/useEditorShortcuts.ts +46 -2
  32. package/src/styles/skin.css +5 -0
  33. package/src/types/editor.ts +120 -1
  34. 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, DUPLICATE_OFFSET, DUPLICATE_OFFSET);
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, DUPLICATE_OFFSET, DUPLICATE_OFFSET);
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: 10, y: 10 },
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: 10, y: 10 },
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[], offset?: { x: number; y: number }) => {
953
+ const pasteSerializedElements = useCallback((list: SerializedElement[]) => {
924
954
  const iframeDoc = getIframeDoc();
925
955
  if (!iframeDoc || list.length === 0) return;
926
- const off = offset ?? { x: 10, y: 10 };
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
- const left = parseInt(el.style.left || '0') || 0;
948
- const top = parseInt(el.style.top || '0') || 0;
949
- el.style.left = `${left + off.x}px`;
950
- el.style.top = `${top + off.y}px`;
951
-
952
- // 挿入先に応じて挿入
953
- if (insertAfterElement && insertAfterElement.parentNode) {
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
- pasteSerializedElements(clip.elements, clip.offset);
993
- clip.offset.x += 10;
994
- clip.offset.y += 10;
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 elements = ids
1475
+ const found = ids
1398
1476
  .map(id => getIframeElement(iframeDoc, id))
1399
1477
  .filter((el): el is HTMLElement => el !== null);
1400
- if (elements.length === 0) return { moved: 0, blocked: 'no-selection' };
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 elements = ids
1542
+ const found = ids
1462
1543
  .map(id => getIframeElement(iframeDoc, id))
1463
1544
  .filter((el): el is HTMLElement => el !== null);
1464
- if (elements.length === 0) return { moved: 0, blocked: 'no-selection' };
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,