@growgroup/visual-editor 0.8.1 → 0.9.1

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.
@@ -28,7 +28,7 @@ import { applyShadowPreset, paintTarget } from '../../utils/element-effects';
28
28
  import {
29
29
  Undo2, Redo2, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight,
30
30
  Type, Square, Circle, Minus, MoveUpRight, Image as ImageIcon, LayoutGrid,
31
- Boxes, Trash2, Copy, X, Save, PenTool, Loader2, ZoomIn, ZoomOut, Maximize,
31
+ Boxes, Blocks, Trash2, Copy, X, Save, PenTool, Loader2, ZoomIn, ZoomOut, Maximize,
32
32
  Search, Sun, Moon, ChevronDown, Plus, MousePointer2, Pencil, Play, Crosshair,
33
33
  Palette, Sparkles, EyeOff, ArrowUp, ArrowDown, Film,
34
34
  MoreHorizontal, MessageSquare, ChevronLeft, ChevronRight, Crop, Monitor, MonitorUp,
@@ -88,6 +88,8 @@ export type PptActions = {
88
88
  ungroupElements?: () => void;
89
89
  openFilePicker: () => void;
90
90
  openMediaLibrary: () => void;
91
+ /** 「台帳から挿入」。io.loadInserts が無ければ渡さない(ボタンを出さない) */
92
+ openCatalogInserts?: () => void;
91
93
  openComponents: () => void;
92
94
  openVariables: () => void;
93
95
  activeTool: EditorTool;
@@ -1739,6 +1741,9 @@ export function PptRibbon({
1739
1741
  pal={pal}
1740
1742
  />
1741
1743
  <BigButton icon={Boxes} label={'コンポー\nネント'} onClick={actions.openComponents} pal={pal} />
1744
+ {actions.openCatalogInserts && (
1745
+ <BigButton icon={Blocks} label={'台帳から\n挿入'} onClick={actions.openCatalogInserts} title="デザインシステムの台帳から挿入" pal={pal} />
1746
+ )}
1742
1747
  </>
1743
1748
  )}
1744
1749
 
@@ -223,6 +223,26 @@ export function fitRect(rect: CommentRect, artboard: HTMLElement): CommentRect {
223
223
  return { x: rect.x * sx, y: rect.y, width: rect.width * sx, height: rect.height };
224
224
  }
225
225
 
226
+ /**
227
+ * コメントの範囲。anchorRect が無くても、表示用の文言(「範囲 (x, y) w×h」「点 (x, y)」)から戻す。
228
+ *
229
+ * 【なぜ文言から戻すか】
230
+ * 範囲コメント(0.4.0)より前の提案書・構成ラフの殻は、コメントの保存で anchorRect と seq を捨て、
231
+ * anchorLabel だけを残す(殻の vite-plugin の許可リストに無いため)。そのままだと範囲を指定して投稿しても
232
+ * 紙面に枠が出ず、「範囲が表示されない」になる。文言はエディタが describeRect で書いた形なので、そこから戻せる。
233
+ * 要素に付けたコメント(anchorSrc)の anchorLabel は要素の文字なので読まない
234
+ */
235
+ export function anchorRectOf(c: Pick<SlideComment, 'anchorRect' | 'anchorLabel' | 'anchorSrc'>): CommentRect | undefined {
236
+ if (c.anchorRect) return c.anchorRect;
237
+ if (c.anchorSrc || !c.anchorLabel) return undefined;
238
+ const num = '(-?\\d+(?:\\.\\d+)?)';
239
+ const range = c.anchorLabel.match(new RegExp(`^範囲 \\(${num}, ${num}\\) ${num}×${num}$`));
240
+ if (range) return { x: +range[1], y: +range[2], width: +range[3], height: +range[4] };
241
+ const point = c.anchorLabel.match(new RegExp(`^点 \\(${num}, ${num}\\)$`));
242
+ if (point) return { x: +point[1], y: +point[2], width: 0, height: 0 };
243
+ return undefined;
244
+ }
245
+
226
246
  /** 矩形を人が読む形に(「範囲 (120, 840) 600×240」「点 (120, 840)」) */
227
247
  export function describeRect(r: CommentRect): string {
228
248
  const n = (v: number) => String(Math.round(v));
@@ -282,7 +302,7 @@ export function useCommentMarkers({
282
302
  const artboard = doc?.getElementById('artboard');
283
303
  if (!doc || !artboard) return;
284
304
  const layer = ensureCommentLayer(doc, artboard);
285
- const shown = comments.filter((c) => (c.anchorRect || c.anchorSrc) && !c.resolved);
305
+ const shown = comments.filter((c) => (anchorRectOf(c) || c.anchorSrc) && !c.resolved);
286
306
  const wanted = new Set(shown.map((c) => c.id));
287
307
  layer.querySelectorAll<HTMLElement>('[data-comment-id]').forEach((el) => {
288
308
  if (!wanted.has(el.dataset.commentId!)) el.remove();
@@ -318,8 +338,9 @@ export function useCommentMarkers({
318
338
  const isActive = c.id === activeThreadId;
319
339
  let x = 0;
320
340
  let y = 0;
321
- if (c.anchorRect) {
322
- const r = fitRect(c.anchorRect, artboard);
341
+ const anchorRect = anchorRectOf(c);
342
+ if (anchorRect) {
343
+ const r = fitRect(anchorRect, artboard);
323
344
  if (isPointRect(r)) {
324
345
  layer.querySelector(`[data-comment-id="${CSS.escape(c.id)}"][data-comment-rect]`)?.remove();
325
346
  x = r.x - size / 2;
@@ -404,7 +425,7 @@ export function useCommentMarkers({
404
425
  }, [
405
426
  active, page, getIframeDoc, outerZoom, activeThreadId,
406
427
  JSON.stringify(draftRect),
407
- JSON.stringify(comments.map((c) => [c.id, c.anchorSrc, c.anchorRect, c.seq, c.resolved, c.author, c.text])),
428
+ JSON.stringify(comments.map((c) => [c.id, c.anchorSrc, c.anchorRect, c.anchorLabel, c.seq, c.resolved, c.author, c.text])),
408
429
  ]);
409
430
  }
410
431
 
@@ -770,10 +791,10 @@ export function PptCommentsPanel({
770
791
  </button>
771
792
  </div>
772
793
 
773
- {c.anchorRect ? (
794
+ {anchorRectOf(c) ? (
774
795
  <div className="mt-1.5 flex items-center gap-1 text-[10px]" style={{ color: 'var(--ed-accent)' }} data-comment-rect-label>
775
796
  <Crosshair className="h-3 w-3" />
776
- {describeRect(c.anchorRect)}
797
+ {describeRect(anchorRectOf(c)!)}
777
798
  </div>
778
799
  ) : c.anchorSrc && (
779
800
  <div className="mt-1.5 flex items-center gap-1 text-[10px]" style={{ color: 'var(--ed-accent)' }}>
@@ -25,7 +25,7 @@ export { useContextMenuHandler } from './useContextMenuHandler';
25
25
  export { useIframeSetup } from './useIframeSetup';
26
26
  export { useFocusManagement } from './useFocusManagement';
27
27
  export { useInlineTextSelection } from './useInlineTextSelection';
28
- export { useLinkNavigation } from './useLinkNavigation';
28
+ export { useLinkNavigation, useRevealElement } from './useLinkNavigation';
29
29
  export { useKeepArtboardInPlace } from './useKeepArtboardInPlace';
30
30
  export { useResizablePanel } from './useResizablePanel';
31
31
 
@@ -9,6 +9,7 @@ import { useCallback, useRef, useEffect, MutableRefObject } from 'react';
9
9
  import { useEditorContext } from '../EditorContext';
10
10
  import { updateSelectionBox } from '../utils/dom-utils';
11
11
  import { extractElementInfo } from '../utils/style-utils';
12
+ import { passThroughSvg } from '../utils/svg-hit';
12
13
 
13
14
  interface UseContextMenuHandlerOptions {
14
15
  /** 編集可能要素を取得する関数 */
@@ -86,7 +87,8 @@ export function useContextMenuHandler(
86
87
  const y = iframeRect.top + e.clientY * outerScale;
87
88
 
88
89
  // 要素がクリックされた場合は選択
89
- const element = getEditableElement(e.target, iframeDoc);
90
+ // SVG の箱の空きは素通りさせる(クリックの判別と同じ。utils/svg-hit.ts)
91
+ const element = getEditableElement(passThroughSvg(iframeDoc, e.target, e.clientX, e.clientY), iframeDoc);
90
92
  if (element) {
91
93
  const elementId = element.getAttribute('data-element-id') || '';
92
94
  const currentIds = selectedElementIdsRef.current;
@@ -26,6 +26,7 @@ import { extractElementInfo } from "../utils/style-utils";
26
26
  import { MARQUEE_DRAG_THRESHOLD } from "../constants";
27
27
  import type { DragState, ResizeState, MarqueeState } from "../types";
28
28
  import { debugLog } from '../utils/debug';
29
+ import { passThroughSvg } from '../utils/svg-hit';
29
30
 
30
31
  /**
31
32
  * mousedown で保留し、mouseup で意味を確定するクリック情報
@@ -1181,6 +1182,9 @@ export function useElementSelection(
1181
1182
  ),
1182
1183
  ) ?? null;
1183
1184
  }
1185
+ // SVG の箱の中で何も描かれていない所を押したら、SVG を素通りして下の要素を押したことにする。
1186
+ // 装飾の SVG が文字やボタンに重なっていると、素のクリックも Cmd+クリックも SVG しか選べなかった(utils/svg-hit.ts)
1187
+ hitSource = passThroughSvg(iframeDoc, hitSource, e.clientX, e.clientY);
1184
1188
  const hit = getEditableElement(hitSource, iframeDoc);
1185
1189
  // 空白=押した点の祖先に編集可能要素がまったく無い状態。
1186
1190
  // #artboard / 紙面と同じ大きさの器 / #artboard-wrapper / #canvas-container は
@@ -1432,7 +1436,7 @@ export function useElementSelection(
1432
1436
 
1433
1437
 
1434
1438
  const isCtrlCmd = e.ctrlKey || e.metaKey;
1435
- const deepElement = getEditableElement(e.target, iframeDoc);
1439
+ const deepElement = getEditableElement(passThroughSvg(iframeDoc, e.target, e.clientX, e.clientY), iframeDoc);
1436
1440
 
1437
1441
  if (!deepElement) return;
1438
1442
 
@@ -12,6 +12,7 @@ import { buildDomTree, isInlineElement, isNonEditableTag } from '../utils/dom-ut
12
12
  import { EDITOR_IFRAME_STYLES } from '../constants';
13
13
  import { isLockedInsidePart } from '../parts';
14
14
  import { debugLog } from '../utils/debug';
15
+ import { isInsideSvg, passThroughSvg } from '../utils/svg-hit';
15
16
 
16
17
  interface UseIframeSetupReturn {
17
18
  /**
@@ -107,7 +108,14 @@ export function useIframeSetup(): UseIframeSetupReturn {
107
108
  element.removeAttribute('data-inline');
108
109
  return;
109
110
  }
110
- if (element.closest('script, style, svg path, svg g')) return;
111
+ if (element.closest('script, style')) return;
112
+ // SVG の中(path / circle / g など)は SVG ごと 1 つの図として扱う(utils/svg-hit.ts)。
113
+ // 以前は path と g だけを外していたので、circle や rect が単独で選ばれていた
114
+ if (isInsideSvg(element)) {
115
+ element.removeAttribute('data-editable');
116
+ element.removeAttribute('data-element-id');
117
+ return;
118
+ }
111
119
  if (element.classList.contains('material-icons')) return;
112
120
  if (element.classList.contains('material-icons-outlined')) return;
113
121
  // selection-box およびその子要素はスキップ
@@ -274,7 +282,8 @@ export function useIframeSetup(): UseIframeSetupReturn {
274
282
  clearPreview();
275
283
  return;
276
284
  }
277
- const next = resolveHoverTarget(e.target, iframeDoc, {
285
+ // SVG の箱の空きは素通りさせる(クリックの判別と同じ。utils/svg-hit.ts)
286
+ const next = resolveHoverTarget(passThroughSvg(iframeDoc, e.target, e.clientX, e.clientY), iframeDoc, {
278
287
  meta: e.metaKey || e.ctrlKey,
279
288
  });
280
289
  if (next === currentPreviewElement) return;
@@ -27,18 +27,21 @@ const PENDING_ANCHOR_DELAY_MS = 500;
27
27
  const prefersReducedMotion = () =>
28
28
  typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
29
29
 
30
- export function useLinkNavigation() {
31
- const { contentList, currentContentId, onContentChange } = useEditorContext();
30
+ /**
31
+ * 紙面の中の要素が見える位置へ視点を移す。
32
+ *
33
+ * キャンバスでは紙面(iframe)は伸びきっていてスクロールしないので、`scrollIntoView` では
34
+ * 何も動かない。キャンバスの位置を動かすか、1 ページ表示なら紙面の中の器をスクロールさせる。
35
+ * リンク移動(アンカー)と「台帳から挿入」のあとの移動で、同じ計算を使う。
36
+ */
37
+ export function useRevealElement() {
32
38
  const canvas = useMultiPageCanvasOptional();
33
- const stateRef = useRef({ contentList, currentContentId, onContentChange, canvas });
34
- stateRef.current = { contentList, currentContentId, onContentChange, canvas };
39
+ const canvasRef = useRef(canvas);
40
+ canvasRef.current = canvas;
35
41
 
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;
42
+ return useCallback((iframeDoc: Document, el: Element | null): boolean => {
40
43
  if (!el) return false;
41
- const c = stateRef.current.canvas;
44
+ const c = canvasRef.current;
42
45
 
43
46
  if (c?.isEnabled) {
44
47
  // キャンバス: 紙面(iframe)は伸びきっていてスクロールしない。キャンバスの位置を動かす
@@ -77,6 +80,23 @@ export function useLinkNavigation() {
77
80
  });
78
81
  return true;
79
82
  }, []);
83
+ }
84
+
85
+ export function useLinkNavigation() {
86
+ const { contentList, currentContentId, onContentChange } = useEditorContext();
87
+ const canvas = useMultiPageCanvasOptional();
88
+ const stateRef = useRef({ contentList, currentContentId, onContentChange, canvas });
89
+ stateRef.current = { contentList, currentContentId, onContentChange, canvas };
90
+ const revealElement = useRevealElement();
91
+
92
+ /** 紙面の中の要素(`'top'` なら先頭)が見える位置へ紙面を移す。見つからなければ false */
93
+ const revealAnchor = useCallback(
94
+ (iframeDoc: Document, hash: string): boolean => {
95
+ const found = findAnchorTarget(iframeDoc, hash);
96
+ return revealElement(iframeDoc, found === 'top' ? iframeDoc.getElementById('artboard') : found);
97
+ },
98
+ [revealElement],
99
+ );
80
100
 
81
101
  const navigate = useCallback(
82
102
  (target: LinkTarget, iframeDoc: Document): string | void => {
@@ -17,6 +17,7 @@
17
17
  import type { EditorPartDef } from '../io';
18
18
  import { findFlowInsertion } from './utils/drop-target';
19
19
  import { isNonEditableTag } from './utils/dom-utils';
20
+ import { isInsideSvg } from './utils/svg-hit';
20
21
 
21
22
  export const PART_ATTR = 'data-part';
22
23
  export const PART_VERSION_ATTR = 'data-part-v';
@@ -317,7 +318,7 @@ export function unlockPartDescendants(root: HTMLElement): number {
317
318
  let i = 0;
318
319
  const stamp = Date.now();
319
320
  for (const el of Array.from(root.querySelectorAll('*'))) {
320
- if (el.closest('script, style, svg path, svg g')) continue;
321
+ if (el.closest('script, style') || isInsideSvg(el)) continue;
321
322
  if (el.tagName === 'SCRIPT' || el.tagName === 'STYLE') continue;
322
323
  if (isNonEditableTag(el)) continue;
323
324
  if (!el.getAttribute('data-element-id')) {
@@ -0,0 +1,79 @@
1
+ /**
2
+ * 「台帳から挿入」の DOM まわり。React に依存しない純粋なユーティリティ。
3
+ *
4
+ * 利用側(io.fetchInsert)は Tailwind のクラスが付いたままの静的 HTML を断片で返す。
5
+ * 色はトークン名なので、紙面の CSS が解決できるものだけが色になる(解決できなければ地の色)。
6
+ * エディタは中身を組み替えず、
7
+ * 1. `<script>` を落とす
8
+ * 2. 要素にする
9
+ * 3. どこに入れるかを決める
10
+ * の 3 つだけをやる。
11
+ */
12
+ import { fallbackContainer, isContainer } from './drop-target';
13
+
14
+ /**
15
+ * 紙面に `<script>` を持ち込まない。
16
+ *
17
+ * 【なぜ paste-sanitizer(DOMPurify)を通さないか】
18
+ * あちらは貼り付け用で、許可する属性を列挙する作り。`data-ds` / `data-ds-v` / `data-slot` の
19
+ * ような台帳側の目印が黙って消える。挿入元は利用側が自分で作った HTML(外部から来た文字列ではない)
20
+ * なので、ここでは「紙面で script を動かさない」という既存の方針だけを守る。
21
+ * 同じ理由で `on*` のイベント属性も落とす(紙面は sandbox で script を動かさないが、
22
+ * 保存 HTML に残ると公開物に混ざるため)。
23
+ */
24
+ export function stripScripts(root: Element): void {
25
+ for (const el of Array.from(root.querySelectorAll('script'))) el.remove();
26
+ const all: Element[] = [root, ...Array.from(root.querySelectorAll('*'))];
27
+ for (const el of all) {
28
+ for (const attr of Array.from(el.attributes)) {
29
+ if (/^on/i.test(attr.name)) el.removeAttribute(attr.name);
30
+ }
31
+ }
32
+ }
33
+
34
+ /**
35
+ * HTML の断片を `doc` の要素にする。
36
+ * ルートが 2 つ以上あるときは `<div>` で束ねずに先頭だけを返す ── 利用側が
37
+ * `<section class="wf-ds">` で包む約束なので、束ねる器をエディタが勝手に足すと
38
+ * 保存 HTML に出どころ不明の `<div>` が増える。空なら null。
39
+ */
40
+ export function insertHtmlToElement(doc: Document, html: string): HTMLElement | null {
41
+ const tpl = doc.createElement('template');
42
+ tpl.innerHTML = html.trim();
43
+ const root = tpl.content.firstElementChild;
44
+ if (!root) return null;
45
+ const el = doc.importNode(root, true) as HTMLElement;
46
+ stripScripts(el);
47
+ return el;
48
+ }
49
+
50
+ /** 挿入先。`before` が null なら `parent` の末尾 */
51
+ export type InsertionPoint = { parent: Element; before: Element | null };
52
+
53
+ /**
54
+ * 挿入位置の規則。
55
+ *
56
+ * - 選択中の要素があれば**その直後**。ただし選択が器(`<main>` など)の直下でなければ、
57
+ * 器の直下まで上がった祖先の直後に入れる(セクションの列に並ぶ)
58
+ * - 選択が無ければ器の末尾
59
+ * - 器 = `<main>` / `[data-wf-body]` / `#artboard` / body(drop-target.ts と同じ判定)
60
+ *
61
+ * ドラッグ&ドロップの `findFlowInsertion` と器の規則を共有しているので、
62
+ * 「ドロップしたときと挿入したときで入る場所が違う」が起きない。
63
+ */
64
+ export function resolveInsertionPoint(doc: Document, selected: Element | null): InsertionPoint {
65
+ const container = fallbackContainer(doc);
66
+ if (!selected || !selected.isConnected) return { parent: container, before: null };
67
+
68
+ // 選択そのものが器なら、その中の末尾に入れる(器の「直後」に入れると紙面の外に出る)
69
+ if (isContainer(doc, selected)) return { parent: selected, before: null };
70
+
71
+ // 器の直下まで上がる
72
+ let node: Element = selected;
73
+ for (;;) {
74
+ const parent: Element | null = node.parentElement;
75
+ if (!parent) return { parent: container, before: null };
76
+ if (isContainer(doc, parent)) return { parent, before: node.nextElementSibling };
77
+ node = parent;
78
+ }
79
+ }
@@ -36,7 +36,7 @@ export interface FlowInsertion {
36
36
  * 「セクションの器」か。この直下に部品を並べる。
37
37
  * 器 = body / #artboard / [data-wf-body] / main
38
38
  */
39
- function isContainer(doc: Document, node: Element | null): node is Element {
39
+ export function isContainer(doc: Document, node: Element | null): node is Element {
40
40
  return (
41
41
  !!node &&
42
42
  (node === doc.body ||
@@ -46,8 +46,12 @@ function isContainer(doc: Document, node: Element | null): node is Element {
46
46
  );
47
47
  }
48
48
 
49
- /** 器が見つからないときの落とし先 */
50
- function fallbackContainer(doc: Document): Element {
49
+ /**
50
+ * 器が見つからないときの落とし先。
51
+ * 「台帳から挿入」(選択が無いとき・選択の祖先が器の直下に無いとき)も同じ器を使うので export する。
52
+ * 器の規則をここ 1 箇所に残さないと、ドロップと挿入で入る場所が変わる
53
+ */
54
+ export function fallbackContainer(doc: Document): Element {
51
55
  return (
52
56
  doc.querySelector('main') ??
53
57
  doc.querySelector('[data-wf-body]') ??
@@ -0,0 +1,141 @@
1
+ /**
2
+ * SVG をクリックしたときの当たり判定。
3
+ *
4
+ * 【なぜ要るか】
5
+ * HTML の中の `<svg>` は、描いた線や塗りが無い所も含めて**箱全体**でクリックを受ける。
6
+ * 装飾の SVG(斜めの線・背景の図形)が文字やボタンの上に重なっていると、その箱の中を
7
+ * どこを押しても SVG が選ばれ、下の要素を選べなかった。Cmd/Ctrl+クリック(最下層を選ぶ)も
8
+ * 押した点の最上面から探すので同じだった。
9
+ *
10
+ * 【どう直したか】
11
+ * Figma と同じく、見えている形(線・塗り・画像)の上を押したときだけ SVG を選び、
12
+ * 箱の中の何も描かれていない所は**素通り**して下の要素を選ぶ。
13
+ * 形の上かどうかは elementsFromPoint で見る。SVG の子(path / circle など)は
14
+ * 描かれている所でしか当たらない(pointer-events の既定 visiblePainted)ので、
15
+ * その点に子が返らなければ「箱の空き」と分かる。細い線は掴みにくいので、周り 3px も見る。
16
+ *
17
+ * SVG の中の要素(path / circle / g など)は、SVG ごと 1 つの図として扱う。
18
+ * 編集の印(data-editable)は付けない(isInsideSvg)。
19
+ */
20
+
21
+ /** エディタが紙面に重ねる層(選択枠・マーキー・ガイド)。当たり判定から外す */
22
+ const EDITOR_LAYERS =
23
+ '.selection-box,.marquee-selection-box,[data-editor-overlay],#gg-smart-guides,#gg-measure-layer,.gg-collab-layer';
24
+
25
+ /** 細い線を掴めるようにする許容(px) */
26
+ const STROKE_TOLERANCE = 3;
27
+
28
+ /** SVG の中の要素か(`<svg>` 自身は含まない)。中身は SVG ごと 1 つの図として扱う */
29
+ export function isInsideSvg(el: Element): boolean {
30
+ const parent = el.parentElement;
31
+ return !!parent && typeof parent.closest === 'function' && !!parent.closest('svg');
32
+ }
33
+
34
+ /** 入れ子の SVG なら一番外の `<svg>` */
35
+ function outermostSvg(el: Element): Element | null {
36
+ let svg = typeof el.closest === 'function' ? el.closest('svg') : null;
37
+ while (svg) {
38
+ const up = svg.parentElement?.closest('svg') ?? null;
39
+ if (!up) return svg;
40
+ svg = up;
41
+ }
42
+ return null;
43
+ }
44
+
45
+ function isEditorLayer(el: Element): boolean {
46
+ return typeof el.closest === 'function' && !!el.closest(EDITOR_LAYERS);
47
+ }
48
+
49
+ /** SVG 自身に背景(CSS の background)が塗られていれば、箱全体が見えている */
50
+ function hasOwnBackground(svg: Element, doc: Document): boolean {
51
+ const cs = doc.defaultView?.getComputedStyle(svg);
52
+ if (!cs) return false;
53
+ const bg = cs.backgroundColor;
54
+ const transparent = !bg || bg === 'transparent' || /rgba\([^)]*,\s*0\)$/.test(bg);
55
+ return !transparent || (cs.backgroundImage && cs.backgroundImage !== 'none') || false;
56
+ }
57
+
58
+ /**
59
+ * SVG を包んでいるだけの透明な器か(図形ツールで描いた線・矢印・ペンは
60
+ * `<div style="position:absolute;background-color:transparent"><svg …></div>` の形)。
61
+ * 子が全部「素通りさせた要素」で、自分の文字も背景・枠線・影も無ければ、器も素通りさせる。
62
+ * これが無いと、SVG を素通りした先で器の箱が当たり、線の周りの空きで結局同じことが起きる
63
+ */
64
+ function isBareWrapper(el: Element, doc: Document, skipped: Set<Element>): boolean {
65
+ if (el.children.length === 0) return false;
66
+ if (!Array.from(el.children).every((c) => skipped.has(c))) return false;
67
+ if (Array.from(el.childNodes).some((n) => n.nodeType === 3 && (n.textContent ?? '').trim() !== '')) return false;
68
+ const cs = doc.defaultView?.getComputedStyle(el);
69
+ if (!cs) return false;
70
+ const paintsBox =
71
+ hasOwnBackground(el, doc) ||
72
+ ['Top', 'Right', 'Bottom', 'Left'].some(
73
+ (s) => parseFloat(cs.getPropertyValue(`border-${s.toLowerCase()}-width`)) > 0 && cs.getPropertyValue(`border-${s.toLowerCase()}-style`) !== 'none',
74
+ ) ||
75
+ (!!cs.boxShadow && cs.boxShadow !== 'none');
76
+ // outline は見ない。エディタが編集できる要素すべてに透明な outline(ホバー予告用)を付けているため
77
+ return !paintsBox;
78
+ }
79
+
80
+ /**
81
+ * 図形ツールの線・矢印・ペンは、位置と大きさを持つ透明な器の中に `position:absolute` の SVG が 1 つだけ入る。
82
+ * 動かす・大きさを変える対象は器なので、SVG を押したら器を選ぶ(SVG を選ぶと器の中で SVG だけが動く)
83
+ */
84
+ function drawnShapeOf(svg: Element, doc: Document): Element {
85
+ const parent = svg.parentElement;
86
+ if (!parent || parent.children.length !== 1) return svg;
87
+ if (parent.hasAttribute('data-gg-fx-host')) return svg;
88
+ if (doc.defaultView?.getComputedStyle(svg).position !== 'absolute') return svg;
89
+ return isBareWrapper(parent, doc, new Set([svg])) ? parent : svg;
90
+ }
91
+
92
+ /** (x, y) の近くで、その SVG の形(子要素)が描かれているか */
93
+ function paintedNear(doc: Document, svg: Element, x: number, y: number): boolean {
94
+ const t = STROKE_TOLERANCE;
95
+ const offsets = [[0, 0], [t, 0], [-t, 0], [0, t], [0, -t], [t, t], [t, -t], [-t, t], [-t, -t]];
96
+ for (const [dx, dy] of offsets) {
97
+ for (const el of doc.elementsFromPoint(x + dx, y + dy)) {
98
+ if (el !== svg && svg.contains(el)) return true;
99
+ }
100
+ }
101
+ return false;
102
+ }
103
+
104
+ /**
105
+ * クリック(ホバー・右クリック・ダブルクリック)の対象を、SVG の空きを素通りさせて決める。
106
+ * SVG と関係の無い点では target をそのまま返す(elementsFromPoint も呼ばない)。
107
+ *
108
+ * @param target 生のイベント対象(e.target、または選択枠の下から拾った要素)
109
+ * @param x, y 紙面(iframe)の中の座標(e.clientX / e.clientY)
110
+ * @returns 選択の判別に使う要素。空白(body 等)になることもある
111
+ */
112
+ export function passThroughSvg(
113
+ doc: Document,
114
+ target: EventTarget | null,
115
+ x: number,
116
+ y: number,
117
+ ): Element | null {
118
+ const el = target as Element | null;
119
+ if (!el || el.nodeType !== 1 || typeof el.closest !== 'function') return el;
120
+ if (!el.closest('svg')) return el;
121
+ if (typeof doc.elementsFromPoint !== 'function') return el;
122
+
123
+ const skipped = new Set<Element>();
124
+ for (const candidate of doc.elementsFromPoint(x, y)) {
125
+ if (isEditorLayer(candidate)) continue;
126
+ const svg = outermostSvg(candidate);
127
+ if (!svg) {
128
+ // 素通りさせた SVG を包むだけの透明な器なら、それも素通りする
129
+ if (skipped.size > 0 && isBareWrapper(candidate, doc, skipped)) {
130
+ skipped.add(candidate);
131
+ continue;
132
+ }
133
+ return candidate; // SVG の外の要素 = 下にあるもの
134
+ }
135
+ if (skipped.has(svg)) continue;
136
+ if (candidate !== svg) return drawnShapeOf(svg, doc); // 形の上を押した = その SVG
137
+ if (hasOwnBackground(svg, doc) || paintedNear(doc, svg, x, y)) return drawnShapeOf(svg, doc);
138
+ skipped.add(svg); // 箱の空き = 素通りして下を見る
139
+ }
140
+ return null;
141
+ }
@@ -627,8 +627,12 @@ export function applyTailwindStyles(
627
627
  /**
628
628
  * Tailwind Browserに再コンパイルをトリガー
629
629
  * iframe内のTailwind Browserインスタンスに変更を通知
630
+ *
631
+ * 紙面へ**新しいクラスを持つ要素を足した**ときにも呼ぶ(「台帳から挿入」)。
632
+ * 足しただけでは Tailwind ブラウザ版がその class を知らず、当たるまで地の姿で出る。
633
+ * 生成した CSS は紙面の `<head>` に入るので、#artboard の中身(= 保存 HTML・履歴)は変わらない。
630
634
  */
631
- function triggerTailwindRecompile(element: HTMLElement): void {
635
+ export function triggerTailwindRecompile(element: HTMLElement): void {
632
636
  try {
633
637
  const doc = element.ownerDocument;
634
638
  const win = doc?.defaultView as Window & {
package/src/index.ts CHANGED
@@ -60,6 +60,10 @@ export type {
60
60
  EditorPartDef,
61
61
  EditorPartCategory,
62
62
  EditorPartsLibrary,
63
+ EditorInsertCatalog,
64
+ EditorInsertGroup,
65
+ EditorInsertItem,
66
+ EditorInsertLevel,
63
67
  EditorCollab,
64
68
  EditorCollabUser,
65
69
  } from "./io";
package/src/io.ts CHANGED
@@ -122,6 +122,53 @@ export type EditorPartsLibrary = {
122
122
  categories?: EditorPartCategory[];
123
123
  };
124
124
 
125
+ /**
126
+ * 台帳から挿入(「台帳から挿入」パネル)。
127
+ *
128
+ * 【役割分担】
129
+ * - 利用側 … 何が挿せるかの目録(loadInserts)と、1 件の HTML(fetchInsert)を作る。
130
+ * HTML は断片(`<section class="wf-ds" data-ds="…" data-ds-v="…">…</section>` の形で
131
+ * 利用側が包んでおく)。エディタは包み直さない
132
+ * - エディタ … 紙面のどこに入れるかを決め、編集の印を付け、履歴に 1 段で載せる
133
+ *
134
+ * 目録・HTML の中身(何が台帳にあるか・どう整形するか)はエディタの関心事ではない。
135
+ * `loadInserts` を渡さなければ「台帳から挿入」の入口ごと出さない。
136
+ */
137
+ export type EditorInsertLevel =
138
+ /** セクション(紙面に 1 段として入る) */
139
+ | 'SEC'
140
+ /** パーツ(セクションより小さい部品) */
141
+ | 'MOL'
142
+ /** ページの雛形(挿入ではなく「新しいページを作る」) */
143
+ | 'TPL'
144
+ /** ページ(同上) */
145
+ | 'PAG'
146
+ // 利用側が独自の階層を足せるように文字列も受ける(補完は上の 4 つが出る)
147
+ | (string & {});
148
+
149
+ export type EditorInsertItem = {
150
+ /** 一意な識別子。`fetchInsert` / `createContent` にそのまま渡す */
151
+ id: string;
152
+ /** 表示名 */
153
+ name: string;
154
+ /** 系統(絞り込みの単位。ヘッダー・ヒーロー・フォーム など) */
155
+ family?: string;
156
+ description?: string;
157
+ /**
158
+ * 階層。`TPL` / `PAG` は紙面に挿さず「ページを作る」に回る。
159
+ * 省略時は挿入(セクション扱い)
160
+ */
161
+ level?: EditorInsertLevel;
162
+ /** 見本の URL(あればカードと右の大きいプレビューに iframe で出す) */
163
+ previewUrl?: string;
164
+ /** 出どころ。表示の区別だけに使う */
165
+ source?: 'ledger' | 'local';
166
+ };
167
+
168
+ export type EditorInsertGroup = { id: string; label: string; items: EditorInsertItem[] };
169
+
170
+ export type EditorInsertCatalog = { groups: EditorInsertGroup[] };
171
+
125
172
  export type EditorIO = {
126
173
  /** 一覧とコメントを取る。無ければ空の一覧として振る舞う */
127
174
  loadDeck?: () => Promise<EditorDeck>;
@@ -192,6 +239,30 @@ export type EditorIO = {
192
239
  /** 部品を消す。無ければ削除を出さない */
193
240
  deletePart?: (id: string) => Promise<void>;
194
241
 
242
+ /**
243
+ * 台帳の目録(利用側が束ねる)。無ければ「台帳から挿入」は出さない。
244
+ * パネルを開くたびに呼ぶ(利用側で束ねたものを返すだけでよい)
245
+ */
246
+ loadInserts?: () => Promise<EditorInsertCatalog>;
247
+ /**
248
+ * 1 件の HTML(断片)。`dummy` は見出し・リード文を赤字ダミーにする利用側の整形で、
249
+ * パネルのチェック(既定 ON)がそのまま渡る。整形しない利用側は無視してよい。
250
+ * `name` はいまのエディタでは使っていない(目録の name を出している)。
251
+ * 目録と実体が食い違いうる利用側のために受け口だけ空けてある
252
+ */
253
+ fetchInsert?: (id: string, opts: { dummy: boolean }) => Promise<{ html: string; name?: string }>;
254
+ /**
255
+ * 新しいページを作る(TPL / PAG の挿入)。戻り値の id で利用側が `contentList` を
256
+ * 読み直し、エディタはその id が一覧に現れたらそのページを編集中にする。
257
+ * 無ければページの雛形は挿入できない(グループを出しても「ページを作る」は押せない)
258
+ */
259
+ createContent?: (input: {
260
+ title: string;
261
+ path: string;
262
+ templateId?: string;
263
+ parentId?: string;
264
+ }) => Promise<{ id: string }>;
265
+
195
266
  /** CSS 変数(デザイントークン)の読み込み。無ければブラウザ内(localStorage)に持つ */
196
267
  loadVariables?: () => Promise<CSSVariableDefinition[]>;
197
268
  /** CSS 変数の保存。無ければブラウザ内(localStorage)に持つ */
package/src/version.ts CHANGED
@@ -6,4 +6,4 @@
6
6
  * 利用側はこの値と node_modules の package.json を見比べて、食い違っていれば
7
7
  * 「再起動してください」と出せる(構成ラフのテンプレートが行っている)
8
8
  */
9
- export const EDITOR_VERSION = '0.8.1';
9
+ export const EDITOR_VERSION = '0.9.1';