@growgroup/visual-editor 0.2.0 → 0.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growgroup/visual-editor",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "HTMLをブラウザ上で直接編集するビジュアルエディタ。スライド(固定サイズ)とWebページ(可変高さ)の両方に使え、保存先はアダプタで差し替えられます。",
5
5
  "keywords": [
6
6
  "visual-editor",
@@ -1293,7 +1293,7 @@ export const EditorPropertyPanel = memo(function EditorPropertyPanel() {
1293
1293
  <span className="truncate font-medium">部品: {selectedPartLabel}</span>
1294
1294
  </div>
1295
1295
  <p className="text-[10px] leading-snug text-gray-500">
1296
- スロット(data-slot)の中だけ編集できます。定義を変えたあとの他ページへの反映は parts:sync。
1296
+ スロット(data-slot)の中だけ編集できます。「この姿で部品を更新」で定義が変わり、他ページへの反映は利用側(savePart)が行います。
1297
1297
  </p>
1298
1298
  <div className="flex gap-1">
1299
1299
  <Button
@@ -683,11 +683,11 @@ export function useComponentEditMode({
683
683
  });
684
684
  closeContextMenu();
685
685
  toast.success('部品から切り離しました', {
686
- description: 'この要素は普通の HTML になり、parts:sync の対象から外れます',
686
+ description: 'この要素は普通の HTML になり、部品の更新の対象から外れます',
687
687
  });
688
688
  }, [selectedElement?.id, getIframeDoc, notifyIframeChange, setSelectedElement, closeContextMenu]);
689
689
 
690
- // インスタンスの今の姿で定義を更新する(版 +1)。他ページへは利用側の parts:sync で反映する
690
+ // インスタンスの今の姿で定義を更新する(版 +1)。他ページへの反映は利用側(io.savePart の実装)が行う
691
691
  const handleUpdatePart = useCallback(async () => {
692
692
  if (!selectedElement?.id) return;
693
693
  const doc = getIframeDoc();
@@ -698,9 +698,7 @@ export function useComponentEditMode({
698
698
  if (!def) return;
699
699
  notifyIframeChange(true);
700
700
  closeContextMenu();
701
- toast.success(`部品「${def.name ?? def.id}」を v${def.version} に更新しました`, {
702
- description: '他のページのインスタンスへは parts:sync で反映します',
703
- });
701
+ toast.success(`部品「${def.name ?? def.id}」を v${def.version} に更新しました`);
704
702
  } catch (error) {
705
703
  console.error('Failed to update part:', error);
706
704
  toast.error('部品の更新に失敗しました');
@@ -148,15 +148,21 @@ export function stripEditorAttrs(root: Element): void {
148
148
  /** スロットを推定して付ける対象(文字や画像を持ちうる要素) */
149
149
  const SLOT_TAGS: Record<string, string> = {
150
150
  h1: 'heading', h2: 'heading', h3: 'heading', h4: 'heading', h5: 'heading', h6: 'heading',
151
- p: 'body', li: 'item', a: 'link', button: 'button', img: 'image',
152
- figcaption: 'caption', blockquote: 'quote', dt: 'term', dd: 'desc',
153
- td: 'cell', th: 'cell', label: 'label', span: 'text', time: 'time', small: 'note',
151
+ p: 'body', a: 'link', button: 'button', img: 'image',
152
+ figcaption: 'caption', blockquote: 'quote', label: 'label', span: 'text', time: 'time', small: 'note',
154
153
  };
155
154
 
155
+ /** タグだけの骨格(「同じ形の兄弟」の判定用。文字と属性は見ない) */
156
+ const tagSkeleton = (el: Element): string =>
157
+ `${el.tagName.toLowerCase()}${el.children.length ? `(${Array.from(el.children).map(tagSkeleton).join(',')})` : ''}`;
158
+
156
159
  /**
157
- * スロットの推定。既に `data-slot` があるものはそのまま。
158
- * 文書順に見て、祖先が既にスロットなら付けない(入れ子のスロットを作らない)。
159
- * span は文字だけを直接持つときだけ。svg の中は見ない。
160
+ * スロットの推定。既に `data-slot` があるものはそのまま。祖先が既にスロットなら付けない(入れ子のスロットを作らない)。
161
+ *
162
+ * 1. 繰り返し(ul/ol・dl・table、同じ形の子が 2 つ以上並ぶ器)は**器ごと 1 つのスロット**(list / items)。
163
+ * カードが 3 枚か 4 枚かはページの内容であって部品の構造ではないので、項目ごとにスロットを切らない
164
+ * (切ると、定義より多い項目が sync で捨てられる)。ルート自身が繰り返しの器ならルートがスロットになる
165
+ * 2. 残りはタグで(見出し→heading、段落→body、リンク→link …)。span は文字だけを直接持つときだけ。svg の中は見ない
160
166
  * 戻り値は付けた数。
161
167
  */
162
168
  export function inferSlots(root: Element): number {
@@ -172,12 +178,32 @@ export function inferSlots(root: Element): number {
172
178
  const base = v.replace(/-\d+$/, '');
173
179
  if (base) used.set(base, Math.max(used.get(base) ?? 0, 1));
174
180
  }
175
- const candidates = Array.from(root.querySelectorAll('*'));
176
- for (const el of candidates) {
177
- if (el.closest('svg')) continue;
178
- if (el.hasAttribute(SLOT_ATTR)) continue;
179
- const ancestorSlot = el.parentElement?.closest(`[${SLOT_ATTR}]`);
180
- if (ancestorSlot && root.contains(ancestorSlot)) continue;
181
+ const inSlot = (el: Element) => {
182
+ const a = el.parentElement?.closest(`[${SLOT_ATTR}]`);
183
+ return !!(a && root.contains(a));
184
+ };
185
+ const skip = (el: Element) => !!el.closest('svg') || el.hasAttribute(SLOT_ATTR) || inSlot(el);
186
+ const isRepeatContainer = (el: Element) => {
187
+ const kids = Array.from(el.children);
188
+ const tag = el.tagName.toLowerCase();
189
+ if ((tag === 'ul' || tag === 'ol') && kids.some((k) => k.tagName.toLowerCase() === 'li')) return 'list';
190
+ if (tag === 'dl' || tag === 'table') return 'list';
191
+ if (kids.length >= 2 && kids.every((k) => tagSkeleton(k) === tagSkeleton(kids[0]))) return 'items';
192
+ return null;
193
+ };
194
+ // 1. 繰り返しの器(ルート自身も含む。文書順なので外側が先に決まり、中の器は祖先がスロットになって飛ばされる)
195
+ for (const el of [root, ...Array.from(root.querySelectorAll('*'))]) {
196
+ if (el !== root && skip(el)) continue;
197
+ if (el === root && el.hasAttribute(SLOT_ATTR)) continue;
198
+ const kind = isRepeatContainer(el);
199
+ if (!kind) continue;
200
+ el.setAttribute(SLOT_ATTR, nameFor(kind));
201
+ count++;
202
+ }
203
+ if (root.hasAttribute(SLOT_ATTR)) return count;
204
+ // 2. 残りはタグで
205
+ for (const el of Array.from(root.querySelectorAll('*'))) {
206
+ if (skip(el)) continue;
181
207
  const tag = el.tagName.toLowerCase();
182
208
  const base = SLOT_TAGS[tag];
183
209
  if (!base) continue;
package/src/io.ts CHANGED
@@ -77,7 +77,7 @@ export type UploadResult = { url: string; width?: number; height?: number };
77
77
  *
78
78
  * ページに挿すときは**実体化**する: 定義を複製してルートに `data-part` `data-part-v` を付けた
79
79
  * 完全な HTML がページに残る。エディタは `data-slot` の中だけを編集対象にし、
80
- * 定義を変えたあとの一括反映は利用側のスクリプト(parts:sync)が行う。
80
+ * 定義を変えたあとの他ページへの反映は利用側(savePart の実装)が行う。
81
81
  */
82
82
  export type EditorPartDef = {
83
83
  /** 識別子。`data-part` の値になる(ファイル名にも使うので英数字と - _ だけ) */