@growgroup/visual-editor 0.6.0 → 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.
Files changed (35) hide show
  1. package/README.md +45 -0
  2. package/dist/editor.css +1 -1
  3. package/package.json +1 -1
  4. package/src/editor/FrontendVisualEditor.tsx +173 -0
  5. package/src/editor/components/EditorCanvas.tsx +16 -0
  6. package/src/editor/components/EditorContextMenu.tsx +80 -0
  7. package/src/editor/components/EditorPropertyPanel.tsx +248 -80
  8. package/src/editor/components/EditorSidebar.tsx +2 -2
  9. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +38 -19
  10. package/src/editor/components/ppt/PptChrome.tsx +46 -4
  11. package/src/editor/components/property-panel/VariableAwareSizeInput.tsx +94 -50
  12. package/src/editor/components/property-panel/VariableAwareUnitInput.tsx +12 -6
  13. package/src/editor/constants.ts +24 -0
  14. package/src/editor/contexts/MultiPageCanvasContext.tsx +108 -6
  15. package/src/editor/hooks/index.ts +3 -0
  16. package/src/editor/hooks/useDragResize.ts +30 -5
  17. package/src/editor/hooks/useElementActions.ts +41 -9
  18. package/src/editor/hooks/useElementSelection.ts +4 -2
  19. package/src/editor/hooks/useIframeSetup.ts +8 -1
  20. package/src/editor/hooks/useInlineTextSelection.ts +16 -0
  21. package/src/editor/hooks/useKeepArtboardInPlace.ts +87 -0
  22. package/src/editor/hooks/useLinkNavigation.ts +160 -0
  23. package/src/editor/parts.ts +2 -0
  24. package/src/editor/types.ts +4 -0
  25. package/src/editor/utils/dom-utils.ts +45 -0
  26. package/src/editor/utils/free-layout-tree.ts +562 -0
  27. package/src/editor/utils/free-layout.ts +794 -0
  28. package/src/editor/utils/geometry.ts +41 -3
  29. package/src/editor/utils/html-utils.ts +2 -1
  30. package/src/editor/utils/inline-text-style.ts +426 -0
  31. package/src/editor/utils/link-navigation.ts +474 -0
  32. package/src/editor/utils/style-utils.ts +24 -0
  33. package/src/editor/utils/tailwind-utils.ts +12 -0
  34. package/src/editor/utils/text-line-break.ts +121 -0
  35. package/src/version.ts +1 -1
