@growgroup/visual-editor 0.8.1 → 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 +17 -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/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
|
@@ -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
|
-
|
|
31
|
-
|
|
30
|
+
/**
|
|
31
|
+
* 紙面の中の要素が見える位置へ視点を移す。
|
|
32
|
+
*
|
|
33
|
+
* キャンバスでは紙面(iframe)は伸びきっていてスクロールしないので、`scrollIntoView` では
|
|
34
|
+
* 何も動かない。キャンバスの位置を動かすか、1 ページ表示なら紙面の中の器をスクロールさせる。
|
|
35
|
+
* リンク移動(アンカー)と「台帳から挿入」のあとの移動で、同じ計算を使う。
|
|
36
|
+
*/
|
|
37
|
+
export function useRevealElement() {
|
|
32
38
|
const canvas = useMultiPageCanvasOptional();
|
|
33
|
-
const
|
|
34
|
-
|
|
39
|
+
const canvasRef = useRef(canvas);
|
|
40
|
+
canvasRef.current = canvas;
|
|
35
41
|
|
|
36
|
-
|
|
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 =
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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
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