@growgroup/visual-editor 0.8.0 → 0.9.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.
@@ -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 => {
@@ -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
+ }
@@ -731,6 +731,47 @@ export function convertToAbsolutePositioning(iframeDoc: Document): number {
731
731
  });
732
732
  });
733
733
 
734
+ // 1.5 編集対象でない、流れの中のブロックの兄弟も一緒に倒す。
735
+ // 部品(data-part)の中では data-slot だけが編集対象なので、スロットの間にある罫線(h-px の div)などは
736
+ // 流れに残る。兄弟が絶対配置で抜けると、その分だけ上へ詰まる(ヘッダーの罫線が 116px 上へ動いた実測)。
737
+ // 自己検証は編集対象しか見ていなかったので、崩れたまま確定していた。
738
+ // 位置を固定するだけで編集対象にはしない(data-editable は付けない)
739
+ const captured = new Set<HTMLElement>(captures.map((c) => c.element));
740
+ const parentsOfCaptured = new Set<HTMLElement>();
741
+ captures.forEach(({ parent }) => {
742
+ if (parent && parent !== iframeDoc.body && parent !== artboard) parentsOfCaptured.add(parent);
743
+ });
744
+ parentsOfCaptured.forEach((parent) => {
745
+ Array.from(parent.children).forEach((child) => {
746
+ const element = child as HTMLElement;
747
+ if (captured.has(element) || skippedInline.some((s) => s.element === element)) return;
748
+ if (element.classList.contains('selection-box') || element.classList.contains('gg-comment-layer')) return;
749
+ if (isNonEditableTag(element)) return;
750
+ const computedStyle = iframeDoc.defaultView!.getComputedStyle(element);
751
+ // 流れの外(absolute / fixed)・非表示は兄弟に影響されない
752
+ if (computedStyle.position === 'absolute' || computedStyle.position === 'fixed') return;
753
+ if (computedStyle.display === 'none') return;
754
+ const parentDisplay = iframeDoc.defaultView!.getComputedStyle(parent).display;
755
+ const isFlexOrGridItem = /(^|\s)(inline-)?(flex|grid)($|\s)/.test(parentDisplay);
756
+ if (!isFlexOrGridItem && isInlineElement(element, computedStyle)) {
757
+ skippedInline.push({ element, rect: element.getBoundingClientRect() });
758
+ return;
759
+ }
760
+ captured.add(element);
761
+ captures.push({
762
+ element,
763
+ rect: element.getBoundingClientRect(),
764
+ parent,
765
+ parentRect: parent.getBoundingClientRect(),
766
+ computedStyle,
767
+ parentComputedStyle: iframeDoc.defaultView!.getComputedStyle(parent),
768
+ originalTransform: computedStyle.transform,
769
+ scale,
770
+ prevStyle: element.getAttribute('style'),
771
+ });
772
+ });
773
+ });
774
+
734
775
  // 2. 親要素を処理(position: relative設定)
735
776
  //
736
777
  // [採寸より前にやる] offsetLeft/offsetTop は offsetParent 基準の値なので、
@@ -768,13 +809,18 @@ export function convertToAbsolutePositioning(iframeDoc: Document): number {
768
809
  if (element.offsetParent !== parent) return;
769
810
  // 寸法は offsetWidth(整数へ丸める)ではなく、rect の小数値を**切り上げ**て使う。
770
811
  // 例: 必要幅1227.4pxの文字列を1227pxで固定すると最後の1文字だけが折り返す
771
- // (284pxの「テキスト」で実害)。位置は1px未満の誤差が折り返しを生まないので offset で良い
812
+ // (284pxの「テキスト」で実害)。位置は1px未満の誤差が折り返しを生まないので offset で良い。
813
+ // ただし rect は縮小した紙面の値で、ブラウザは 1/64px に丸めて返す(56px × 0.55 = 30.8 → 30.8125)。
814
+ // 倍率で割り戻すと 56.02 のように少しだけ大きくなり、そのまま切り上げると全部の要素が 1px 膨らむ
815
+ // (h-px の罫線が 2px、56px の帯が 57px になった実測)。丸めの分だけ引いてから切り上げる
772
816
  const s = capScale || 1;
817
+ const quantum = 1 / 64 / s + 0.005;
818
+ const ceilSize = (v: number) => Math.max(0, Math.ceil(v - quantum));
773
819
  offsets.set(element, {
774
820
  left: element.offsetLeft,
775
821
  top: element.offsetTop,
776
- width: Math.ceil(rect.width / s),
777
- height: Math.ceil(rect.height / s),
822
+ width: ceilSize(rect.width / s),
823
+ height: ceilSize(rect.height / s),
778
824
  });
779
825
  });
780
826
 
@@ -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]') ??
@@ -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.0';
9
+ export const EDITOR_VERSION = '0.9.0';