@@ -0,0 +1,160 @@
1
+ /**
2
+ * ⌘ / Ctrl + クリックでリンク先へ移る(utils/link-navigation.ts)の、エディタ側の行き先。
3
+ *
4
+ * ページの切替は、ページ一覧で切り替えるときと同じ経路を通す
5
+ * (未保存の変更は FrontendVisualEditor.handleContentChange が保存してから移る):
6
+ * - キャンバス … focusPage で視点を寄せ、activatePage で編集中のページにする
7
+ * - 1 ページ表示 … onContentChange(= handleContentChange。保存 → 読み込み → 利用側へ通知)
8
+ */
9
+ import { useCallback, useRef } from 'react';
10
+ import { useEditorContext } from '../EditorContext';
11
+ import { RULER_SIZE, useMultiPageCanvasOptional } from '../contexts/MultiPageCanvasContext';
12
+ import {
13
+ attachLinkNavigation,
14
+ clearPendingAnchor,
15
+ findAnchorTarget,
16
+ peekPendingAnchor,
17
+ resolveLinkTarget,
18
+ setPendingAnchor,
19
+ type LinkTarget,
20
+ } from '../utils/link-navigation';
21
+
22
+ /** アンカーの要素を、画面の上端からこれだけ下に置く(定規・フレーム名に隠れないように) */
23
+ const ANCHOR_MARGIN = 48;
24
+ /** 別のページのアンカーへ移ってきたとき、紙面の高さ・初期位置が落ち着くのを待つ */
25
+ const PENDING_ANCHOR_DELAY_MS = 500;
26
+
27
+ const prefersReducedMotion = () =>
28
+ typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
29
+
30
+ export function useLinkNavigation() {
31
+ const { contentList, currentContentId, onContentChange } = useEditorContext();
32
+ const canvas = useMultiPageCanvasOptional();
33
+ const stateRef = useRef({ contentList, currentContentId, onContentChange, canvas });
34
+ stateRef.current = { contentList, currentContentId, onContentChange, canvas };
35
+
36
+ /** 紙面の中の要素(`'top'` なら先頭)が見える位置へ紙面を移す。見つからなければ false */
37
+ const revealAnchor = useCallback((iframeDoc: Document, hash: string): boolean => {
38
+ const found = findAnchorTarget(iframeDoc, hash);
39
+ const el = found === 'top' ? iframeDoc.getElementById('artboard') : found;
40
+ if (!el) return false;
41
+ const c = stateRef.current.canvas;
42
+
43
+ if (c?.isEnabled) {
44
+ // キャンバス: 紙面(iframe)は伸びきっていてスクロールしない。キャンバスの位置を動かす
45
+ const frameEl = iframeDoc.defaultView?.frameElement as HTMLElement | null;
46
+ const container = c.containerRef.current;
47
+ if (!frameEl || !container) return false;
48
+ const fr = frameEl.getBoundingClientRect();
49
+ const scale = fr.width / (frameEl.clientWidth || fr.width) || 1;
50
+ const er = el.getBoundingClientRect();
51
+ const cr = container.getBoundingClientRect();
52
+ const inset = c.rulersVisible ? RULER_SIZE : 0;
53
+ const left = fr.left + er.left * scale;
54
+ const top = fr.top + er.top * scale;
55
+ const width = Math.min(er.width * scale, cr.width - inset);
56
+ const dy = cr.top + inset + ANCHOR_MARGIN - top;
57
+ const fitsX = left >= cr.left + inset && left + width <= cr.right;
58
+ const dx = fitsX ? 0 : cr.left + inset + ANCHOR_MARGIN - left;
59
+ const { canvasZoom, canvasOffset } = c.viewStore.get();
60
+ c.setView({ zoom: canvasZoom, offset: { x: canvasOffset.x + dx, y: canvasOffset.y + dy } }, { animate: true });
61
+ return true;
62
+ }
63
+
64
+ // 1 ページ表示: 紙面の中の #canvas-container がスクロールする
65
+ const container = iframeDoc.getElementById('canvas-container');
66
+ if (!container) {
67
+ el.scrollIntoView({ block: 'start' });
68
+ return true;
69
+ }
70
+ const cr = container.getBoundingClientRect();
71
+ const er = el.getBoundingClientRect();
72
+ const fitsX = er.left >= cr.left && er.left + Math.min(er.width, cr.width) <= cr.right;
73
+ container.scrollTo({
74
+ top: container.scrollTop + er.top - cr.top - ANCHOR_MARGIN,
75
+ left: fitsX ? container.scrollLeft : container.scrollLeft + er.left - cr.left - ANCHOR_MARGIN,
76
+ behavior: prefersReducedMotion() ? 'auto' : 'smooth',
77
+ });
78
+ return true;
79
+ }, []);
80
+
81
+ const navigate = useCallback(
82
+ (target: LinkTarget, iframeDoc: Document): string | void => {
83
+ const { currentContentId: currentId, onContentChange: change, canvas: c } = stateRef.current;
84
+ const missingAnchor = (hash: string) => `このページに #${hash} の場所がありません`;
85
+ switch (target.kind) {
86
+ case 'ignore':
87
+ return;
88
+ case 'unresolved':
89
+ return 'このリンク先はページ一覧にありません';
90
+ case 'external':
91
+ // 紙面の iframe は sandbox(allow-popups 無し)なので、親のウィンドウで開く
92
+ window.open(target.url, '_blank', 'noopener,noreferrer');
93
+ return;
94
+ case 'anchor':
95
+ return revealAnchor(iframeDoc, target.hash) ? undefined : missingAnchor(target.hash);
96
+ case 'page': {
97
+ if (target.id === currentId) {
98
+ if (target.hash) return revealAnchor(iframeDoc, target.hash) ? undefined : missingAnchor(target.hash);
99
+ if (c?.isEnabled) c.focusPage(target.id);
100
+ else revealAnchor(iframeDoc, '');
101
+ return;
102
+ }
103
+ setPendingAnchor(target.id, target.hash);
104
+ if (c?.isEnabled) {
105
+ c.focusPage(target.id);
106
+ void c.activatePage(target.id).then((ok) => {
107
+ if (!ok) clearPendingAnchor(target.id);
108
+ });
109
+ return;
110
+ }
111
+ if (!change) {
112
+ clearPendingAnchor(target.id);
113
+ return 'このエディタではページを移れません';
114
+ }
115
+ void Promise.resolve(change(target.id)).then((ok) => {
116
+ if (ok === false) clearPendingAnchor(target.id);
117
+ });
118
+ return;
119
+ }
120
+ }
121
+ },
122
+ [revealAnchor],
123
+ );
124
+
125
+ /** 紙面に ⌘ / Ctrl + クリックの移動を付ける。戻り値は後始末 */
126
+ const setupLinkNavigation = useCallback(
127
+ (iframeDoc: Document) => {
128
+ const detach = attachLinkNavigation(iframeDoc, {
129
+ resolve: (href) => {
130
+ const s = stateRef.current;
131
+ return resolveLinkTarget(href, {
132
+ pages: s.contentList,
133
+ currentId: s.currentContentId,
134
+ origin: window.location.origin,
135
+ });
136
+ },
137
+ navigate,
138
+ });
139
+ // 別のページのアンカー(`/company#access`)で移ってきた
140
+ let timer: ReturnType<typeof setTimeout> | null = null;
141
+ if (peekPendingAnchor(stateRef.current.currentContentId)) {
142
+ timer = setTimeout(() => {
143
+ timer = null;
144
+ const hash = peekPendingAnchor(stateRef.current.currentContentId);
145
+ // 作り直される途中のエディタ(1 ページ表示で利用側が読み直す前の 1 枚)なら次に任せる
146
+ if (!hash || !iframeDoc.defaultView?.frameElement?.isConnected) return;
147
+ clearPendingAnchor();
148
+ revealAnchor(iframeDoc, hash);
149
+ }, PENDING_ANCHOR_DELAY_MS);
150
+ }
151
+ return () => {
152
+ detach();
153
+ if (timer) clearTimeout(timer);
154
+ };
155
+ },
156
+ [navigate, revealAnchor],
157
+ );
158
+
159
+ return { setupLinkNavigation };
160
+ }
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import type { EditorPartDef } from '../io';
18
18
  import { findFlowInsertion } from './utils/drop-target';
