@growgroup/visual-editor 0.6.0 → 0.7.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 +45 -0
- package/dist/editor.css +1 -1
- package/package.json +1 -1
- package/src/editor/FrontendVisualEditor.tsx +173 -0
- package/src/editor/components/EditorCanvas.tsx +16 -0
- package/src/editor/components/EditorContextMenu.tsx +80 -0
- package/src/editor/components/EditorPropertyPanel.tsx +248 -80
- package/src/editor/components/EditorSidebar.tsx +2 -2
- package/src/editor/components/multi-page/MultiPageCanvasView.tsx +38 -19
- package/src/editor/components/ppt/PptChrome.tsx +46 -4
- package/src/editor/components/property-panel/VariableAwareSizeInput.tsx +94 -50
- package/src/editor/components/property-panel/VariableAwareUnitInput.tsx +12 -6
- package/src/editor/constants.ts +24 -0
- package/src/editor/contexts/MultiPageCanvasContext.tsx +108 -6
- package/src/editor/hooks/index.ts +3 -0
- package/src/editor/hooks/useDragResize.ts +30 -5
- package/src/editor/hooks/useElementActions.ts +41 -9
- package/src/editor/hooks/useElementSelection.ts +4 -2
- package/src/editor/hooks/useIframeSetup.ts +8 -1
- package/src/editor/hooks/useInlineTextSelection.ts +16 -0
- package/src/editor/hooks/useKeepArtboardInPlace.ts +88 -0
- package/src/editor/hooks/useLinkNavigation.ts +160 -0
- package/src/editor/parts.ts +2 -0
- package/src/editor/types.ts +4 -0
- package/src/editor/utils/dom-utils.ts +45 -0
- package/src/editor/utils/free-layout-tree.ts +562 -0
- package/src/editor/utils/free-layout.ts +794 -0
- package/src/editor/utils/geometry.ts +41 -3
- package/src/editor/utils/html-utils.ts +2 -1
- package/src/editor/utils/inline-text-style.ts +426 -0
- package/src/editor/utils/link-navigation.ts +474 -0
- package/src/editor/utils/style-utils.ts +24 -0
- package/src/editor/utils/tailwind-utils.ts +12 -0
- package/src/editor/utils/text-line-break.ts +121 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ツリーの絶対配置 = 選んだ要素から下の「箱」を、入れ子まで全部絶対配置にする(webpage 専用)。
|
|
3
|
+
*
|
|
4
|
+
* 【器ひとつの変換(free-layout.ts)との違い】
|
|
5
|
+
* enableFreeLayout は器の直下の子だけを倒すので、カードの中の見出しや画像までは動かせない。
|
|
6
|
+
* ここでは選んだ要素から下へ降り、段落・見出し・画像・ボタン・ブロックの箱を全部倒す。
|
|
7
|
+
* 目印は自由配置と同じ(gg-freelayout / gg-freelayout-item / gg-freelayout-rel)なので、
|
|
8
|
+
* ドラッグの範囲・札・右パネルの切替・解除はそのまま効く。
|
|
9
|
+
*
|
|
10
|
+
* 【木ごと 1 回で測り、1 回で書き、1 回で確かめる】
|
|
11
|
+
* 器ごとに enableFreeLayout を再帰で呼ぶと、深いところで 1 つ巻き戻ったときに
|
|
12
|
+
* 「途中まで倒れた木」が残る。全部の箱を先に測り、全部書き、全部確かめ、
|
|
13
|
+
* 1 つでもずれたら木ごと戻す。履歴(⌘Z)も共同編集の送信も 1 回で済む。
|
|
14
|
+
*
|
|
15
|
+
* 【位置は矩形から小数で出す】
|
|
16
|
+
* offsetLeft / offsetTop は整数に丸められる。入れ子の各段で 0.5px 未満ずつずれると、
|
|
17
|
+
* 深い要素ほどずれが積み上がる。そこで getBoundingClientRect(小数)をズーム倍率で割り戻し、
|
|
18
|
+
* 器の padding box 基準の left / top にする。大きさは自由配置と同じく
|
|
19
|
+
* getComputedStyle の値(box-sizing と同じ物差し)をそのまま書く。
|
|
20
|
+
* 測る間だけ、木の中の変形(transform / translate / rotate / scale)と遷移を止める。
|
|
21
|
+
* 回転した箱の矩形は外接矩形になり、左上が本当の位置と合わないため。
|
|
22
|
+
*
|
|
23
|
+
* 【どこまで降りるか】(display は computed で見る。構成ラフのボタンは <a class="inline-flex"> で箱)
|
|
24
|
+
* - 箱として倒すが、中へは降りない(葉)
|
|
25
|
+
* - 文字の流れを持つ要素: 直下に文字がある / 直下に行内(display:inline)の子や <br> がある
|
|
26
|
+
* → span・a・strong・br などは段落の中の流れのまま残る
|
|
27
|
+
* - p / h1〜h6 / a / button / label / figcaption / blockquote / pre / フォーム部品
|
|
28
|
+
* - 置換要素と svg: img / picture / video / canvas / iframe / svg(中の図形は絶対配置にならない)
|
|
29
|
+
* - table(tr / td を絶対配置にすると表の組みが壊れる。表ごと 1 つの箱として動かす)
|
|
30
|
+
* - ul / ol は display が flex / grid(カードの並び)のときだけ降りて li を箱にする。
|
|
31
|
+
* 縦に流れるリスト(マーカーや行間の流れがある)は葉
|
|
32
|
+
* - 段組み(columns)の器(子が段をまたいで分かれ、矩形 1 つで表せない)
|
|
33
|
+
* - 触らない: display:none、position:fixed、部品(data-part)のスロットの外
|
|
34
|
+
* (スロットの外の器は変換せずに降り、スロットを新しい起点にする)
|
|
35
|
+
* - 元から absolute の子は倒さない(クラスで決まっている位置は、幅が変わっても追従するので残す)。
|
|
36
|
+
* ただし基準(包含ブロック)が変わる子だけは、位置を書き直して見た目を保つ(印を付け、解除で戻す)
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { capturePrestyle, getArtboardScale, isNonEditableTag } from './dom-utils';
|
|
40
|
+
import { isLockedInsidePart } from '../parts';
|
|
41
|
+
import { debugLog } from './debug';
|
|
42
|
+
import {
|
|
43
|
+
FREELAYOUT_CLASS,
|
|
44
|
+
FREELAYOUT_HOLD_CLASS,
|
|
45
|
+
FREELAYOUT_ITEM_CLASS,
|
|
46
|
+
FREELAYOUT_REL_CLASS,
|
|
47
|
+
isEditorOverlay,
|
|
48
|
+
layoutChildren,
|
|
49
|
+
rememberFlowValues,
|
|
50
|
+
rememberItemGeometry,
|
|
51
|
+
styleSizeFor,
|
|
52
|
+
} from './free-layout';
|
|
53
|
+
|
|
54
|
+
/** 降りなかった・触らなかった理由(報告とトーストに使う) */
|
|
55
|
+
export type FreeLayoutTreeSkip =
|
|
56
|
+
| 'text'
|
|
57
|
+
| 'inline'
|
|
58
|
+
| 'element'
|
|
59
|
+
| 'media'
|
|
60
|
+
| 'svg'
|
|
61
|
+
| 'table'
|
|
62
|
+
| 'list'
|
|
63
|
+
| 'columns'
|
|
64
|
+
| 'locked'
|
|
65
|
+
| 'fixed';
|
|
66
|
+
|
|
67
|
+
export interface FreeLayoutTreePlan {
|
|
68
|
+
/** 流れから抜いて絶対配置にする箱と、その基準にする器 */
|
|
69
|
+
items: { element: HTMLElement; container: HTMLElement }[];
|
|
70
|
+
/** 流れに残ったまま(または元から absolute のまま)器になるもの。高さを固定する */
|
|
71
|
+
roots: HTMLElement[];
|
|
72
|
+
/** 子を倒す器(items と roots の両方にまたがる) */
|
|
73
|
+
containers: Set<HTMLElement>;
|
|
74
|
+
/** 基準が変わるので位置を書き直す、元から absolute の要素 */
|
|
75
|
+
reanchors: { element: HTMLElement; container: HTMLElement }[];
|
|
76
|
+
skipped: Record<FreeLayoutTreeSkip, number>;
|
|
77
|
+
/** 変換できない理由。あれば何も書かない */
|
|
78
|
+
blocked?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface FreeLayoutTreeResult {
|
|
82
|
+
/** 絶対配置にした箱の数 */
|
|
83
|
+
items: number;
|
|
84
|
+
/** 子を倒した器の数 */
|
|
85
|
+
containers: number;
|
|
86
|
+
/** 基準が変わったので位置を書き直した、元から absolute の要素の数 */
|
|
87
|
+
reanchored: number;
|
|
88
|
+
skipped: Record<FreeLayoutTreeSkip, number>;
|
|
89
|
+
/** 変換しなかった理由(巻き戻し済み) */
|
|
90
|
+
error?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 箱として倒すが中へは降りない要素 */
|
|
94
|
+
const LEAF_TAGS: Record<string, FreeLayoutTreeSkip> = {
|
|
95
|
+
P: 'element',
|
|
96
|
+
H1: 'element',
|
|
97
|
+
H2: 'element',
|
|
98
|
+
H3: 'element',
|
|
99
|
+
H4: 'element',
|
|
100
|
+
H5: 'element',
|
|
101
|
+
H6: 'element',
|
|
102
|
+
A: 'element',
|
|
103
|
+
BUTTON: 'element',
|
|
104
|
+
LABEL: 'element',
|
|
105
|
+
FIGCAPTION: 'element',
|
|
106
|
+
BLOCKQUOTE: 'element',
|
|
107
|
+
PRE: 'element',
|
|
108
|
+
INPUT: 'element',
|
|
109
|
+
SELECT: 'element',
|
|
110
|
+
TEXTAREA: 'element',
|
|
111
|
+
FIELDSET: 'element',
|
|
112
|
+
DETAILS: 'element',
|
|
113
|
+
IMG: 'media',
|
|
114
|
+
PICTURE: 'media',
|
|
115
|
+
VIDEO: 'media',
|
|
116
|
+
AUDIO: 'media',
|
|
117
|
+
CANVAS: 'media',
|
|
118
|
+
IFRAME: 'media',
|
|
119
|
+
OBJECT: 'media',
|
|
120
|
+
EMBED: 'media',
|
|
121
|
+
MATH: 'media',
|
|
122
|
+
SVG: 'svg',
|
|
123
|
+
TABLE: 'table',
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** display:inline でも中身が 1 つの箱として描かれる要素(置換要素とフォーム部品) */
|
|
127
|
+
const ATOMIC_INLINE_TAGS = new Set([
|
|
128
|
+
'IMG', 'SVG', 'VIDEO', 'AUDIO', 'CANVAS', 'IFRAME', 'OBJECT', 'EMBED', 'INPUT', 'SELECT', 'TEXTAREA', 'BUTTON', 'MATH',
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
const MEASURE_ATTR = 'data-gg-freelayout-measure';
|
|
132
|
+
|
|
133
|
+
const tagOf = (el: Element): string => el.tagName.toUpperCase();
|
|
134
|
+
|
|
135
|
+
/** 文字の流れ(行)に乗っている要素か。これを子に持つ器は、その流れを残すため器にしない */
|
|
136
|
+
function isFlowInline(el: HTMLElement, cs: CSSStyleDeclaration): boolean {
|
|
137
|
+
if (isNonEditableTag(el)) return true;
|
|
138
|
+
const d = cs.display;
|
|
139
|
+
if (d === 'contents' || d.startsWith('table-') || d.startsWith('ruby')) return true;
|
|
140
|
+
return d === 'inline' && !ATOMIC_INLINE_TAGS.has(tagOf(el));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function hasDirectText(el: HTMLElement): boolean {
|
|
144
|
+
for (const node of Array.from(el.childNodes)) {
|
|
145
|
+
if (node.nodeType === 3 && node.textContent && node.textContent.trim()) return true;
|
|
146
|
+
}
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 中へ降りない理由。null なら子を倒せる */
|
|
151
|
+
function leafReason(
|
|
152
|
+
el: HTMLElement,
|
|
153
|
+
cs: CSSStyleDeclaration,
|
|
154
|
+
kids: HTMLElement[],
|
|
155
|
+
win: Window,
|
|
156
|
+
): FreeLayoutTreeSkip | null {
|
|
157
|
+
const tag = tagOf(el);
|
|
158
|
+
const byTag = LEAF_TAGS[tag];
|
|
159
|
+
if (byTag) return byTag;
|
|
160
|
+
if ((tag === 'UL' || tag === 'OL') && !/(^|-)(flex|grid)$/.test(cs.display)) return 'list';
|
|
161
|
+
if (isFlowInline(el, cs)) return 'inline';
|
|
162
|
+
if (cs.columnCount !== 'auto' || cs.columnWidth !== 'auto') return 'columns';
|
|
163
|
+
if (hasDirectText(el)) return 'text';
|
|
164
|
+
if (kids.some((k) => isFlowInline(k, win.getComputedStyle(k)))) return 'inline';
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 絶対配置の子の基準(包含ブロック)を作る要素か */
|
|
169
|
+
function establishesContainingBlock(cs: CSSStyleDeclaration): boolean {
|
|
170
|
+
return (
|
|
171
|
+
cs.position !== 'static' ||
|
|
172
|
+
cs.transform !== 'none' ||
|
|
173
|
+
(cs.translate && cs.translate !== 'none') ||
|
|
174
|
+
(cs.rotate && cs.rotate !== 'none') ||
|
|
175
|
+
(cs.scale && cs.scale !== 'none') ||
|
|
176
|
+
cs.filter !== 'none' ||
|
|
177
|
+
cs.perspective !== 'none' ||
|
|
178
|
+
/paint|layout|strict|content/.test(cs.contain) ||
|
|
179
|
+
/transform|perspective|filter/.test(cs.willChange)
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const emptySkipped = (): Record<FreeLayoutTreeSkip, number> => ({
|
|
184
|
+
text: 0,
|
|
185
|
+
inline: 0,
|
|
186
|
+
element: 0,
|
|
187
|
+
media: 0,
|
|
188
|
+
svg: 0,
|
|
189
|
+
table: 0,
|
|
190
|
+
list: 0,
|
|
191
|
+
columns: 0,
|
|
192
|
+
locked: 0,
|
|
193
|
+
fixed: 0,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 何をどう倒すかを決める(DOM は 1 つも書かない)。
|
|
198
|
+
* メニューの出し分け(倒せる箱が 1 つ以上あるか)にも使う。
|
|
199
|
+
*/
|
|
200
|
+
export function planFreeLayoutTree(root: HTMLElement, iframeDoc: Document): FreeLayoutTreePlan {
|
|
201
|
+
const plan: FreeLayoutTreePlan = {
|
|
202
|
+
items: [],
|
|
203
|
+
roots: [],
|
|
204
|
+
containers: new Set(),
|
|
205
|
+
reanchors: [],
|
|
206
|
+
skipped: emptySkipped(),
|
|
207
|
+
};
|
|
208
|
+
const win = iframeDoc.defaultView;
|
|
209
|
+
if (!win || isLockedInsidePart(root)) return plan;
|
|
210
|
+
const itemSet = new Set<HTMLElement>();
|
|
211
|
+
|
|
212
|
+
const rendered = (el: HTMLElement): HTMLElement[] =>
|
|
213
|
+
layoutChildren(el).filter((k) => win.getComputedStyle(k).display !== 'none');
|
|
214
|
+
|
|
215
|
+
const visit = (el: HTMLElement, role: 'root' | 'item'): void => {
|
|
216
|
+
const cs = win.getComputedStyle(el);
|
|
217
|
+
const kids = rendered(el);
|
|
218
|
+
// 中身の無い箱(画像の枠・区切り線)。自身が item なら箱として倒れる
|
|
219
|
+
if (kids.length === 0 && !hasDirectText(el)) return;
|
|
220
|
+
const reason = leafReason(el, cs, kids, win);
|
|
221
|
+
if (reason) {
|
|
222
|
+
plan.skipped[reason]++;
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
// 部品のスロットの外。器にせず(高さも position も書かない)に降りて、スロットを起点にする
|
|
226
|
+
if (isLockedInsidePart(el) || kids.some((k) => isLockedInsidePart(k))) {
|
|
227
|
+
plan.skipped.locked++;
|
|
228
|
+
for (const k of kids) {
|
|
229
|
+
if (win.getComputedStyle(k).position === 'fixed') plan.skipped.fixed++;
|
|
230
|
+
else visit(k, 'root');
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const flow: HTMLElement[] = [];
|
|
235
|
+
const positioned: HTMLElement[] = [];
|
|
236
|
+
for (const k of kids) {
|
|
237
|
+
const pos = win.getComputedStyle(k).position;
|
|
238
|
+
if (pos === 'fixed') plan.skipped.fixed++;
|
|
239
|
+
// 前の変換で倒した子(印あり)は、測り直して書き直す(同じ位置になる)
|
|
240
|
+
else if (pos === 'absolute' && !k.classList.contains(FREELAYOUT_ITEM_CLASS)) positioned.push(k);
|
|
241
|
+
else flow.push(k);
|
|
242
|
+
}
|
|
243
|
+
if (flow.length > 0) {
|
|
244
|
+
plan.containers.add(el);
|
|
245
|
+
if (role === 'root') plan.roots.push(el);
|
|
246
|
+
for (const k of flow) {
|
|
247
|
+
plan.items.push({ element: k, container: el });
|
|
248
|
+
itemSet.add(k);
|
|
249
|
+
}
|
|
250
|
+
for (const k of flow) visit(k, 'item');
|
|
251
|
+
}
|
|
252
|
+
// 元から absolute の子は倒さないが、その中の箱は倒す(その子が器になる)
|
|
253
|
+
for (const k of positioned) visit(k, 'root');
|
|
254
|
+
};
|
|
255
|
+
visit(root, 'root');
|
|
256
|
+
|
|
257
|
+
// 基準が変わる、元から absolute の要素を探す。
|
|
258
|
+
// 倒した箱と、流れに残る器(static なら relative にする)が新しく基準を作る
|
|
259
|
+
const future = new Set<HTMLElement>([...itemSet, ...plan.roots]);
|
|
260
|
+
const containingBlock = (el: HTMLElement, after: boolean): Element | null => {
|
|
261
|
+
for (let a = el.parentElement; a; a = a.parentElement) {
|
|
262
|
+
if (after && future.has(a)) return a;
|
|
263
|
+
if (establishesContainingBlock(win.getComputedStyle(a))) return a;
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
};
|
|
267
|
+
for (const d of Array.from(root.querySelectorAll<HTMLElement>('*'))) {
|
|
268
|
+
if (itemSet.has(d) || d.namespaceURI !== 'http://www.w3.org/1999/xhtml' || isEditorOverlay(d)) continue;
|
|
269
|
+
if (win.getComputedStyle(d).position !== 'absolute') continue;
|
|
270
|
+
const before = containingBlock(d, false);
|
|
271
|
+
const after = containingBlock(d, true);
|
|
272
|
+
if (before === after || !after) continue;
|
|
273
|
+
if (isLockedInsidePart(d)) {
|
|
274
|
+
plan.blocked = '部品のスロットの外にある絶対配置の要素の基準が変わるため、変換できません';
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
plan.reanchors.push({ element: d, container: after as HTMLElement });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return plan;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** 倒せる箱が 1 つ以上あるか(メニューの出し分け) */
|
|
284
|
+
export function canEnableFreeLayoutTree(root: HTMLElement, iframeDoc: Document): boolean {
|
|
285
|
+
const plan = planFreeLayoutTree(root, iframeDoc);
|
|
286
|
+
return !plan.blocked && plan.items.some(({ element }) => !element.classList.contains(FREELAYOUT_ITEM_CLASS));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* 変換の前に、書体と木の中の画像の読み込みを待つ。
|
|
291
|
+
* 読み込み前に測ると、あとで文字の幅や画像の高さが変わった分だけ固定した寸法とずれる。
|
|
292
|
+
* 遅延読み込みで永遠に来ない画像もあるので、待つのは timeoutMs まで
|
|
293
|
+
*/
|
|
294
|
+
export async function waitForFreeLayoutTreeReady(
|
|
295
|
+
root: HTMLElement,
|
|
296
|
+
iframeDoc: Document,
|
|
297
|
+
timeoutMs = 1500,
|
|
298
|
+
): Promise<void> {
|
|
299
|
+
const fonts = (iframeDoc as Document & { fonts?: { ready: Promise<unknown> } }).fonts?.ready ?? Promise.resolve();
|
|
300
|
+
const images = Array.from(root.querySelectorAll('img'))
|
|
301
|
+
.filter((img) => !img.complete)
|
|
302
|
+
.map(
|
|
303
|
+
(img) =>
|
|
304
|
+
new Promise<void>((resolve) => {
|
|
305
|
+
img.addEventListener('load', () => resolve(), { once: true });
|
|
306
|
+
img.addEventListener('error', () => resolve(), { once: true });
|
|
307
|
+
}),
|
|
308
|
+
);
|
|
309
|
+
await Promise.race([
|
|
310
|
+
Promise.all([fonts, ...images]),
|
|
311
|
+
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
|
|
312
|
+
]);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
interface StyleUndo {
|
|
316
|
+
element: HTMLElement;
|
|
317
|
+
style: string | null;
|
|
318
|
+
className: string | null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const snapshot = (el: HTMLElement): StyleUndo => ({
|
|
322
|
+
element: el,
|
|
323
|
+
style: el.getAttribute('style'),
|
|
324
|
+
className: el.getAttribute('class'),
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
function rollback(undos: StyleUndo[]): void {
|
|
328
|
+
for (const u of undos) {
|
|
329
|
+
if (u.style === null) u.element.removeAttribute('style');
|
|
330
|
+
else u.element.setAttribute('style', u.style);
|
|
331
|
+
if (u.className === null) u.element.removeAttribute('class');
|
|
332
|
+
else u.element.setAttribute('class', u.className);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* 測る間だけ、木の中の変形と遷移を止める。戻り値の関数で元に戻す。
|
|
338
|
+
*
|
|
339
|
+
* style 属性ではなく <head> の一時的な <style> と、選んだ要素の属性で掛ける
|
|
340
|
+
* (書き込みで style 属性を触るので、そこに混ぜると戻すときに区別できない)。
|
|
341
|
+
* 変形を戻す瞬間に遷移が走らないよう、変形 → 遷移 の順に 1 段ずつ外す
|
|
342
|
+
*/
|
|
343
|
+
function freezeTransforms(iframeDoc: Document, root: HTMLElement): () => void {
|
|
344
|
+
const style = iframeDoc.createElement('style');
|
|
345
|
+
style.setAttribute(MEASURE_ATTR, '');
|
|
346
|
+
const sel = (mode: string) => `[${MEASURE_ATTR}${mode}],[${MEASURE_ATTR}${mode}] *`;
|
|
347
|
+
style.textContent =
|
|
348
|
+
`${sel('')}{transition:none!important}` +
|
|
349
|
+
`${sel('="measure"')}{transform:none!important;translate:none!important;rotate:none!important;scale:none!important}`;
|
|
350
|
+
(iframeDoc.head ?? iframeDoc.documentElement).appendChild(style);
|
|
351
|
+
root.setAttribute(MEASURE_ATTR, 'measure');
|
|
352
|
+
root.getBoundingClientRect();
|
|
353
|
+
return () => {
|
|
354
|
+
root.setAttribute(MEASURE_ATTR, 'hold');
|
|
355
|
+
root.getBoundingClientRect();
|
|
356
|
+
root.removeAttribute(MEASURE_ATTR);
|
|
357
|
+
style.remove();
|
|
358
|
+
root.getBoundingClientRect();
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const round2 = (v: number): number => Math.round(v * 100) / 100;
|
|
363
|
+
|
|
364
|
+
/** 流れの中で次に来る兄弟(余白の相殺で位置が変わっていないかを見る目安) */
|
|
365
|
+
function nextFlowSibling(el: HTMLElement, win: Window): HTMLElement | null {
|
|
366
|
+
for (let n = el.nextElementSibling as HTMLElement | null; n; n = n.nextElementSibling as HTMLElement | null) {
|
|
367
|
+
if (isEditorOverlay(n) || typeof n.getBoundingClientRect !== 'function') continue;
|
|
368
|
+
const cs = win.getComputedStyle(n);
|
|
369
|
+
if (cs.display === 'none' || cs.position === 'absolute' || cs.position === 'fixed') continue;
|
|
370
|
+
return n;
|
|
371
|
+
}
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* 選んだ要素から下の箱を、見た目を変えずに全部絶対配置にする。
|
|
377
|
+
*
|
|
378
|
+
* 選んだ要素自身は流れに残り(高さを固定する)、ページの高さは変わらない。
|
|
379
|
+
* 1 つでも 1px を超えてずれたら、木ごと巻き戻して error を返す。
|
|
380
|
+
* 呼ぶ前に waitForFreeLayoutTreeReady で書体と画像を待つこと。
|
|
381
|
+
*/
|
|
382
|
+
export function enableFreeLayoutTree(root: HTMLElement, iframeDoc: Document): FreeLayoutTreeResult {
|
|
383
|
+
const plan = planFreeLayoutTree(root, iframeDoc);
|
|
384
|
+
const result: FreeLayoutTreeResult = { items: 0, containers: 0, reanchored: 0, skipped: plan.skipped };
|
|
385
|
+
const win = iframeDoc.defaultView;
|
|
386
|
+
if (!win) return result;
|
|
387
|
+
if (plan.blocked) return { ...result, error: plan.blocked };
|
|
388
|
+
if (plan.items.length === 0) return result;
|
|
389
|
+
|
|
390
|
+
const scale = getArtboardScale(iframeDoc);
|
|
391
|
+
const touched = new Set<HTMLElement>([
|
|
392
|
+
...plan.roots,
|
|
393
|
+
...plan.items.map((i) => i.element),
|
|
394
|
+
...plan.reanchors.map((r) => r.element),
|
|
395
|
+
]);
|
|
396
|
+
const undos = Array.from(touched, snapshot);
|
|
397
|
+
const unfreeze = freezeTransforms(iframeDoc, root);
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
// ① 全部測る(まだ何も書かない)
|
|
401
|
+
const rects = new Map<HTMLElement, DOMRect>();
|
|
402
|
+
const rectOf = (el: HTMLElement): DOMRect => {
|
|
403
|
+
let r = rects.get(el);
|
|
404
|
+
if (!r) {
|
|
405
|
+
r = el.getBoundingClientRect();
|
|
406
|
+
rects.set(el, r);
|
|
407
|
+
}
|
|
408
|
+
return r;
|
|
409
|
+
};
|
|
410
|
+
// 器の padding box の左上(ビューポート座標)。スクロールしている器はその分だけ戻す
|
|
411
|
+
const origins = new Map<HTMLElement, { x: number; y: number }>();
|
|
412
|
+
const originOf = (k: HTMLElement): { x: number; y: number } => {
|
|
413
|
+
let o = origins.get(k);
|
|
414
|
+
if (!o) {
|
|
415
|
+
const r = rectOf(k);
|
|
416
|
+
const cs = win.getComputedStyle(k);
|
|
417
|
+
o = {
|
|
418
|
+
x: r.left + ((parseFloat(cs.borderLeftWidth) || 0) - (k.scrollLeft || 0)) * scale,
|
|
419
|
+
y: r.top + ((parseFloat(cs.borderTopWidth) || 0) - (k.scrollTop || 0)) * scale,
|
|
420
|
+
};
|
|
421
|
+
origins.set(k, o);
|
|
422
|
+
}
|
|
423
|
+
return o;
|
|
424
|
+
};
|
|
425
|
+
const place = (list: { element: HTMLElement; container: HTMLElement }[]) =>
|
|
426
|
+
list.map(({ element, container }) => {
|
|
427
|
+
const r = rectOf(element);
|
|
428
|
+
const o = originOf(container);
|
|
429
|
+
return {
|
|
430
|
+
element,
|
|
431
|
+
left: (r.left - o.x) / scale,
|
|
432
|
+
top: (r.top - o.y) / scale,
|
|
433
|
+
size: styleSizeFor(win.getComputedStyle(element), r.width / scale, r.height / scale),
|
|
434
|
+
};
|
|
435
|
+
});
|
|
436
|
+
const itemPlans = place(plan.items);
|
|
437
|
+
const reanchorPlans = place(plan.reanchors);
|
|
438
|
+
const rootPlans = plan.roots.map((element) => {
|
|
439
|
+
const cs = win.getComputedStyle(element);
|
|
440
|
+
const next = nextFlowSibling(element, win);
|
|
441
|
+
const parent = element.parentElement;
|
|
442
|
+
return {
|
|
443
|
+
element,
|
|
444
|
+
isStatic: cs.position === 'static',
|
|
445
|
+
inFlow: cs.position !== 'absolute',
|
|
446
|
+
height: cs.height,
|
|
447
|
+
width: cs.width,
|
|
448
|
+
next,
|
|
449
|
+
nextRect: next ? next.getBoundingClientRect() : null,
|
|
450
|
+
parent,
|
|
451
|
+
parentRect: parent ? parent.getBoundingClientRect() : null,
|
|
452
|
+
};
|
|
453
|
+
});
|
|
454
|
+
for (const el of touched) rectOf(el);
|
|
455
|
+
|
|
456
|
+
// ② 全部書く。先に器(流れに残るもの)、次に倒す箱
|
|
457
|
+
for (const rp of rootPlans) {
|
|
458
|
+
const el = rp.element;
|
|
459
|
+
capturePrestyle(el);
|
|
460
|
+
const ours =
|
|
461
|
+
el.classList.contains(FREELAYOUT_CLASS) ||
|
|
462
|
+
el.classList.contains(FREELAYOUT_HOLD_CLASS) ||
|
|
463
|
+
el.classList.contains(FREELAYOUT_ITEM_CLASS);
|
|
464
|
+
if (rp.isStatic) {
|
|
465
|
+
rememberFlowValues(el, ['position']);
|
|
466
|
+
el.style.position = 'relative';
|
|
467
|
+
el.classList.add(FREELAYOUT_REL_CLASS);
|
|
468
|
+
}
|
|
469
|
+
if (!ours) rememberFlowValues(el, ['height']);
|
|
470
|
+
el.style.height = rp.height;
|
|
471
|
+
el.classList.add(FREELAYOUT_CLASS);
|
|
472
|
+
}
|
|
473
|
+
for (const p of [...itemPlans, ...reanchorPlans]) {
|
|
474
|
+
const el = p.element;
|
|
475
|
+
capturePrestyle(el);
|
|
476
|
+
rememberItemGeometry(el);
|
|
477
|
+
el.classList.add(FREELAYOUT_ITEM_CLASS);
|
|
478
|
+
el.style.position = 'absolute';
|
|
479
|
+
el.style.left = `${round2(p.left)}px`;
|
|
480
|
+
el.style.top = `${round2(p.top)}px`;
|
|
481
|
+
el.style.width = p.size.width;
|
|
482
|
+
el.style.height = p.size.height;
|
|
483
|
+
// 絶対配置では margin は位置のずれにしかならない。left / top に一本化する
|
|
484
|
+
el.style.margin = '0';
|
|
485
|
+
// 解除したときに flex:1 が復活して伸びないよう、明示的に止めておく
|
|
486
|
+
el.style.flex = 'none';
|
|
487
|
+
el.style.flexGrow = '0';
|
|
488
|
+
el.style.flexShrink = '0';
|
|
489
|
+
if (plan.containers.has(el)) el.classList.add(FREELAYOUT_CLASS);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ③ 流れに残る器の外側の手当て。
|
|
493
|
+
// - 余白の相殺: 最初/最後の子の margin が器を突き抜けていた場合、子が抜けると器が上下にずれる
|
|
494
|
+
// - 中身で決まっていた幅: flex の子や inline-block は、子が抜けると幅が縮む
|
|
495
|
+
// ずれた分だけ margin / width を書き足し、測り直す(相殺は最大値で決まるので 1 回で合わないことがある)
|
|
496
|
+
const px = (v: string): number => parseFloat(v) || 0;
|
|
497
|
+
for (let pass = 0; pass < 6; pass++) {
|
|
498
|
+
let adjusted = false;
|
|
499
|
+
for (const rp of rootPlans) {
|
|
500
|
+
const el = rp.element;
|
|
501
|
+
const before = rectOf(el);
|
|
502
|
+
const now = el.getBoundingClientRect();
|
|
503
|
+
if (Math.abs(before.width - now.width) / scale > 0.5) {
|
|
504
|
+
rememberFlowValues(el, ['width'], true);
|
|
505
|
+
el.style.width = rp.width;
|
|
506
|
+
adjusted = true;
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
if (!rp.inFlow) continue;
|
|
510
|
+
const dy = (before.top - now.top) / scale;
|
|
511
|
+
if (Math.abs(dy) > 0.5) {
|
|
512
|
+
rememberFlowValues(el, ['margin-top'], true);
|
|
513
|
+
el.style.marginTop = `${round2(px(win.getComputedStyle(el).marginTop) + dy)}px`;
|
|
514
|
+
adjusted = true;
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
let dBottom = 0;
|
|
518
|
+
if (rp.next && rp.nextRect) dBottom = (rp.nextRect.top - rp.next.getBoundingClientRect().top) / scale;
|
|
519
|
+
else if (rp.parent && rp.parentRect) dBottom = (rp.parentRect.height - rp.parent.getBoundingClientRect().height) / scale;
|
|
520
|
+
if (Math.abs(dBottom) > 0.5) {
|
|
521
|
+
rememberFlowValues(el, ['margin-bottom'], true);
|
|
522
|
+
el.style.marginBottom = `${round2(px(win.getComputedStyle(el).marginBottom) + dBottom)}px`;
|
|
523
|
+
adjusted = true;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (!adjusted) break;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ④ 全部確かめる。1 つでもずれたら木ごと戻す(崩れた紙面より、変換していない紙面のほうがまし)
|
|
530
|
+
const TOLERANCE = 1;
|
|
531
|
+
const differs = (a: DOMRect, b: DOMRect): boolean =>
|
|
532
|
+
Math.abs(a.left - b.left) / scale > TOLERANCE ||
|
|
533
|
+
Math.abs(a.top - b.top) / scale > TOLERANCE ||
|
|
534
|
+
Math.abs(a.width - b.width) / scale > TOLERANCE ||
|
|
535
|
+
Math.abs(a.height - b.height) / scale > TOLERANCE;
|
|
536
|
+
const moved = Array.from(touched).filter((el) => differs(rectOf(el), el.getBoundingClientRect()));
|
|
537
|
+
const outsideMoved = rootPlans.some(
|
|
538
|
+
(rp) =>
|
|
539
|
+
(rp.next && rp.nextRect && differs(rp.nextRect, rp.next.getBoundingClientRect())) ||
|
|
540
|
+
(!rp.next && rp.parent && rp.parentRect && Math.abs(rp.parentRect.height - rp.parent.getBoundingClientRect().height) / scale > TOLERANCE),
|
|
541
|
+
);
|
|
542
|
+
if (moved.length > 0 || outsideMoved) {
|
|
543
|
+
console.warn('[enableFreeLayoutTree] 変換で位置がずれたため巻き戻します:', moved.length, '要素', outsideMoved ? '(外側も)' : '');
|
|
544
|
+
rollback(undos);
|
|
545
|
+
return {
|
|
546
|
+
...result,
|
|
547
|
+
error: `${moved.length || 1}個の要素の位置が変わってしまうため、変換を取り消しました`,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
result.items = itemPlans.length;
|
|
552
|
+
result.containers = plan.containers.size;
|
|
553
|
+
result.reanchored = reanchorPlans.length;
|
|
554
|
+
debugLog('[enableFreeLayoutTree] 絶対配置へ:', result);
|
|
555
|
+
return result;
|
|
556
|
+
} catch (err) {
|
|
557
|
+
rollback(undos);
|
|
558
|
+
throw err;
|
|
559
|
+
} finally {
|
|
560
|
+
unfreeze();
|
|
561
|
+
}
|
|
562
|
+
}
|