@growgroup/visual-editor 0.6.0 → 0.7.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.
Files changed (35) hide show
  1. package/README.md +45 -0
  2. package/dist/editor.css +1 -1
  3. package/package.json +1 -1
  4. package/src/editor/FrontendVisualEditor.tsx +173 -0
  5. package/src/editor/components/EditorCanvas.tsx +16 -0
  6. package/src/editor/components/EditorContextMenu.tsx +80 -0
  7. package/src/editor/components/EditorPropertyPanel.tsx +248 -80
  8. package/src/editor/components/EditorSidebar.tsx +2 -2
  9. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +38 -19
  10. package/src/editor/components/ppt/PptChrome.tsx +46 -4
  11. package/src/editor/components/property-panel/VariableAwareSizeInput.tsx +94 -50
  12. package/src/editor/components/property-panel/VariableAwareUnitInput.tsx +12 -6
  13. package/src/editor/constants.ts +24 -0
  14. package/src/editor/contexts/MultiPageCanvasContext.tsx +108 -6
  15. package/src/editor/hooks/index.ts +3 -0
  16. package/src/editor/hooks/useDragResize.ts +30 -5
  17. package/src/editor/hooks/useElementActions.ts +41 -9
  18. package/src/editor/hooks/useElementSelection.ts +4 -2
  19. package/src/editor/hooks/useIframeSetup.ts +8 -1
  20. package/src/editor/hooks/useInlineTextSelection.ts +16 -0
  21. package/src/editor/hooks/useKeepArtboardInPlace.ts +87 -0
  22. package/src/editor/hooks/useLinkNavigation.ts +160 -0
  23. package/src/editor/parts.ts +2 -0
  24. package/src/editor/types.ts +4 -0
  25. package/src/editor/utils/dom-utils.ts +45 -0
  26. package/src/editor/utils/free-layout-tree.ts +562 -0
  27. package/src/editor/utils/free-layout.ts +794 -0
  28. package/src/editor/utils/geometry.ts +41 -3
  29. package/src/editor/utils/html-utils.ts +2 -1
  30. package/src/editor/utils/inline-text-style.ts +426 -0
  31. package/src/editor/utils/link-navigation.ts +474 -0
  32. package/src/editor/utils/style-utils.ts +24 -0
  33. package/src/editor/utils/tailwind-utils.ts +12 -0
  34. package/src/editor/utils/text-line-break.ts +121 -0
  35. package/src/version.ts +1 -1
@@ -89,6 +89,37 @@ export interface ResizeModifiers {
89
89
  altKey?: boolean;
90
90
  /** 最小サイズ(既定 MIN_ELEMENT_SIZE) */
91
91
  minSize?: number;
92
+ /** 要素の min / max-width・height(px)。ハンドルで大きさを変えても、紙面と同じところで止める */
93
+ limits?: SizeLimits;
94
+ }
95
+
96
+ /** 要素の最小・最大の幅と高さ(px)。指定が無い・px で読めない軸は 0 / Infinity */
97
+ export interface SizeLimits {
98
+ minWidth: number;
99
+ maxWidth: number;
100
+ minHeight: number;
101
+ maxHeight: number;
102
+ }
103
+
104
+ /**
105
+ * 要素の min / max-width・height を計算値から読む。
106
+ * % などの相対値は計算値でも px にならないので、その軸は制約なしとして扱う(紙面側の CSS が効く)
107
+ */
108
+ export function readSizeLimits(element: HTMLElement, doc: Document): SizeLimits {
109
+ const cs = doc.defaultView?.getComputedStyle(element);
110
+ const px = (v: string | undefined, fallback: number) =>
111
+ v && /^-?\d+(\.\d+)?px$/.test(v) ? parseFloat(v) : fallback;
112
+ return {
113
+ minWidth: px(cs?.minWidth, 0),
114
+ maxWidth: px(cs?.maxWidth, Infinity),
115
+ minHeight: px(cs?.minHeight, 0),
116
+ maxHeight: px(cs?.maxHeight, Infinity),
117
+ };
118
+ }
119
+
120
+ /** CSS と同じく min が max に勝つ */
121
+ function clampToLimit(value: number, min: number, max: number): number {
122
+ return Math.max(min, Math.min(max, value));
92
123
  }