19
+ import { isNonEditableTag } from './utils/dom-utils';
19
20
 
20
21
  export const PART_ATTR = 'data-part';
21
22
  export const PART_VERSION_ATTR = 'data-part-v';
@@ -318,6 +319,7 @@ export function unlockPartDescendants(root: HTMLElement): number {
318
319
  for (const el of Array.from(root.querySelectorAll('*'))) {
319
320
  if (el.closest('script, style, svg path, svg g')) continue;
320
321
  if (el.tagName === 'SCRIPT' || el.tagName === 'STYLE') continue;
322
+ if (isNonEditableTag(el)) continue;
321
323
  if (!el.getAttribute('data-element-id')) {
322
324
  el.setAttribute('data-editable', 'true');
323
325
  el.setAttribute('data-element-id', `el-${stamp}-u${i++}`);
@@ -159,6 +159,10 @@ export interface SelectedElementInfo {
159
159
  // CSS Variable references (raw inline style values if they contain var())
160
160
  rawWidth?: string; // width (e.g., "var(--spacing-lg)")
161
161
  rawHeight?: string; // height
162
+ rawMinWidth?: string; // min-width(サイズ欄の最小・最大。指定が無ければ undefined)
163
+ rawMaxWidth?: string; // max-width
164
+ rawMinHeight?: string; // min-height
165
+ rawMaxHeight?: string; // max-height
162
166
  rawPaddingTop?: string; // padding-top
163
167
  rawPaddingRight?: string; // padding-right
164
168
  rawPaddingBottom?: string; // padding-bottom
@@ -8,6 +8,9 @@
8
8
  import type { ElementCapture, DOMTreeNode, MarqueeState } from '../types';
9
9
  import { removeConflictingClasses } from './tailwind-utils';
10
10
  import { debugLog } from './debug';
11
+ // 目印クラスの定義は free-layout.ts 側が正本。ここでは選択枠の札を出すためだけに読む
12
+ // (循環importになるが、使うのは関数の中だけなので評価順の問題は起きない)
13
+ import { FREELAYOUT_CLASS } from './free-layout';
11
14
 
12
15
  // ========================================
13
16
  // buildDomTree キャッシュ(Phase 2 最適化)
@@ -295,6 +298,29 @@ export function applyCanvasZoomDom(iframeDoc: Document, zoomPct: number): void {
295
298
  if (!topAnchored && h * scale <= ch) container.scrollTop = Math.max(0, (scrollArea.offsetHeight - ch) / 2);
296
299
  }
297
300
 
301
+ /**
302
+ * 1 ページ表示のキャンバスを、横のスクロール位置 scrollLeft へ動かす。
303
+ * スクロールできる範囲の外なら #canvas-scroll-area を左右へ e ずつ広げて届かせる
304
+ * (紙面は中央寄せなので、広げると紙面が e だけ右へ動く。行き先も e だけ先へ足す)。
305
+ * 広げた幅は、次に倍率が変わったとき applyCanvasZoomDom が測り直す
306
+ */
307
+ export function scrollCanvasLeftTo(iframeDoc: Document, scrollLeft: number): void {
308
+ if (iframeDoc.body?.dataset.embedded === '1') return;
309
+ const scrollArea = iframeDoc.getElementById('canvas-scroll-area');
310
+ const container = iframeDoc.getElementById('canvas-container');
311
+ if (!scrollArea || !container) return;
312
+ const areaWidth = scrollArea.getBoundingClientRect().width;
313
+ // 負になりうる(スクロール領域が容器より狭い)。そのときスクロールできるのは 0 だけ
314
+ const maxRaw = areaWidth - container.clientWidth;
315
+ let target = scrollLeft;
316
+ if (target < 0 || target > Math.max(0, maxRaw)) {
317
+ const e = Math.max(-target, target - maxRaw);
318
+ scrollArea.style.width = `${areaWidth + e * 2}px`;
319
+ target += e;
320
+ }
321
+ container.scrollLeft = target;
322
+ }
323
+
298
324
  /* ============================ 原本への書き戻し支援 ============================
299
325
  *
300
326
  * エディタは開いた時点で全要素を絶対配置へ変換する。この変換はエディタ内の
@@ -925,6 +951,15 @@ export function restoreArtboardAutoHeight(iframeDoc: Document): void {
925
951
  }
926
952
  }
927
953
 
954
+ /**
955
+ * 編集対象(選択・レイヤー)にしない要素か。
956
+ * 改行(<br>)と改行の候補位置(<wbr>)は文字の一部で、箱として掴むものではない。
957
+ * 印を付けるとレイヤー一覧に中身の無い「シェイプ」として並び、紙面でも幅 0 の要素として選べてしまう
958
+ */
959
+ export function isNonEditableTag(el: Element): boolean {
960
+ return el.tagName === 'BR' || el.tagName === 'WBR';
961
+ }
962
+
928
963
  /**
929
964
  * DOMツリー構築の内部実装
930
965
  * キャッシュを使用しない純粋な構築処理
@@ -940,6 +975,8 @@ function buildDomTreeInternal(iframeDoc: Document, rootElement?: Element): DOMTr
940
975
 
941
976
  // スキップする要素
942
977
  if (htmlChild.tagName === 'SCRIPT' || htmlChild.tagName === 'STYLE') return;
978
+ // 改行はレイヤーにしない(印が残っていても出さない)
979
+ if (isNonEditableTag(htmlChild)) return;
943
980
  if (htmlChild.classList?.contains('selection-box')) return;
944
981
  if (htmlChild.classList?.contains('resize-handle')) return;
945
982
  if (htmlChild.classList?.contains('drawing-preview')) return;
@@ -1349,6 +1386,14 @@ function drawSelectionBox(
1349
1386
  selectionBox.appendChild(badge);
1350
1387
  }
1351
1388
  }
1389
+ // 自由配置の器は、選んだときに「ここは流し込みではない」と分かる札を出す。
1390
+ // 部品の札(左上)とぶつからないよう右上に置く
1391
+ if (mode !== 'member' && element.classList.contains(FREELAYOUT_CLASS)) {
1392
+ const badge = iframeDoc.createElement('div');
1393
+ badge.className = 'freelayout-badge';
1394
+ badge.textContent = '自由配置';
1395
+ selectionBox.appendChild(badge);
1396
+ }
1352
1397
  selectionBox.style.cssText = `
1353
1398
  left: ${relativeLeft}px;
1354
1399
  top: ${relativeTop}px;