@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.
- package/README.md +23 -0
- package/dist/editor.css +1 -1
- package/package.json +1 -1
- package/src/editor/EditorToolbar.tsx +11 -1
- package/src/editor/FrontendVisualEditor.tsx +183 -2
- package/src/editor/components/CatalogInsertDialog.tsx +647 -0
- package/src/editor/components/ppt/PptChrome.tsx +6 -1
- package/src/editor/hooks/index.ts +1 -1
- package/src/editor/hooks/useLinkNavigation.ts +29 -9
- package/src/editor/utils/catalog-inserts.ts +79 -0
- package/src/editor/utils/dom-utils.ts +49 -3
- package/src/editor/utils/drop-target.ts +7 -3
- package/src/editor/utils/tailwind-utils.ts +5 -1
- package/src/index.ts +4 -0
- package/src/io.ts +71 -0
- package/src/version.ts +1 -1
|
@@ -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}
|