93
124
 
94
125
  /**
@@ -143,9 +174,16 @@ export function computeResizeGeometry(
143
174
  }
144
175
  }
145
176
 
146
- // 4. クランプ
147
- const rawWidth = Math.max(minSize, orig.width + dw);
148
- const rawHeight = Math.max(minSize, orig.height + dh);
177
+ // 4. クランプ(最小サイズと、要素の min / max-width・height)
178
+ const limits = mods.limits;
179
+ const rawWidth = Math.max(
180
+ minSize,
181
+ limits ? clampToLimit(orig.width + dw, limits.minWidth, limits.maxWidth) : orig.width + dw,
182
+ );
183
+ const rawHeight = Math.max(
184
+ minSize,
185
+ limits ? clampToLimit(orig.height + dh, limits.minHeight, limits.maxHeight) : orig.height + dh,
186
+ );
149
187
 
150
188
  // 5. クランプ後の実増分でアンカーを決める
151
189
  const appliedDw = rawWidth - orig.width;
@@ -6,7 +6,7 @@ import { SLIDE_WIDTH, SLIDE_HEIGHT, WEBPAGE_WIDTH, WEBPAGE_MIN_HEIGHT } from '..
6
6
  import type { PreviewStyle } from '../../io';
7
7
  import type { EditorMode } from '../EditorContext';
8
8
  import { prepareHtmlForSave } from './viewport-utils';
9
- import { classifySaveAndRevert } from './dom-utils';
9
+ import { classifySaveAndRevert, isNonEditableTag } from './dom-utils';
10
10
 
11
11
  // キャンバス背景色(Figmaライクなダークグレー)
12
12
  const CANVAS_BG_COLOR = '#1a1a1a';
@@ -454,6 +454,7 @@ export function makeChildrenEditable(container: HTMLElement): number {
454
454
  el.tagName === 'SCRIPT' ||
455
455
  el.tagName === 'STYLE' ||
456
456
  el.tagName === 'LINK' ||
457
+ isNonEditableTag(el) ||
457
458
  el.classList?.contains('selection-box') ||
458
459
  el.classList?.contains('marquee-selection-box') ||
459
460
  // [移植時の修正] スライドのキャンバス自体(#artboard と同じ大きさの直下の器)は
@@ -0,0 +1,426 @@
1
+ /**
2
+ * テキストの一部を選んだまま文字の大きさ・太さ・色・字間を変えると、選んだ範囲だけに当てる
3
+ * (Figma の文字の部分スタイルに当たる)。
4
+ *
5
+ * 【何が起きていたか】
6
+ * 右パネルの入力欄にフォーカスが移るとテキスト編集は終わる(contenteditable が外れる)が、
7
+ * iframe の選択範囲は残っている。エディタはそれを読まず、選択中の要素(段落)全体に当てていた。
8
+ * 選んだ文字が自分の大きさを持つ行内要素(`<span class="text-sm">`)だと、段落が大きくなり
9
+ * 選んだ所は元のまま = 「選んだ所ではなく、それ以外の文字が大きくなる」。
10
+ *
11
+ * 【方針】
12
+ * - テキスト編集中に範囲を選んだら覚えておく。パネルへフォーカスが移っても忘れない。
13
+ * 紙面を押す・キャレットだけにする・要素の文字を全部選ぶ(編集に入った直後の全選択)と忘れる
14
+ * - 範囲に当てるのは文字の属性(INLINE_TEXT_PROPERTIES)だけ。行間・揃え・余白は要素に当てる(呼び出し側)
15
+ * - 範囲の中の文字をテキストノードごとに見る。行内要素の文字を丸ごと選んでいればその要素に当て
16
+ * (包みを増やさない)、一部だけならその部分を <span> で包む。包んだ中にある同じ指定は外し、
17
+ * 何の指定も無くなった span はほどく
18
+ */
19
+
20
+ import { generateElementId } from './dom-utils';
21
+ import { applyTailwindStyles, clearInlineStyle, removeConflictingClasses } from './tailwind-utils';
22
+
23
+ /** 範囲に当てる文字の属性。これ以外(行間・揃え・余白・塗り)は要素に当てる */
24
+ export const INLINE_TEXT_PROPERTIES: ReadonlySet<string> = new Set([
25
+ 'fontSize',
26
+ 'fontWeight',
27
+ 'fontStyle',
28
+ 'textDecoration',
29
+ 'color',
30
+ 'letterSpacing',
31
+ 'fontFamily',
32
+ ]);
33
+
34
+ /** 範囲の中で値が揃っていないとき */
35
+ export const MIXED = '__mixed__';
36
+
37
+ /** 覚えている範囲の文字の見た目(computed style)。揃っていない属性は MIXED */
38
+ export interface InlineTextSummary {
39
+ /** 範囲を含む要素(テキスト編集していた要素)の data-element-id */
40
+ hostId: string;
41
+ fontSize: string;
42
+ fontWeight: string;
43
+ fontStyle: string;
44
+ textDecoration: string;
45
+ color: string;
46
+ letterSpacing: string;
47
+ fontFamily: string;
48
+ }
49
+
50
+ export interface InlineTextRange {
51
+ doc: Document;
52
+ host: HTMLElement;
53
+ hostId: string;
54
+ range: Range;
55
+ }
56
+
57
+ interface Segment {
58
+ node: Text;
59
+ start: number;
60
+ end: number;
61
+ }
62
+
63
+ const TOOLBAR_SELECTOR = '#gg-inline-format-toolbar';
64
+
65
+ /** 紙面の上に重ねた道具。ページの本文(#artboard)の外にあっても「紙面の外の余白」として扱わない */
66
+ const IN_FRAME_TOOLS = [
67
+ TOOLBAR_SELECTOR,
68
+ '.selection-box',
69
+ '.resize-handle',
70
+ '.rotation-handle',
71
+ '.border-radius-handle',
72
+ '.gg-collab-layer',
73
+ '.gg-comment-layer',
74
+ '#gg-measure-layer',
75
+ ].join(', ');
76
+
77
+ /** 文字の属性を持てる行内要素。これ以外(ブロック・画像・改行)には当てない */
78
+ const PHRASING_TAGS = new Set([
79
+ 'SPAN', 'STRONG', 'B', 'EM', 'I', 'U', 'S', 'SMALL', 'MARK', 'A', 'CODE', 'SUB', 'SUP',
80
+ 'FONT', 'ABBR', 'CITE', 'Q', 'DEL', 'INS', 'KBD', 'VAR', 'SAMP', 'DFN', 'TIME', 'DATA', 'BDI', 'BDO',
81
+ ]);
82
+
83
+ /** ほどいてよい span が持っていてよい属性(エディタの印と、空の class / style) */
84
+ const EDITOR_ATTRS = new Set(['data-editable', 'data-inline', 'data-element-id', 'contenteditable']);
85
+
86
+ let remembered: InlineTextRange | null = null;
87
+ let summary: InlineTextSummary | null = null;
88
+ const listeners = new Set<() => void>();
89
+
90
+ const squash = (s: string) => s.replace(/\s+/g, ' ').trim();
91
+
92
+ function setRemembered(next: InlineTextRange | null): void {
93
+ if (!next && !remembered) return;
94
+ remembered = next;
95
+ summary = next ? summarize(next) : null;
96
+ listeners.forEach((fn) => fn());
97
+ }
98
+
99
+ function isAlive(r: InlineTextRange): boolean {
100
+ return (
101
+ r.host.isConnected &&
102
+ !r.range.collapsed &&
103
+ r.host.contains(r.range.commonAncestorContainer) &&
104
+ !!squash(r.range.toString())
105
+ );
106
+ }
107
+
108
+ export function subscribeInlineTextSelection(fn: () => void): () => void {
109
+ listeners.add(fn);
110
+ return () => {
111
+ listeners.delete(fn);
112
+ };
113
+ }
114
+
115
+ export function getInlineTextSummary(): InlineTextSummary | null {
116
+ return summary;
117
+ }
118
+
119
+ /** 要素 elementId の中で覚えている範囲がまだ使えるなら返す(使えなくなっていたら忘れる) */
120
+ export function getInlineTextRange(doc: Document, elementId: string): InlineTextRange | null {
121
+ const r = remembered;
122
+ if (!r) return null;
123
+ if (!isAlive(r)) {
124
+ setRemembered(null);
125
+ return null;
126
+ }
127
+ return r.doc === doc && r.hostId === elementId ? r : null;
128
+ }
129
+
130
+ /** 範囲にかかるテキストノードと、その中の選ばれている位置。空白だけの部分は数えない */
131
+ function segmentsOf(range: Range): Segment[] {
132
+ const out: Segment[] = [];
133
+ const push = (node: Text) => {
134
+ const start = node === range.startContainer ? range.startOffset : 0;
135
+ const end = node === range.endContainer ? range.endOffset : node.length;
136
+ if (start < end && node.data.slice(start, end).trim()) out.push({ node, start, end });
137
+ };
138
+ const root = range.commonAncestorContainer;
139
+ if (root.nodeType === 3) {
140
+ push(root as Text);
141
+ return out;
142
+ }
143
+ const walker = root.ownerDocument!.createTreeWalker(root, 4 /* NodeFilter.SHOW_TEXT */);
144
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
145
+ if (range.intersectsNode(n)) push(n as Text);
146
+ }
147
+ return out;
148
+ }
149
+
150
+ function isInlineBox(el: Element): el is HTMLElement {
151
+ if (!PHRASING_TAGS.has(el.tagName)) return false;
152
+ const view = el.ownerDocument.defaultView;
153
+ return !!view && view.getComputedStyle(el).display.startsWith('inline');
154
+ }
155
+
156
+ /** 要素の中の文字(空白以外)がすべて「丸ごと選ばれたテキストノード」か */
157
+ function fullyCovered(el: Element, full: Set<Text>): boolean {
158
+ const walker = el.ownerDocument.createTreeWalker(el, 4);
159
+ let any = false;
160
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
161
+ const t = n as Text;
162
+ if (!t.data.trim()) continue;
163
+ if (!full.has(t)) return false;
164
+ any = true;
165
+ }
166
+ return any;
167
+ }
168
+
169
+ function isBareSpan(el: Element): boolean {
170
+ if (el.tagName !== 'SPAN') return false;
171
+ return Array.from(el.attributes).every(
172
+ (a) => EDITOR_ATTRS.has(a.name) || ((a.name === 'class' || a.name === 'style') && !a.value.trim()),
173
+ );
174
+ }
175
+
176
+ /** span を外して中身だけ残す(隣のテキストとはつながない。範囲の端に使うノードを消さないため) */
177
+ function unwrap(el: HTMLElement): void {
178
+ const parent = el.parentNode;
179
+ if (!parent) return;
180
+ while (el.firstChild) parent.insertBefore(el.firstChild, el);
181
+ parent.removeChild(el);
182
+ }
183
+
184
+ const inDocumentOrder = (a: Node, b: Node) =>
185
+ a.compareDocumentPosition(b) & 4 /* DOCUMENT_POSITION_FOLLOWING */ ? -1 : 1;
186
+
187
+ /** 改行と空白だけを挟んで隣り合う、同じ親の包み span を 1 つにまとめる */
188
+ function mergeAdjacent(spans: HTMLElement[]): HTMLElement[] {
189
+ const out: HTMLElement[] = [];
190
+ for (const span of spans) {
191
+ const prev = out[out.length - 1];
192
+ if (prev && prev.parentNode === span.parentNode) {
193
+ const between: Node[] = [];
194
+ let n = prev.nextSibling;
195
+ while (n && n !== span) {
196
+ const gap = (n.nodeType === 3 && !n.textContent!.trim()) || (n.nodeType === 1 && /^(BR|WBR)$/.test((n as Element).tagName));
197
+ if (!gap) break;
198
+ between.push(n);
199
+ n = n.nextSibling;
200
+ }
201
+ if (n === span) {
202
+ between.forEach((b) => prev.appendChild(b));
203
+ while (span.firstChild) prev.appendChild(span.firstChild);
204
+ span.remove();
205
+ continue;
206
+ }
207
+ }
208
+ out.push(span);
209
+ }
210
+ return out;
211
+ }
212
+
213
+ /**
214
+ * 覚えている範囲に文字の属性を当てる。範囲は当てた先の中身に張り直す
215
+ * (パネルで 1 桁ずつ打っても、2 回目以降は同じ要素に当たり、包みが増えない)。
216
+ */
217
+ export function applyInlineTextStyle(target: InlineTextRange, styles: Record<string, string>): void {
218
+ const { doc, host, range } = target;
219
+ const segs = segmentsOf(range);
220
+ if (!segs.length) return;
221
+
222
+ // 選ばれていない所が空白だけなら、そのテキストノードは丸ごと選ばれている
223
+ const full = new Set(
224
+ segs.filter((s) => !s.node.data.slice(0, s.start).trim() && !s.node.data.slice(s.end).trim()).map((s) => s.node),
225
+ );
226
+
227
+ // 1. 丸ごと選ばれている行内要素(いちばん外側)は、その要素に当てる
228
+ const owners: HTMLElement[] = [];
229
+ const partial: Segment[] = [];
230
+ for (const seg of segs) {
231
+ let owner: HTMLElement | null = null;
232
+ if (full.has(seg.node)) {
233
+ for (
234
+ let el = seg.node.parentElement;
235
+ el && el !== host && host.contains(el) && isInlineBox(el) && fullyCovered(el, full);
236
+ el = el.parentElement
237
+ ) {
238
+ owner = el;
239
+ }
240
+ }
241
+ if (owner) {
242
+ if (!owners.includes(owner)) owners.push(owner);
243
+ } else {
244
+ partial.push(seg);
245
+ }
246
+ }
247
+
248
+ // 2. 残りの文字は、選ばれている部分だけを span で包む
249
+ const wrapped: HTMLElement[] = [];
250
+ for (const seg of partial) {
251
+ if (owners.some((o) => o.contains(seg.node))) continue;
252
+ let node = seg.node;
253
+ if (seg.end < node.length) node.splitText(seg.end);
254
+ if (seg.start > 0) node = node.splitText(seg.start);
255
+ const span = doc.createElement('span');
256
+ span.setAttribute('data-editable', 'true');
257
+ span.setAttribute('data-inline', 'true');
258
+ span.setAttribute('data-element-id', generateElementId());
259
+ node.parentNode!.insertBefore(span, node);
260
+ span.appendChild(node);
261
+ wrapped.push(span);
262
+ }
263
+
264
+ const targets = [...owners, ...mergeAdjacent(wrapped.sort(inDocumentOrder))]
265
+ .filter((el, _, all) => !all.some((other) => other !== el && other.contains(el)))
266
+ .sort(inDocumentOrder);
267
+ if (!targets.length) return;
268
+
269
+ // 3. 中にある同じ指定を外してから当てる(外側の値が効くように)。指定が無くなった span はほどく
270
+ const props = Object.keys(styles);
271
+ for (const el of targets) {
272
+ el.querySelectorAll<HTMLElement>('*').forEach((d) => {
273
+ for (const p of props) {
274
+ if (d.getAttribute('class')) removeConflictingClasses(d, p);
275
+ clearInlineStyle(d, p);
276
+ }
277
+ if (d.getAttribute('style') === '') d.removeAttribute('style');
278
+ });
279
+ Array.from(el.querySelectorAll<HTMLElement>('span')).filter(isBareSpan).forEach(unwrap);
280
+ applyTailwindStyles(el, styles);
281
+ // 列挙の値(font-bold / font-normal / italic など)は applyTailwindStyles だとクラスだけになり、
282
+ // ページの CSS にそのクラスが無いと効かない(構成ラフの殻のコンパイル済み CSS には italic・no-underline が無い。
283
+ // 提案書デッキの紙面は Tailwind Browser が付いたクラスをその場で作るので効く)。
284
+ // 文字の一部に当てた値は、任意値のクラスと同じくインラインにも書いて、クラスの有無によらず効かせる
285
+ for (const [prop, value] of Object.entries(styles)) {
286
+ if (value) (el.style as unknown as Record<string, string>)[prop] = value;
287
+ }
288
+ }
289
+
290
+ const startRef = targets[0].firstChild ?? targets[0];
291
+ const endRef = targets[targets.length - 1].lastChild ?? targets[targets.length - 1];
292
+ targets.filter(isBareSpan).forEach(unwrap);
293
+
294
+ const next = doc.createRange();
295
+ next.setStartBefore(startRef);
296
+ next.setEndAfter(endRef);
297
+ const sel = doc.getSelection();
298
+ if (sel) {
299
+ sel.removeAllRanges();
300
+ sel.addRange(next);
301
+ }
302
+ setRemembered({ doc, host, hostId: target.hostId, range: next.cloneRange() });
303
+ }
304
+
305
+ function summarize(r: InlineTextRange): InlineTextSummary | null {
306
+ const view = r.doc.defaultView;
307
+ const segs = segmentsOf(r.range);
308
+ if (!view || !segs.length) return null;
309
+ const styles = segs.map((s) => view.getComputedStyle(s.node.parentElement!));
310
+ const pick = (read: (cs: CSSStyleDeclaration) => string) => {
311
+ const first = read(styles[0]);
312
+ return styles.every((cs) => read(cs) === first) ? first : MIXED;
313
+ };
314
+ return {
315
+ hostId: r.hostId,
316
+ fontSize: pick((cs) => cs.fontSize),
317
+ fontWeight: pick((cs) => cs.fontWeight),
318
+ fontStyle: pick((cs) => cs.fontStyle),
319
+ textDecoration: pick((cs) => cs.textDecorationLine),
320
+ color: pick((cs) => cs.color),
321
+ letterSpacing: pick((cs) => cs.letterSpacing),
322
+ fontFamily: pick((cs) => cs.fontFamily),
323
+ };
324
+ }
325
+
326
+ /**
327
+ * 紙面の文字の選択を消す(テキスト編集中なら編集も終える)。フレームの外をクリックしたとき用。
328
+ * 要素の選択には触らない(何も無い所をクリックしたときの既存の処理に任せる)
329
+ */
330
+ export function clearTextSelection(doc: Document): void {
331
+ const editing = doc.querySelector<HTMLElement>('[contenteditable="true"]');
332
+ if (editing) {
333
+ // フォーカスが残っていれば、blur で EditorCanvas の focusout が編集を終えて変更を履歴へ送る
334
+ editing.blur();
335
+ editing.classList.remove('editing');
336
+ editing.removeAttribute('contenteditable');
337
+ }
338
+ const sel = doc.getSelection();
339
+ if (sel && sel.rangeCount > 0) sel.removeAllRanges();
340
+ if (remembered?.doc === doc) setRemembered(null);
341
+ }
342
+
343
+ function hasTextSelection(doc: Document): boolean {
344
+ const sel = doc.getSelection();
345
+ return (!!sel && sel.rangeCount > 0 && !sel.isCollapsed) || !!doc.querySelector('[contenteditable="true"]');
346
+ }
347
+
348
+ /**
349
+ * テキスト編集中の範囲選択を覚える仕組みを iframe に取り付ける。
350
+ * @returns 後片付け関数
351
+ */
352
+ export function setupInlineTextSelection(doc: Document): () => void {
353
+ const onSelectionChange = () => {
354
+ const sel = doc.getSelection();
355
+ const range = sel && sel.rangeCount > 0 ? sel.getRangeAt(0) : null;
356
+ if (range && !range.collapsed) {
357
+ const common = range.commonAncestorContainer;
358
+ const el = common.nodeType === 1 ? (common as Element) : common.parentElement;
359
+ const host = el?.closest<HTMLElement>('[contenteditable="true"]') ?? null;
360
+ const hostId = host?.getAttribute('data-element-id');
361
+ if (host && hostId) {
362
+ const text = squash(range.toString());
363
+ // 文字を全部選んでいる(編集に入った直後の全選択を含む)ときは、要素全体の扱い
364
+ if (text && text !== squash(host.textContent || '')) {
365
+ setRemembered({ doc, host, hostId, range: range.cloneRange() });
366
+ } else {
367
+ setRemembered(null);
368
+ }
369
+ return;
370
+ }
371
+ }
372
+ // パネルへフォーカスが移ったあとの変化(編集の終わり・パネルからの適用)では忘れない
373
+ if (!doc.hasFocus()) {
374
+ if (remembered && !isAlive(remembered)) setRemembered(null);
375
+ return;
376
+ }
377
+ setRemembered(null);
378
+ };
379
+ // 紙面を押したら忘れる(要素の選択やドラッグで選択範囲が変わらない場合があるため)。
380
+ // 書式ツールバーのボタンは選択を保ったまま押すものなので除く
381
+ const onPointerDown = (e: Event) => {
382
+ const t = e.target as Element | null;
383
+ if (t?.closest?.(TOOLBAR_SELECTOR)) return;
384
+ setRemembered(null);
385
+ // 紙面の外の余白(ページの本文の外)を押したら、編集に入っていない文字の選択も消す。
386
+ // 編集中の選択は、紙面の mousedown(useElementSelection)が編集を終えるときに消す
387
+ if (t && !t.closest?.('#artboard') && !t.closest?.(IN_FRAME_TOOLS) && !doc.querySelector('[contenteditable="true"]')) {
388
+ doc.getSelection()?.removeAllRanges();
389
+ }
390
+ };
391
+ // フレームの外(キャンバスの何も無い所・別のページのフレーム)を押したら、紙面の文字の選択を消す。
392
+ // キャンバスの何も無い所の mousedown は既定の動きを止めるので、フォーカスが紙面に残り、編集も選択も残っていた。
393
+ // 右パネル・リボン・ポップアップはキャンバスの外にあるので対象にならない(文字の一部の書式が選択の残りを使う)
394
+ const hostDoc = doc.defaultView?.frameElement?.ownerDocument ?? null;
395
+ const onHostPointerDown = (e: Event) => {
396
+ const t = e.target as Element | null;
397
+ if (!t?.closest?.('[data-infinite-canvas]') || t.closest('[data-editor-frame]')) return;
398
+ if (hasTextSelection(doc)) clearTextSelection(doc);
399
+ };
400
+ // Esc でも消す。編集中の Esc は既存の処理が編集を終えて選択を消すので、ここでは編集に入っていない選択だけ
401
+ const onKeyDown = (e: KeyboardEvent) => {
402
+ if (e.key !== 'Escape' || e.isComposing || doc.querySelector('[contenteditable="true"]')) return;
403
+ const sel = doc.getSelection();
404
+ if (sel && sel.rangeCount > 0 && !sel.isCollapsed) sel.removeAllRanges();
405
+ };
406
+ // フォーカスがエディタの外側(キャンバスや本文)にあるときの Esc。入力欄やパネルにフォーカスがあるときの Esc はその欄のもの
407
+ const onHostKeyDown = (e: KeyboardEvent) => {
408
+ if (e.key !== 'Escape' || e.isComposing || !hostDoc) return;
409
+ const active = hostDoc.activeElement;
410
+ if (active && active !== hostDoc.body && !active.closest('[data-infinite-canvas]')) return;
411
+ if (hasTextSelection(doc)) clearTextSelection(doc);
412
+ };
413
+ doc.addEventListener('selectionchange', onSelectionChange);
414
+ doc.addEventListener('pointerdown', onPointerDown, true);
415
+ doc.addEventListener('keydown', onKeyDown, true);
416
+ hostDoc?.addEventListener('pointerdown', onHostPointerDown, true);
417
+ hostDoc?.addEventListener('keydown', onHostKeyDown, true);
418
+ return () => {
419
+ doc.removeEventListener('selectionchange', onSelectionChange);
420
+ doc.removeEventListener('pointerdown', onPointerDown, true);
421
+ doc.removeEventListener('keydown', onKeyDown, true);
422
+ hostDoc?.removeEventListener('pointerdown', onHostPointerDown, true);
423
+ hostDoc?.removeEventListener('keydown', onHostKeyDown, true);
424
+ if (remembered?.doc === doc) setRemembered(null);
425
+ };
426
+ }