@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.
@@ -32,10 +32,10 @@ import { createAuthApi } from '../lib/api/auth-fetch';
32
32
  import { useAuth } from '../components/auth/AuthProvider';
33
33
  import { findSlideRoot, findInsertionParent } from './utils/slide-root';
34
34
  import { registerAutoSaveFlush, flushAutoSave, beginContentSwitch, endContentSwitch, isContentSwitching } from './autosave';
35
- import { getCleanHtml } from './utils/html-utils';
35
+ import { getCleanHtml, makeChildrenEditable } from './utils/html-utils';
36
36
  import { EditorAppearanceContext, useEditorTheme } from './contexts/EditorAppearanceContext';
37
37
  import { CanvasAppearance } from './components/shell/CanvasAppearance';
38
- import { can, io, type CommentRect } from '../io';
38
+ import { can, io, notProvided, type CommentRect, type EditorInsertItem } from '../io';
39
39
  import { insertIntoFlow } from './parts';
40
40
  import type { Slide } from '../types/slide';
41
41
  import {
@@ -50,6 +50,7 @@ import {
50
50
  useComponentEditMode,
51
51
  toEditorMediaUrl,
52
52
  editorZoomApiRef,
53
+ useRevealElement,
53
54
  } from './hooks';
54
55
  import type { MediaItem } from './hooks';
55
56
  import {
@@ -108,6 +109,8 @@ import { Input } from '../components/ui/input';
108
109
  import { Button } from '../components/ui/button';
109
110
  import { cn } from '../lib/utils';
110
111
  import { clearPartDropIndicator } from './utils/drop-target';
112
+ import { insertHtmlToElement, resolveInsertionPoint } from './utils/catalog-inserts';
113
+ import { triggerTailwindRecompile } from './utils/tailwind-utils';
111
114
  import { debugLog } from './utils/debug';
112
115
  import { readStorage, writeStorage } from './utils/storage';
113
116
  import { useCollab } from './collab/useCollab';
@@ -126,6 +129,15 @@ const JsEditorDialog = lazy(() => import('./components/JsEditorDialog').then((m)
126
129
  const CssEditorDialog = lazy(() => import('./components/CssEditorDialog').then((m) => ({ default: m.CssEditorDialog })));
127
130
  const HtmlImportDialog = lazy(() => import('./components/HtmlImportDialog').then((m) => ({ default: m.HtmlImportDialog })));
128
131
  const HtmlEditorDialog = lazy(() => import('./components/HtmlEditorDialog').then((m) => ({ default: m.HtmlEditorDialog })));
132
+ const CatalogInsertDialog = lazy(() => import('./components/CatalogInsertDialog').then((m) => ({ default: m.CatalogInsertDialog })));
133
+
134
+ /**
135
+ * 「台帳から挿入」でページを作ったあと、そのページが `contentList` に現れるのを待つ上限。
136
+ * 利用側が一覧を読み直して prop を更新するまでの時間で、作る処理そのものの時間は含まない
137
+ */
138
+ const NEW_PAGE_WAIT_MS = 15000;
139
+ /** 上の待ちの試し直しの間隔 */
140
+ const NEW_PAGE_RETRY_MS = 200;
129
141
 
130
142
  /**
131
143
  * 保存の状態表示。エディタの殻(Figma風ヘッダー / PowerPoint風タイトルバー)で共通に使う。
@@ -251,6 +263,7 @@ function FrontendVisualEditorInner({
251
263
  // [移植時の追加] ヘッダーに「何枚目の何というスライドを編集中か」を出すため
252
264
  contentList,
253
265
  currentContentId,
266
+ onContentChange,
254
267
  domTree,
255
268
  setDomTree,
256
269
  notifyIframeChange,
@@ -437,6 +450,11 @@ function FrontendVisualEditorInner({
437
450
  // メディアライブラリ状態
438
451
  const [isMediaLibraryOpen, setIsMediaLibraryOpen] = useState(false);
439
452
 
453
+ // 「台帳から挿入」の状態。io.loadInserts が無ければ入口ごと出さない
454
+ const [isCatalogInsertOpen, setIsCatalogInsertOpen] = useState(false);
455
+ /** createContent で作ったページ。contentList に現れたら編集中にする */
456
+ const [pendingNewPageId, setPendingNewPageId] = useState<string | null>(null);
457
+
440
458
  // ページ設定管理(CSS/JS編集、エクスポート、OGP、HTMLインポート含む)
441
459
  const {
442
460
  isCssEditorOpen,
@@ -1167,6 +1185,149 @@ function FrontendVisualEditorInner({
1167
1185
  return doc.querySelector<HTMLElement>(`[data-element-id="${selectedElement.id}"]`);
1168
1186
  }, [getIframeDoc, selectedElement?.id]);
1169
1187
 
1188
+ // ── 台帳から挿入 ───────────────────────────────────────
1189
+ // 挿す手順は部品のドロップ(IFRAME_COMPONENT_DROP)と同じ順にする:
1190
+ // 要素にする → 位置を決めて入れる → 編集の印を付ける → notifyIframeChange(true)
1191
+ // → 選択 → 視点を寄せる
1192
+ // notifyIframeChange より先に印を付け終えないと、Undo で戻した HTML に印が無く、
1193
+ // 押すたびに 1 段ぶん違う紙面へ戻る。履歴は必ずここ 1 回だけで 1 段になる
1194
+ // (紙面の MutationObserver は data-editable の付け直しとレイヤー再構築だけで、履歴には触らない)
1195
+ const revealElement = useRevealElement();
1196
+
1197
+ /**
1198
+ * パネルを開いた時点の選択要素(挿入の基準)。
1199
+ *
1200
+ * 【なぜ挿入のときに選択を読み直さないか / なぜ紙面の `.selected` も見るか】
1201
+ * 保存(自動保存を含む)が終わると `originalHtml` が今の姿に更新され、それを見ている
1202
+ * 既存の effect(「ページ切替で選択が残らないように」)が選択の**状態**を空にする。
1203
+ * 紙面の見た目(`.selected` の枠)は残るので、利用者には選んだままに見える。
1204
+ * パネルを開いて見比べている数秒のあいだに自動保存が挟まると、選んだつもりなのに
1205
+ * 「選択なし」= 末尾へ飛ぶ、という当たらない動きになる。
1206
+ *
1207
+ * そこで基準は「開いた時点」に固定し、状態が空なら紙面の `.selected` を採る
1208
+ * (利用者が見ている選択に合わせる)。パネルは modal なので開いている間は紙面を
1209
+ * 選び直せない = 開いた時点の選択が答えでよい。
1210
+ * 開いている間に要素が消えた(Undo)場合は resolveInsertionPoint が弾く
1211
+ */
1212
+ const catalogAnchorRef = useRef<HTMLElement | null>(null);
1213
+
1214
+ const openCatalogInserts = useCallback(() => {
1215
+ catalogAnchorRef.current =
1216
+ selectedDomElement() ?? getIframeDoc()?.querySelector<HTMLElement>('#artboard .selected') ?? null;
1217
+ setIsCatalogInsertOpen(true);
1218
+ }, [selectedDomElement, getIframeDoc]);
1219
+
1220
+ const handleCatalogInsert = useCallback(
1221
+ async (item: EditorInsertItem, opts: { dummy: boolean }) => {
1222
+ const fetchInsert = io().fetchInsert;
1223
+ if (!fetchInsert) throw notProvided('fetchInsert');
1224
+ const { html: fragment } = await fetchInsert(item.id, opts);
1225
+ const iframeDoc = getIframeDoc();
1226
+ if (!iframeDoc) throw new Error('紙面がまだ開いていません');
1227
+
1228
+ const element = insertHtmlToElement(iframeDoc, fragment);
1229
+ if (!element) throw new Error(`「${item.name}」の中身が空でした`);
1230
+
1231
+ const { parent, before } = resolveInsertionPoint(iframeDoc, catalogAnchorRef.current);
1232
+ parent.insertBefore(element, before);
1233
+
1234
+ // エディタの印。ルート自身と、中の編集できる要素に付ける(部品の実体化と同じ手順)
1235
+ if (!element.getAttribute('data-element-id')) {
1236
+ element.setAttribute('data-editable', 'true');
1237
+ element.setAttribute('data-element-id', `el-${Date.now()}-insert`);
1238
+ }
1239
+ makeChildrenEditable(element);
1240
+ // 台帳の HTML は Tailwind のクラスが付いたまま来る。紙面の Tailwind ブラウザ版は
1241
+ // 足しただけの class を知らないので、その場で生成し直させる
1242
+ // (生成先は紙面の <head>。#artboard の中身は変わらないので履歴には載らない)
1243
+ triggerTailwindRecompile(element);
1244
+
1245
+ notifyIframeChange(true);
1246
+
1247
+ const elementInfo = extractElementInfo(element, iframeDoc);
1248
+ if (elementInfo) {
1249
+ setSelectedElement(elementInfo);
1250
+ setSelectedElementIds([element.getAttribute('data-element-id')!]);
1251
+ // 前の選択の枠は先に外す(付けっぱなしだと 2 つ選ばれているように見え、
1252
+ // 次に挿すときの基準にも前の要素が残る)
1253
+ iframeDoc.querySelectorAll('.selected').forEach((el) => el.classList.remove('selected'));
1254
+ element.classList.add('selected');
1255
+ }
1256
+ // 挿したものが画面の外だと「押したのに何も起きない」に見える。紙面をそこへ寄せる
1257
+ requestAnimationFrame(() => revealElement(iframeDoc, element));
1258
+ },
1259
+ [getIframeDoc, selectedDomElement, notifyIframeChange, setSelectedElement, setSelectedElementIds, revealElement],
1260
+ );
1261
+
1262
+ const handleCatalogCreatePage = useCallback(
1263
+ async (item: EditorInsertItem, input: { title: string; path: string }) => {
1264
+ const createContent = io().createContent;
1265
+ if (!createContent) throw notProvided('createContent');
1266
+ // parentId は利用側が案件を知っている前提で渡さない(templateId だけで足りる)
1267
+ const { id } = await createContent({ ...input, templateId: item.id });
1268
+ if (!id) throw new Error('作ったページの id が返りませんでした');
1269
+ // 利用側が contentList を読み直すのを待ってから、そのページを編集中にする
1270
+ setPendingNewPageId(id);
1271
+ },
1272
+ [],
1273
+ );
1274
+
1275
+ const newPageStateRef = useRef({ contentList, onContentChange });
1276
+ newPageStateRef.current = { contentList, onContentChange };
1277
+
1278
+ /**
1279
+ * 作ったページが一覧に現れたら、そのページを編集中にする。
1280
+ * 経路はリンク移動(useLinkNavigation)と同じ ── キャンバスは focusPage + activatePage、
1281
+ * 1 ページ表示は onContentChange。どちらも未保存の変更を保存してから移る。
1282
+ *
1283
+ * 【なぜ contentList の変化ではなく時間で試し直すか】
1284
+ * キャンバスのフレームは MultiPageCanvasProvider(このコンポーネントの親)の effect で
1285
+ * contentList から組み直される。親の effect は子より後に走るので、contentList が
1286
+ * 届いた瞬間にはまだフレームが無く、activatePage は false を返す。
1287
+ * 「一覧に出たか」と「フレームが組めたか」の 2 つを待つので、短い間隔で試し直す
1288
+ */
1289
+ useEffect(() => {
1290
+ const id = pendingNewPageId;
1291
+ if (!id) return;
1292
+ let cancelled = false;
1293
+ let timer: ReturnType<typeof setTimeout> | null = null;
1294
+ const deadline = Date.now() + NEW_PAGE_WAIT_MS;
1295
+
1296
+ const give = (message?: string) => {
1297
+ if (cancelled) return;
1298
+ setPendingNewPageId(null);
1299
+ if (message) toast.error(message);
1300
+ };
1301
+
1302
+ const tick = async () => {
1303
+ if (cancelled) return;
1304
+ const { contentList: list, onContentChange: change } = newPageStateRef.current;
1305
+ if (list.some((c) => c.id === id)) {
1306
+ const canvas = multiPageCanvasRef.current;
1307
+ if (canvas?.isEnabled) {
1308
+ canvas.focusPage(id);
1309
+ if (await canvas.activatePage(id)) return give();
1310
+ } else if (change) {
1311
+ const ok = await Promise.resolve(change(id));
1312
+ return give(ok === false ? '作ったページを開けませんでした' : undefined);
1313
+ } else {
1314
+ return give('このエディタではページを移れません');
1315
+ }
1316
+ }
1317
+ if (cancelled) return;
1318
+ if (Date.now() >= deadline) {
1319
+ return give('作ったページが一覧に出てきませんでした。一覧を読み直してから開いてください');
1320
+ }
1321
+ timer = setTimeout(() => void tick(), NEW_PAGE_RETRY_MS);
1322
+ };
1323
+ void tick();
1324
+
1325
+ return () => {
1326
+ cancelled = true;
1327
+ if (timer) clearTimeout(timer);
1328
+ };
1329
+ }, [pendingNewPageId]);
1330
+
1170
1331
  /**
1171
1332
  * 変換のあとの後始末。
1172
1333
  * notifyIframeChange が履歴の積み増しとレイヤーツリーの作り直しをやるので、
@@ -2239,6 +2400,7 @@ function FrontendVisualEditorInner({
2239
2400
  selectedElement?.id && hasUngroupableChildren(selectedElement.id) ? ungroupElements : undefined,
2240
2401
  openFilePicker: () => openFilePicker({ x: 100, y: 100 }),
2241
2402
  openMediaLibrary: () => setIsMediaLibraryOpen(true),
2403
+ openCatalogInserts: can('loadInserts') ? openCatalogInserts : undefined,
2242
2404
  openComponents: () => setIsComponentPanelOpen(true),
2243
2405
  openVariables: () => setIsVariablesPanelOpen(true),
2244
2406
  activeTool,
@@ -2438,6 +2600,7 @@ function FrontendVisualEditorInner({
2438
2600
  onImageUpload={() => openFilePicker({ x: 100, y: 100 })}
2439
2601
  onOpenMediaLibrary={can('apiFetch') ? () => setIsMediaLibraryOpen(true) : undefined}
2440
2602
  isMediaReplaceMode={isImageSelected}
2603
+ onOpenCatalogInserts={can('loadInserts') ? openCatalogInserts : undefined}
2441
2604
  canComment={can('commentAction')}
2442
2605
  onAiRegenerate={can('apiFetch') && selectedElement && selectedElementIds.length <= 1 ? openAiPrompt : undefined}
2443
2606
  onOpenVariables={() => setIsVariablesPanelOpen(true)}
@@ -2715,6 +2878,24 @@ function FrontendVisualEditorInner({
2715
2878
  />
2716
2879
  </Suspense>}
2717
2880
 
2881
+ {/* 台帳から挿入(io.loadInserts を渡した利用側だけ) */}
2882
+ {isCatalogInsertOpen && <Suspense fallback={<div role="status" className="absolute bottom-16 left-4 rounded bg-[#2c2c2c] p-3">読み込み中…</div>}>
2883
+ <CatalogInsertDialog
2884
+ isOpen={isCatalogInsertOpen}
2885
+ onClose={() => {
2886
+ setIsCatalogInsertOpen(false);
2887
+ catalogAnchorRef.current = null;
2888
+ // ショートカットが引き続き機能するようフォーカスを復元
2889
+ requestAnimationFrame(() => {
2890
+ restoreFocus();
2891
+ });
2892
+ }}
2893
+ editorMode={editorMode === 'slide' ? 'slide' : 'webpage'}
2894
+ onInsert={handleCatalogInsert}
2895
+ onCreatePage={can('createContent') ? handleCatalogCreatePage : undefined}
2896
+ />
2897
+ </Suspense>}
2898
+
2718
2899
  {/* CSS変数パネル */}
2719
2900
  <VariablesPanel
2720
2901
  isOpen={isVariablesPanelOpen}