@growgroup/visual-editor 0.1.6 → 0.2.1
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 +59 -2
- package/dist/editor.css +1 -1
- package/package.json +7 -3
- package/src/components/ui/alert-dialog.tsx +18 -4
- package/src/components/ui/context-menu.tsx +18 -4
- package/src/components/ui/dialog.tsx +19 -5
- package/src/components/ui/dropdown-menu.tsx +18 -4
- package/src/components/ui/popover.tsx +10 -2
- package/src/components/ui/select.tsx +10 -2
- package/src/components/ui/tooltip.tsx +10 -2
- package/src/editor/EditorToolbar.tsx +10 -20
- package/src/editor/FrontendVisualEditor.tsx +157 -43
- package/src/editor/components/EditorCanvas.tsx +1 -1
- package/src/editor/components/EditorContextMenu.tsx +53 -71
- package/src/editor/components/EditorFooter.tsx +36 -70
- package/src/editor/components/EditorHeader.tsx +37 -20
- package/src/editor/components/EditorLayerPanel.tsx +7 -9
- package/src/editor/components/EditorPropertyPanel.tsx +84 -8
- package/src/editor/components/LayerTreeItem.tsx +28 -5
- package/src/editor/components/component-panel/ComponentPanel.tsx +4 -3
- package/src/editor/components/ppt/PptChrome.tsx +156 -344
- package/src/editor/components/ppt/PptComments.tsx +135 -87
- package/src/editor/components/ppt/PptDesignProposals.tsx +21 -26
- package/src/editor/components/ppt/PptNotes.tsx +15 -15
- package/src/editor/components/shell/CanvasAppearance.tsx +19 -0
- package/src/editor/components/shell/ConfirmDialog.tsx +15 -0
- package/src/editor/components/shell/EditorTopBar.tsx +29 -68
- package/src/editor/components/shell/LeftPanel.tsx +20 -94
- package/src/editor/components/shell/PagesPanel.tsx +4 -2
- package/src/editor/contexts/EditorAppearanceContext.tsx +44 -0
- package/src/editor/contexts/EditorComponentsContext.tsx +278 -16
- package/src/editor/editor-skin.css +2 -122
- package/src/editor/hooks/useComponentEditMode.ts +93 -1
- package/src/editor/hooks/useIframeSetup.ts +10 -0
- package/src/editor/hooks/useMediaLibrary.ts +4 -5
- package/src/editor/hooks/useResizablePanel.ts +40 -11
- package/src/editor/parts.ts +321 -0
- package/src/editor/utils/dom-utils.ts +5 -4
- package/src/index.ts +25 -0
- package/src/io.ts +54 -0
- package/src/styles/skin.css +159 -0
- package/src/types/editor-components.ts +23 -1
|
@@ -10,6 +10,7 @@ import { useCallback, useRef, useEffect } from 'react';
|
|
|
10
10
|
import { useEditorContext } from '../EditorContext';
|
|
11
11
|
import { buildDomTree, isInlineElement } from '../utils/dom-utils';
|
|
12
12
|
import { EDITOR_IFRAME_STYLES } from '../constants';
|
|
13
|
+
import { isLockedInsidePart } from '../parts';
|
|
13
14
|
|
|
14
15
|
interface UseIframeSetupReturn {
|
|
15
16
|
/**
|
|
@@ -111,6 +112,15 @@ export function useIframeSetup(): UseIframeSetupReturn {
|
|
|
111
112
|
// selection-boxの子孫要素をすべてスキップ(念のため)
|
|
112
113
|
if (element.closest('.selection-box')) return;
|
|
113
114
|
|
|
115
|
+
// 部品のインスタンス(data-part)の中で、スロット(data-slot)の外にある要素は編集対象にしない。
|
|
116
|
+
// ルート自身とスロットの中は通常どおり(src/editor/parts.ts)。
|
|
117
|
+
// 保存 HTML には data-editable が残らないので、古い保存物には効かない = 既存の挙動は変わらない
|
|
118
|
+
if (isLockedInsidePart(element)) {
|
|
119
|
+
element.removeAttribute('data-editable');
|
|
120
|
+
element.removeAttribute('data-element-id');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
114
124
|
// 既にIDがある場合はスキップ
|
|
115
125
|
if (!element.getAttribute('data-element-id')) {
|
|
116
126
|
const elementId = `el-${Date.now()}-${elementIndex++}`;
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* 同時オープン時のリクエスト重複も in-flight promise で防ぐ。
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { io, notProvided } from '../../io';
|
|
12
13
|
import { useCallback, useEffect, useState } from 'react';
|
|
13
14
|
|
|
14
15
|
export interface MediaItem {
|
|
@@ -48,11 +49,9 @@ function normalizeItem(raw: unknown): MediaItem | null {
|
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
async function fetchMediaItems(): Promise<MediaItem[]> {
|
|
51
|
-
const
|
|
52
|
-
if (!
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
const data = (await res.json()) as MediaResponse;
|
|
52
|
+
const fetchCatalog = io().apiFetch;
|
|
53
|
+
if (!fetchCatalog) throw notProvided('apiFetch');
|
|
54
|
+
const data = await fetchCatalog('/__media') as MediaResponse;
|
|
56
55
|
const items = Array.isArray(data.items) ? data.items : [];
|
|
57
56
|
return items.map(normalizeItem).filter((i): i is MediaItem => i !== null);
|
|
58
57
|
}
|
|
@@ -24,6 +24,15 @@ export interface UseResizablePanelReturn {
|
|
|
24
24
|
isDragging: boolean;
|
|
25
25
|
/** リサイズハンドルのprops */
|
|
26
26
|
resizeHandleProps: {
|
|
27
|
+
role: 'separator';
|
|
28
|
+
tabIndex: number;
|
|
29
|
+
'aria-label': string;
|
|
30
|
+
'aria-orientation': 'vertical';
|
|
31
|
+
'aria-valuemin': number;
|
|
32
|
+
'aria-valuemax': number;
|
|
33
|
+
'aria-valuenow': number;
|
|
34
|
+
onKeyDown: (e: React.KeyboardEvent) => void;
|
|
35
|
+
onDoubleClick: () => void;
|
|
27
36
|
onMouseDown: (e: React.MouseEvent) => void;
|
|
28
37
|
onTouchStart: (e: React.TouchEvent) => void;
|
|
29
38
|
style: React.CSSProperties;
|
|
@@ -44,7 +53,8 @@ export function useResizablePanel({
|
|
|
44
53
|
// ローカルストレージから初期値を読み込み
|
|
45
54
|
const getInitialWidth = () => {
|
|
46
55
|
if (storageKey && typeof window !== 'undefined') {
|
|
47
|
-
|
|
56
|
+
let stored: string | null = null;
|
|
57
|
+
try { stored = localStorage.getItem(storageKey); } catch { /* 既定幅を使う */ }
|
|
48
58
|
if (stored) {
|
|
49
59
|
const parsed = parseInt(stored, 10);
|
|
50
60
|
if (!isNaN(parsed) && parsed >= minWidth && parsed <= maxWidth) {
|
|
@@ -65,11 +75,13 @@ export function useResizablePanel({
|
|
|
65
75
|
const clampedWidth = Math.min(maxWidth, Math.max(minWidth, newWidth));
|
|
66
76
|
setWidthState(clampedWidth);
|
|
67
77
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
78
|
+
}, [minWidth, maxWidth]);
|
|
79
|
+
|
|
80
|
+
// ドラッグ中の同期ストレージ書込みを避け、確定時だけ幅を記憶する。
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (isDragging || !storageKey) return;
|
|
83
|
+
try { localStorage.setItem(storageKey, String(width)); } catch { /* 表示は継続 */ }
|
|
84
|
+
}, [width, isDragging, storageKey]);
|
|
73
85
|
|
|
74
86
|
// マウスダウン
|
|
75
87
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
@@ -123,8 +135,11 @@ export function useResizablePanel({
|
|
|
123
135
|
document.addEventListener('mouseup', handleMouseUp);
|
|
124
136
|
document.addEventListener('touchmove', handleTouchMove);
|
|
125
137
|
document.addEventListener('touchend', handleTouchEnd);
|
|
138
|
+
document.addEventListener('touchcancel', handleTouchEnd);
|
|
126
139
|
|
|
127
140
|
// カーソルスタイルを変更
|
|
141
|
+
const previousCursor = document.body.style.cursor;
|
|
142
|
+
const previousSelect = document.body.style.userSelect;
|
|
128
143
|
document.body.style.cursor = 'col-resize';
|
|
129
144
|
document.body.style.userSelect = 'none';
|
|
130
145
|
|
|
@@ -133,21 +148,35 @@ export function useResizablePanel({
|
|
|
133
148
|
document.removeEventListener('mouseup', handleMouseUp);
|
|
134
149
|
document.removeEventListener('touchmove', handleTouchMove);
|
|
135
150
|
document.removeEventListener('touchend', handleTouchEnd);
|
|
136
|
-
document.
|
|
137
|
-
document.body.style.
|
|
151
|
+
document.removeEventListener('touchcancel', handleTouchEnd);
|
|
152
|
+
document.body.style.cursor = previousCursor;
|
|
153
|
+
document.body.style.userSelect = previousSelect;
|
|
138
154
|
};
|
|
139
155
|
}, [isDragging, direction, setWidth]);
|
|
140
156
|
|
|
141
157
|
// リサイズハンドルのprops
|
|
142
|
-
const resizeHandleProps = {
|
|
158
|
+
const resizeHandleProps: UseResizablePanelReturn['resizeHandleProps'] = {
|
|
159
|
+
role: 'separator', tabIndex: 0,
|
|
160
|
+
'aria-label': 'パネルの幅(矢印キーで調整)',
|
|
161
|
+
'aria-orientation': 'vertical',
|
|
162
|
+
'aria-valuemin': minWidth, 'aria-valuemax': maxWidth, 'aria-valuenow': width,
|
|
163
|
+
onDoubleClick: () => setWidth(initialWidth),
|
|
164
|
+
onKeyDown: (e) => {
|
|
165
|
+
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return;
|
|
166
|
+
e.preventDefault(); e.stopPropagation();
|
|
167
|
+
const sign = direction === 'right' ? 1 : -1;
|
|
168
|
+
if (e.key === 'Home') setWidth(minWidth);
|
|
169
|
+
else if (e.key === 'End') setWidth(maxWidth);
|
|
170
|
+
else setWidth(width + (e.key === 'ArrowRight' ? sign : -sign) * (e.shiftKey ? 32 : 8));
|
|
171
|
+
},
|
|
143
172
|
onMouseDown: handleMouseDown,
|
|
144
173
|
onTouchStart: handleTouchStart,
|
|
145
174
|
style: {
|
|
146
|
-
cursor: 'col-resize',
|
|
175
|
+
cursor: 'col-resize', touchAction: 'none',
|
|
147
176
|
} as React.CSSProperties,
|
|
148
177
|
className: `
|
|
149
178
|
absolute top-0 ${direction === 'right' ? 'right-0' : 'left-0'}
|
|
150
|
-
w-1 h-full z-10
|
|
179
|
+
ed-resize-handle w-1 h-full z-10
|
|
151
180
|
hover:bg-blue-500/50
|
|
152
181
|
${isDragging ? 'bg-blue-500' : 'bg-transparent'}
|
|
153
182
|
transition-colors duration-150
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 部品(パーツ)= HTML の `<template data-part-def>` と、ページに実体化されたインスタンス。
|
|
3
|
+
*
|
|
4
|
+
* ここは DOM だけを扱う純粋なユーティリティ(React に依存しない)。
|
|
5
|
+
*
|
|
6
|
+
* 【用語】
|
|
7
|
+
* - 定義 … `<template data-part-def="X" data-part-v="N">` の中のルート要素 1 つ
|
|
8
|
+
* - 実体化 … 定義を複製してページに置くこと。ルートに `data-part="X" data-part-v="N"` が付く。
|
|
9
|
+
* ページには**展開後の完全な HTML** が残る(読むときに何も解決しない)
|
|
10
|
+
* - スロット … `data-slot="名前"`。インスタンスの中で編集してよい範囲。
|
|
11
|
+
* 定義を更新したあとの一括反映(parts:sync)でも中身が保たれる
|
|
12
|
+
*
|
|
13
|
+
* 【ロック】インスタンスの中でスロットの外にある要素は編集対象にしない
|
|
14
|
+
* (useIframeSetup が `data-editable` を付けない)。ルート自身は選択できる
|
|
15
|
+
* (移動・削除・並び替え)。スロットの外を直したいときは「部品から切り離す」。
|
|
16
|
+
*/
|
|
17
|
+
import type { EditorPartDef } from '../io';
|
|
18
|
+
|
|
19
|
+
export const PART_ATTR = 'data-part';
|
|
20
|
+
export const PART_VERSION_ATTR = 'data-part-v';
|
|
21
|
+
export const SLOT_ATTR = 'data-slot';
|
|
22
|
+
export const PART_DEF_ATTR = 'data-part-def';
|
|
23
|
+
const PART_NAME_ATTR = 'data-part-name';
|
|
24
|
+
const PART_CATEGORY_ATTR = 'data-part-category';
|
|
25
|
+
const PART_DESC_ATTR = 'data-part-desc';
|
|
26
|
+
|
|
27
|
+
/** 部品の id に使える形へ丸める(ファイル名・属性値になる) */
|
|
28
|
+
export function toPartId(raw: string): string {
|
|
29
|
+
const s = raw.trim().replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
30
|
+
return s || `part-${Date.now().toString(36)}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseIntSafe(v: string | null | undefined, fallback: number): number {
|
|
34
|
+
const n = Number.parseInt(v ?? '', 10);
|
|
35
|
+
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `<template data-part-def>` の文字列(ファイル 1 つぶん)を定義に読む。
|
|
40
|
+
* template が無い・中身が空なら null。
|
|
41
|
+
*/
|
|
42
|
+
export function parsePartTemplate(source: string, doc: Document = document): EditorPartDef | null {
|
|
43
|
+
const parser = new (doc.defaultView?.DOMParser ?? DOMParser)();
|
|
44
|
+
const parsed = parser.parseFromString(source, 'text/html');
|
|
45
|
+
const tpl = parsed.querySelector<HTMLTemplateElement>(`template[${PART_DEF_ATTR}]`);
|
|
46
|
+
if (!tpl) return null;
|
|
47
|
+
const root = tpl.content.firstElementChild;
|
|
48
|
+
if (!root) return null;
|
|
49
|
+
const id = tpl.getAttribute(PART_DEF_ATTR)?.trim();
|
|
50
|
+
if (!id) return null;
|
|
51
|
+
return {
|
|
52
|
+
id,
|
|
53
|
+
name: tpl.getAttribute(PART_NAME_ATTR) ?? undefined,
|
|
54
|
+
category: tpl.getAttribute(PART_CATEGORY_ATTR) ?? undefined,
|
|
55
|
+
description: tpl.getAttribute(PART_DESC_ATTR) ?? undefined,
|
|
56
|
+
version: parseIntSafe(tpl.getAttribute(PART_VERSION_ATTR), 1),
|
|
57
|
+
html: root.outerHTML,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function escapeAttr(v: string): string {
|
|
62
|
+
return v.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 定義をファイルに書く形(`<template data-part-def>…</template>`)へ */
|
|
66
|
+
export function serializePartTemplate(def: EditorPartDef): string {
|
|
67
|
+
const attrs = [
|
|
68
|
+
`${PART_DEF_ATTR}="${escapeAttr(def.id)}"`,
|
|
69
|
+
`${PART_VERSION_ATTR}="${def.version}"`,
|
|
70
|
+
def.name ? `${PART_NAME_ATTR}="${escapeAttr(def.name)}"` : '',
|
|
71
|
+
def.category ? `${PART_CATEGORY_ATTR}="${escapeAttr(def.category)}"` : '',
|
|
72
|
+
def.description ? `${PART_DESC_ATTR}="${escapeAttr(def.description)}"` : '',
|
|
73
|
+
].filter(Boolean);
|
|
74
|
+
const body = def.html
|
|
75
|
+
.split('\n')
|
|
76
|
+
.map((l) => (l.trim() ? ` ${l}` : l))
|
|
77
|
+
.join('\n');
|
|
78
|
+
return `<template ${attrs.join(' ')}>\n${body}\n</template>\n`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 定義の HTML(ルート要素 1 つ)を `doc` の要素にする */
|
|
82
|
+
export function partRootElement(def: EditorPartDef, doc: Document): HTMLElement | null {
|
|
83
|
+
const tpl = doc.createElement('template');
|
|
84
|
+
tpl.innerHTML = def.html.trim();
|
|
85
|
+
const root = tpl.content.firstElementChild;
|
|
86
|
+
return root ? (doc.importNode(root, true) as HTMLElement) : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 実体化: 定義を複製し、ルートに `data-part` `data-part-v` を付けた要素を返す。
|
|
91
|
+
* ページにはこの要素がそのまま残る。
|
|
92
|
+
*/
|
|
93
|
+
export function materializePart(def: EditorPartDef, doc: Document): HTMLElement | null {
|
|
94
|
+
const el = partRootElement(def, doc);
|
|
95
|
+
if (!el) return null;
|
|
96
|
+
el.setAttribute(PART_ATTR, def.id);
|
|
97
|
+
el.setAttribute(PART_VERSION_ATTR, String(def.version));
|
|
98
|
+
return el;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** インスタンスのルートか */
|
|
102
|
+
export function isPartRoot(el: Element | null | undefined): el is HTMLElement {
|
|
103
|
+
return !!el && el.nodeType === 1 && el.hasAttribute(PART_ATTR);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 要素を含む最も近いインスタンスのルート(自分自身でもよい) */
|
|
107
|
+
export function closestPartRoot(el: Element | null | undefined): HTMLElement | null {
|
|
108
|
+
if (!el || el.nodeType !== 1) return null;
|
|
109
|
+
const root = el.closest(`[${PART_ATTR}]`);
|
|
110
|
+
return root instanceof Element ? (root as HTMLElement) : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** ルートの部品情報(無ければ null) */
|
|
114
|
+
export function partInfoOf(el: Element | null | undefined): { id: string; version: number } | null {
|
|
115
|
+
if (!isPartRoot(el)) return null;
|
|
116
|
+
return { id: el.getAttribute(PART_ATTR) ?? '', version: parseIntSafe(el.getAttribute(PART_VERSION_ATTR), 1) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* インスタンスの中でスロットの外にいる(= ロックされている)か。
|
|
121
|
+
* ルート自身はロックしない(選択して動かせる)。スロット要素とその子孫もロックしない。
|
|
122
|
+
*/
|
|
123
|
+
export function isLockedInsidePart(el: Element): boolean {
|
|
124
|
+
if (el.hasAttribute(PART_ATTR)) return false;
|
|
125
|
+
const root = el.parentElement?.closest(`[${PART_ATTR}]`);
|
|
126
|
+
if (!root) return false;
|
|
127
|
+
const slot = el.closest(`[${SLOT_ATTR}]`);
|
|
128
|
+
return !(slot && root.contains(slot) && slot !== root);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** エディタが作業用に付ける属性・class を落とす(部品の定義や保存 HTML に持ち込まない) */
|
|
132
|
+
export function stripEditorAttrs(root: Element): void {
|
|
133
|
+
const attrs = ['contenteditable', 'data-editable', 'data-element-id', 'data-original-content', 'data-shape-type', 'data-inline', 'data-display-name', 'data-locked', 'data-hidden'];
|
|
134
|
+
const classes = ['selected', 'dragging', 'editing', 'rotating', 'panning', 'hover-preview', 'marquee-hover', 'marquee-active', 'text-editable-hover', 'drag-ghost'];
|
|
135
|
+
const all: Element[] = [root, ...Array.from(root.querySelectorAll('*'))];
|
|
136
|
+
for (const el of all) {
|
|
137
|
+
for (const a of attrs) el.removeAttribute(a);
|
|
138
|
+
for (const attr of Array.from(el.attributes)) {
|
|
139
|
+
if (attr.name.startsWith('data-original-') || attr.name.startsWith('data-gg-')) el.removeAttribute(attr.name);
|
|
140
|
+
}
|
|
141
|
+
if (el.classList.length) {
|
|
142
|
+
el.classList.remove(...classes);
|
|
143
|
+
if (!el.getAttribute('class')) el.removeAttribute('class');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** スロットを推定して付ける対象(文字や画像を持ちうる要素) */
|
|
149
|
+
const SLOT_TAGS: Record<string, string> = {
|
|
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',
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* スロットの推定。既に `data-slot` があるものはそのまま。
|
|
158
|
+
* 文書順に見て、祖先が既にスロットなら付けない(入れ子のスロットを作らない)。
|
|
159
|
+
* span は文字だけを直接持つときだけ。svg の中は見ない。
|
|
160
|
+
* 戻り値は付けた数。
|
|
161
|
+
*/
|
|
162
|
+
export function inferSlots(root: Element): number {
|
|
163
|
+
let count = 0;
|
|
164
|
+
const used = new Map<string, number>();
|
|
165
|
+
const nameFor = (base: string) => {
|
|
166
|
+
const n = (used.get(base) ?? 0) + 1;
|
|
167
|
+
used.set(base, n);
|
|
168
|
+
return n === 1 ? base : `${base}-${n}`;
|
|
169
|
+
};
|
|
170
|
+
for (const el of Array.from(root.querySelectorAll('[' + SLOT_ATTR + ']'))) {
|
|
171
|
+
const v = el.getAttribute(SLOT_ATTR) ?? '';
|
|
172
|
+
const base = v.replace(/-\d+$/, '');
|
|
173
|
+
if (base) used.set(base, Math.max(used.get(base) ?? 0, 1));
|
|
174
|
+
}
|
|
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 tag = el.tagName.toLowerCase();
|
|
182
|
+
const base = SLOT_TAGS[tag];
|
|
183
|
+
if (!base) continue;
|
|
184
|
+
if (tag === 'span') {
|
|
185
|
+
const hasText = Array.from(el.childNodes).some((n) => n.nodeType === 3 && n.textContent?.trim());
|
|
186
|
+
if (!hasText || el.children.length > 0) continue;
|
|
187
|
+
}
|
|
188
|
+
if (tag !== 'img' && !el.textContent?.trim() && el.children.length === 0) continue;
|
|
189
|
+
el.setAttribute(SLOT_ATTR, nameFor(base));
|
|
190
|
+
count++;
|
|
191
|
+
}
|
|
192
|
+
return count;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 選択要素から部品の定義を作る(「部品として保存」)。
|
|
197
|
+
* 元の要素は変えない(複製に対して整える)。スロットは推定して付ける。
|
|
198
|
+
*/
|
|
199
|
+
export function partDefFromElement(
|
|
200
|
+
el: HTMLElement,
|
|
201
|
+
meta: { id: string; name?: string; category?: string; description?: string; version?: number },
|
|
202
|
+
options: {
|
|
203
|
+
/**
|
|
204
|
+
* スロットを推定して付けるか。新規作成のときだけ true。
|
|
205
|
+
* 更新(版 +1)で推定すると、定義が意図して固定にしていた要素(ボタンの文言など)が
|
|
206
|
+
* 黙ってスロットになる。更新では既に付いている data-slot だけを尊重する
|
|
207
|
+
*/
|
|
208
|
+
inferSlots?: boolean;
|
|
209
|
+
} = {},
|
|
210
|
+
): EditorPartDef {
|
|
211
|
+
const clone = el.cloneNode(true) as HTMLElement;
|
|
212
|
+
stripEditorAttrs(clone);
|
|
213
|
+
clone.removeAttribute(PART_ATTR);
|
|
214
|
+
clone.removeAttribute(PART_VERSION_ATTR);
|
|
215
|
+
// 絶対配置の座標は「その場所」の情報なので定義には持ち込まない
|
|
216
|
+
for (const k of ['position', 'left', 'top', 'right', 'bottom']) clone.style.removeProperty(k);
|
|
217
|
+
if (!clone.getAttribute('style')) clone.removeAttribute('style');
|
|
218
|
+
if (options.inferSlots) inferSlots(clone);
|
|
219
|
+
return {
|
|
220
|
+
id: meta.id,
|
|
221
|
+
name: meta.name,
|
|
222
|
+
category: meta.category,
|
|
223
|
+
description: meta.description,
|
|
224
|
+
version: meta.version ?? 1,
|
|
225
|
+
html: clone.outerHTML,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* ページ上の要素を、その場でインスタンスにする(「部品として保存」の直後)。
|
|
231
|
+
* 定義に付けたスロットと同じ位置に `data-slot` を付け、ルートに `data-part` を付ける。
|
|
232
|
+
* DOM の形は定義と同じ(複製元だから)なので、文書順で対応づける。
|
|
233
|
+
*/
|
|
234
|
+
export function stampAsInstance(el: HTMLElement, def: EditorPartDef, doc: Document): void {
|
|
235
|
+
const ref = partRootElement(def, doc);
|
|
236
|
+
if (!ref) return;
|
|
237
|
+
const refAll = [ref, ...Array.from(ref.querySelectorAll('*'))];
|
|
238
|
+
const liveAll = [el, ...Array.from(el.querySelectorAll('*'))];
|
|
239
|
+
// エディタの作業属性は複製時に落としているので、要素数が一致するはず。ずれたらスロットは付けない
|
|
240
|
+
if (refAll.length === liveAll.length) {
|
|
241
|
+
for (let i = 0; i < refAll.length; i++) {
|
|
242
|
+
const s = refAll[i].getAttribute(SLOT_ATTR);
|
|
243
|
+
if (s) liveAll[i].setAttribute(SLOT_ATTR, s);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
el.setAttribute(PART_ATTR, def.id);
|
|
247
|
+
el.setAttribute(PART_VERSION_ATTR, String(def.version));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** 「部品から切り離す」: 出自の記録だけ外す。中身と data-slot はそのまま(普通の HTML になる) */
|
|
251
|
+
export function detachPart(el: HTMLElement): void {
|
|
252
|
+
el.removeAttribute(PART_ATTR);
|
|
253
|
+
el.removeAttribute(PART_VERSION_ATTR);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* フロー(流し込み)への挿入位置を決める。
|
|
258
|
+
* ドロップ位置の要素から、「セクションの器」の直下にある祖先まで上がり、その後ろに入れる。
|
|
259
|
+
* 器 = `[data-wf-body]` / `main` / `#artboard` / body。何も当たらなければ器の末尾。
|
|
260
|
+
*/
|
|
261
|
+
export function insertIntoFlow(doc: Document, el: HTMLElement, x: number, y: number): void {
|
|
262
|
+
const isContainer = (node: Element | null): boolean =>
|
|
263
|
+
!!node &&
|
|
264
|
+
(node === doc.body ||
|
|
265
|
+
node.id === 'artboard' ||
|
|
266
|
+
node.hasAttribute('data-wf-body') ||
|
|
267
|
+
node.tagName === 'MAIN');
|
|
268
|
+
const fallback = (): Element =>
|
|
269
|
+
doc.querySelector('[data-wf-body]') ?? doc.querySelector('main') ?? doc.getElementById('artboard') ?? doc.body;
|
|
270
|
+
|
|
271
|
+
let hit: Element | null = doc.elementFromPoint(x, y);
|
|
272
|
+
if (!hit || hit === doc.body || hit.id === 'artboard' || hit.closest('.selection-box')) {
|
|
273
|
+
fallback().appendChild(el);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
// 器の直下にあたる祖先まで上がる
|
|
277
|
+
let anchor: Element = hit;
|
|
278
|
+
while (anchor.parentElement && !isContainer(anchor.parentElement)) {
|
|
279
|
+
anchor = anchor.parentElement;
|
|
280
|
+
}
|
|
281
|
+
if (!anchor.parentElement) {
|
|
282
|
+
fallback().appendChild(el);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
anchor.after(el);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* インスタンスの中のロックを今すぐ効かせる(「部品として保存」の直後)。
|
|
290
|
+
* 通常は useIframeSetup が付け直すが、それは子要素の増減でしか走らないので、ここで落とす。
|
|
291
|
+
*/
|
|
292
|
+
export function applyLockInside(root: HTMLElement): void {
|
|
293
|
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
|
294
|
+
if (!isLockedInsidePart(el)) continue;
|
|
295
|
+
el.removeAttribute('data-editable');
|
|
296
|
+
el.removeAttribute('data-element-id');
|
|
297
|
+
el.removeAttribute('contenteditable');
|
|
298
|
+
el.classList.remove('selected', 'hover-preview', 'text-editable-hover');
|
|
299
|
+
if (!el.getAttribute('class')) el.removeAttribute('class');
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* 「部品から切り離す」の直後に、ロックされていた子孫を編集できるようにする。
|
|
305
|
+
* useIframeSetup と同じ形の id を付ける。
|
|
306
|
+
*/
|
|
307
|
+
export function unlockPartDescendants(root: HTMLElement): number {
|
|
308
|
+
let i = 0;
|
|
309
|
+
const stamp = Date.now();
|
|
310
|
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
|
311
|
+
if (el.closest('script, style, svg path, svg g')) continue;
|
|
312
|
+
if (el.tagName === 'SCRIPT' || el.tagName === 'STYLE') continue;
|
|
313
|
+
if (!el.getAttribute('data-element-id')) {
|
|
314
|
+
el.setAttribute('data-editable', 'true');
|
|
315
|
+
el.setAttribute('data-element-id', `el-${stamp}-u${i++}`);
|
|
316
|
+
} else if (!el.getAttribute('data-editable')) {
|
|
317
|
+
el.setAttribute('data-editable', 'true');
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return i;
|
|
321
|
+
}
|
|
@@ -253,7 +253,7 @@ export function applyCanvasZoomDom(iframeDoc: Document, zoomPct: number): void {
|
|
|
253
253
|
// 上の余白は中央寄せでは自然に生まれたが、上端固定では自分で確保する。
|
|
254
254
|
// 縦方向の中央寄せもしない(ページの高さが変わるたびに紙面が動く原因だった)
|
|
255
255
|
const topAnchored = iframeDoc.body.dataset.editorMode === 'webpage';
|
|
256
|
-
scrollArea.style.paddingTop = topAnchored ?
|
|
256
|
+
scrollArea.style.paddingTop = topAnchored ? `32px` : '';
|
|
257
257
|
|
|
258
258
|
// 紙面が容器に収まる軸はスクロールを中央へ(拡大時のパンには干渉しない)
|
|
259
259
|
if (w * scale <= cw) container.scrollLeft = Math.max(0, (scrollArea.offsetWidth - cw) / 2);
|
|
@@ -919,9 +919,10 @@ function buildDomTreeInternal(iframeDoc: Document, rootElement?: Element): DOMTr
|
|
|
919
919
|
const className = classNameStr.split(' ')
|
|
920
920
|
.filter(c => c && !c.startsWith('selected') && !c.startsWith('dragging') && !c.startsWith('editing'))[0] || '';
|
|
921
921
|
|
|
922
|
-
// コンポーネントインスタンス情報を取得
|
|
923
|
-
const
|
|
924
|
-
const
|
|
922
|
+
// コンポーネントインスタンス情報を取得(HTML 部品の data-part も同じ印として扱う)
|
|
923
|
+
const partId = htmlChild.getAttribute('data-part') || undefined;
|
|
924
|
+
const componentInstanceId = htmlChild.getAttribute('data-component-instance') || partId;
|
|
925
|
+
const masterComponentId = htmlChild.getAttribute('data-component-master') || partId;
|
|
925
926
|
|
|
926
927
|
result.push({
|
|
927
928
|
id: elementId,
|
package/src/index.ts
CHANGED
|
@@ -52,4 +52,29 @@ export type {
|
|
|
52
52
|
CommentAction,
|
|
53
53
|
ExportFormat,
|
|
54
54
|
UploadResult,
|
|
55
|
+
EditorPartDef,
|
|
56
|
+
EditorPartCategory,
|
|
57
|
+
EditorPartsLibrary,
|
|
55
58
|
} from "./io";
|
|
59
|
+
export type { CSSVariableDefinition, CSSVariableCategory } from "./types/css-variables";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 部品(HTML の `<template data-part-def>`)のユーティリティ。
|
|
63
|
+
* 利用側が parts/*.html を読み書きする・スクリプトで同期する(parts:sync)ときに使う。
|
|
64
|
+
* DOM だけに依存する(Node では linkedom 等の Document を渡す)。
|
|
65
|
+
*/
|
|
66
|
+
export {
|
|
67
|
+
PART_ATTR,
|
|
68
|
+
PART_VERSION_ATTR,
|
|
69
|
+
SLOT_ATTR,
|
|
70
|
+
PART_DEF_ATTR,
|
|
71
|
+
parsePartTemplate,
|
|
72
|
+
serializePartTemplate,
|
|
73
|
+
materializePart,
|
|
74
|
+
inferSlots,
|
|
75
|
+
partDefFromElement,
|
|
76
|
+
partInfoOf,
|
|
77
|
+
isPartRoot,
|
|
78
|
+
isLockedInsidePart,
|
|
79
|
+
toPartId,
|
|
80
|
+
} from "./editor/parts";
|
package/src/io.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
|
+
import type { CSSVariableDefinition } from "./types/css-variables";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* エディタと「置き場」のあいだの唯一の境界。
|
|
@@ -63,6 +64,43 @@ export type ExportFormat = "pdf" | "pdf-lite" | "pptx" | "pptx-edit";
|
|
|
63
64
|
|
|
64
65
|
export type UploadResult = { url: string; width?: number; height?: number };
|
|
65
66
|
|
|
67
|
+
/**
|
|
68
|
+
* 部品(パーツ)の定義。利用側では 1 部品 1 ファイル(`parts/<id>.html`)の
|
|
69
|
+
*
|
|
70
|
+
* <template data-part-def="ContactBand" data-part-v="1"
|
|
71
|
+
* data-part-name="お問合せ帯" data-part-category="block" data-part-desc="…">
|
|
72
|
+
* <section …>…<h2 data-slot="heading">…</h2>…</section>
|
|
73
|
+
* </template>
|
|
74
|
+
*
|
|
75
|
+
* を想定している。`html` は template の中身(ルート要素 1 つ)。
|
|
76
|
+
* 文字列と `<template>` の相互変換は `parsePartTemplate` / `serializePartTemplate`(src/editor/parts.ts)。
|
|
77
|
+
*
|
|
78
|
+
* ページに挿すときは**実体化**する: 定義を複製してルートに `data-part` `data-part-v` を付けた
|
|
79
|
+
* 完全な HTML がページに残る。エディタは `data-slot` の中だけを編集対象にし、
|
|
80
|
+
* 定義を変えたあとの他ページへの反映は利用側(savePart の実装)が行う。
|
|
81
|
+
*/
|
|
82
|
+
export type EditorPartDef = {
|
|
83
|
+
/** 識別子。`data-part` の値になる(ファイル名にも使うので英数字と - _ だけ) */
|
|
84
|
+
id: string;
|
|
85
|
+
/** 表示名。無ければ id */
|
|
86
|
+
name?: string;
|
|
87
|
+
/** パネルの分類。無ければ "parts" */
|
|
88
|
+
category?: string;
|
|
89
|
+
description?: string;
|
|
90
|
+
/** 定義の版。実体化したインスタンスに `data-part-v` として写る */
|
|
91
|
+
version: number;
|
|
92
|
+
/** ルート要素 1 つの HTML */
|
|
93
|
+
html: string;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export type EditorPartCategory = { id: string; name: string; description?: string };
|
|
97
|
+
|
|
98
|
+
export type EditorPartsLibrary = {
|
|
99
|
+
parts: EditorPartDef[];
|
|
100
|
+
/** 無ければ parts の category から作る */
|
|
101
|
+
categories?: EditorPartCategory[];
|
|
102
|
+
};
|
|
103
|
+
|
|
66
104
|
export type EditorIO = {
|
|
67
105
|
/** 一覧とコメントを取る。無ければ空の一覧として振る舞う */
|
|
68
106
|
loadDeck?: () => Promise<EditorDeck>;
|
|
@@ -104,6 +142,22 @@ export type EditorIO = {
|
|
|
104
142
|
|
|
105
143
|
/** 保存結果の通知。無ければ何もしない */
|
|
106
144
|
notifySave?: (info: { page: number }) => void;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 部品(HTML の template)の一覧。渡すと部品パネルはこれを使い、
|
|
148
|
+
* ブラウザ内(localStorage)の JSON コンポーネントは読まない。
|
|
149
|
+
* 挿入は実体化(完全な HTML をページに残す)になる
|
|
150
|
+
*/
|
|
151
|
+
loadParts?: () => Promise<EditorPartsLibrary>;
|
|
152
|
+
/** 部品を作る・更新する(「部品として保存」「部品を更新」)。無ければそれらの操作を出さない */
|
|
153
|
+
savePart?: (part: EditorPartDef) => Promise<EditorPartDef | void>;
|
|
154
|
+
/** 部品を消す。無ければ削除を出さない */
|
|
155
|
+
deletePart?: (id: string) => Promise<void>;
|
|
156
|
+
|
|
157
|
+
/** CSS 変数(デザイントークン)の読み込み。無ければブラウザ内(localStorage)に持つ */
|
|
158
|
+
loadVariables?: () => Promise<CSSVariableDefinition[]>;
|
|
159
|
+
/** CSS 変数の保存。無ければブラウザ内(localStorage)に持つ */
|
|
160
|
+
saveVariables?: (variables: CSSVariableDefinition[]) => Promise<void>;
|
|
107
161
|
};
|
|
108
162
|
|
|
109
163
|
let current: EditorIO = {};
|