@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.
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 +88 -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
@@ -0,0 +1,474 @@
1
+ /**
2
+ * ⌘ / Ctrl + クリックで、紙面の中のリンク(`<a href>`)の先へ移る。
3
+ *
4
+ * - 同じサイトのページ(contentList[].href と照らし合わせる) … そのページを編集中にする
5
+ * - 同じページのアンカー(`#id`) … その要素が見える位置へ紙面を移す
6
+ * - 別のオリジンの http(s) … 新しいタブ(noopener)
7
+ * - `mailto:` / `tel:` / `javascript:` など … 何もしない
8
+ * - ページ一覧に無い同じオリジンの URL … 短く知らせる
9
+ *
10
+ * [既存の ⌘ / Ctrl クリックとの両立]
11
+ * useElementSelection の mousedown では ⌘ / Ctrl は「階層を無視して最深要素を選ぶ」、
12
+ * ⌘ + Shift は「最深要素の追加・解除」、⌘ + ドラッグは自由配置(useDragResize)、
13
+ * ⌘ + ダブルクリックはテキスト編集。押した瞬間にはどれになるか決められないので、
14
+ * ここは mousedown で候補を覚えるだけにして選択には手を出さず、
15
+ * - 動かさずに離した(ドラッグではない)
16
+ * - Shift / Alt を押していない
17
+ * - 2 回目の mousedown(ダブルクリック)が LINK_NAV_DELAY_MS 以内に来なかった
18
+ * - 押した点がテキスト編集中の要素の中ではない
19
+ * ときだけ移る。リンクの中の深い要素を移らずに選びたいときは ⌘ + Alt + クリック。
20
+ *
21
+ * Mac は ⌘ だけ。Mac の Ctrl + クリックは右クリック扱い(contextmenu が出て click が来ない)で、
22
+ * エディタのコンテキストメニューが開く。Windows などは Ctrl(と Meta)。
23
+ */
24
+ import { MARQUEE_DRAG_THRESHOLD } from '../constants';
25
+
26
+ // ------------------------------------------------------------
27
+ // リンク先の解決(DOM に触らない)
28
+ // ------------------------------------------------------------
29
+
30
+ export interface LinkNavPage {
31
+ id: string;
32
+ title?: string;
33
+ href?: string;
34
+ }
35
+
36
+ export type LinkTarget =
37
+ | { kind: 'page'; id: string; title: string; hash: string | null }
38
+ | { kind: 'anchor'; hash: string }
39
+ | { kind: 'external'; url: string }
40
+ | { kind: 'ignore' }
41
+ | { kind: 'unresolved' };
42
+
43
+ const SCHEME_RE = /^([a-zA-Z][a-zA-Z0-9+.-]*):/;
44
+
45
+ /** `/company/`・`/company/index.html`・`/company` を同じ形にそろえる */
46
+ function normalizePath(pathname: string): string {
47
+ let p = pathname;
48
+ try {
49
+ p = decodeURI(p);
50
+ } catch {
51
+ /* 壊れた % はそのまま比べる */
52
+ }
53
+ p = p.replace(/\/{2,}/g, '/').replace(/\/index\.html?$/i, '/');
54
+ if (p.length > 1) p = p.replace(/\/+$/, '');
55
+ return p || '/';
56
+ }
57
+
58
+ /**
59
+ * ページの中の相対リンクの基準。`/company` の中身は `company/index.html` なので、
60
+ * `./access` は `/company/access`、`../` は `/` を指す(末尾に / を補う)。
61
+ * `/about.html` のように拡張子のある URL はそのまま基準にする
62
+ */
63
+ function baseOf(pageHref: string | undefined, origin: string): string {
64
+ const root = `${origin}/`;
65
+ if (!pageHref) return root;
66
+ try {
67
+ const u = new URL(pageHref, root);
68
+ const last = u.pathname.split('/').pop() ?? '';
69
+ if (!u.pathname.endsWith('/') && !last.includes('.')) u.pathname += '/';
70
+ u.search = '';
71
+ u.hash = '';
72
+ return u.href;
73
+ } catch {
74
+ return root;
75
+ }
76
+ }
77
+
78
+ export function resolveLinkTarget(
79
+ rawHref: string,
80
+ options: { pages: readonly LinkNavPage[]; currentId: string | null | undefined; origin: string },
81
+ ): LinkTarget {
82
+ const href = rawHref.trim();
83
+ if (href === '') return { kind: 'anchor', hash: '' };
84
+ if (href.startsWith('#')) return { kind: 'anchor', hash: href.slice(1) };
85
+ const scheme = SCHEME_RE.exec(href)?.[1]?.toLowerCase();
86
+ if (scheme && scheme !== 'http' && scheme !== 'https') return { kind: 'ignore' };
87
+
88
+ const { pages, currentId, origin } = options;
89
+ const current = pages.find((p) => p.id === currentId);
90
+ let url: URL;
91
+ try {
92
+ url = new URL(href, baseOf(current?.href, origin));
93
+ } catch {
94
+ return { kind: 'unresolved' };
95
+ }
96
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return { kind: 'ignore' };
97
+
98
+ const hash = url.hash ? url.hash.slice(1) : null;
99
+ const key = url.origin + normalizePath(url.pathname);
100
+ const stripExt = (k: string) => k.replace(/\.html?$/i, '');
101
+ // `/company.html` と `/company` は、完全に一致するページが無いときだけ同じと見なす
102
+ let loose: LinkNavPage | undefined;
103
+ for (const page of pages) {
104
+ if (!page.href) continue;
105
+ let pageUrl: URL;
106
+ try {
107
+ pageUrl = new URL(page.href, `${origin}/`);
108
+ } catch {
109
+ continue;
110
+ }
111
+ const pageKey = pageUrl.origin + normalizePath(pageUrl.pathname);
112
+ if (pageKey === key) return { kind: 'page', id: page.id, title: page.title || page.id, hash };
113
+ if (!loose && stripExt(pageKey) === stripExt(key)) loose = page;
114
+ }
115
+ if (loose) return { kind: 'page', id: loose.id, title: loose.title || loose.id, hash };
116
+ if (url.origin !== origin) return { kind: 'external', url: url.href };
117
+ return { kind: 'unresolved' };
118
+ }
119
+
120
+ /**
121
+ * アンカーの行き先。`#` だけ・`#top`(同じ id が無いとき)は紙面の先頭('top')。
122
+ * 紙面(#artboard)の外にある要素(エディタの部品)は拾わない
123
+ */
124
+ export function findAnchorTarget(doc: Document, hash: string): HTMLElement | 'top' | null {
125
+ const artboard = doc.getElementById('artboard');
126
+ if (!artboard) return null;
127
+ let decoded = hash;
128
+ try {
129
+ decoded = decodeURIComponent(hash);
130
+ } catch {
131
+ /* そのまま */
132
+ }
133
+ for (const name of new Set([hash, decoded])) {
134
+ if (!name) continue;
135
+ const byId = doc.getElementById(name);
136
+ if (byId && artboard.contains(byId)) return byId;
137
+ const byName = Array.from(doc.getElementsByName(name)).find(
138
+ (el) => el.tagName === 'A' && artboard.contains(el),
139
+ );
140
+ if (byName) return byName as HTMLElement;
141
+ }
142
+ if (!decoded || decoded.toLowerCase() === 'top') return 'top';
143
+ return null;
144
+ }
145
+
146
+ // ------------------------------------------------------------
147
+ // ページを移ったあとで開くアンカー(`/company#access`)
148
+ // ------------------------------------------------------------
149
+ // 1 ページ表示ではページを移るとエディタが作り直されるので、フックの ref ではなく
150
+ // モジュールに持つ。移り先のエディタが紙面を読み込んだら使う
151
+
152
+ const PENDING_ANCHOR_TTL_MS = 8000;
153
+ let pendingAnchor: { id: string; hash: string; until: number } | null = null;
154
+
155
+ export function setPendingAnchor(id: string, hash: string | null): void {
156
+ pendingAnchor = hash ? { id, hash, until: Date.now() + PENDING_ANCHOR_TTL_MS } : null;
157
+ }
158
+
159
+ export function peekPendingAnchor(id: string | null | undefined): string | null {
160
+ if (!pendingAnchor) return null;
161
+ if (Date.now() > pendingAnchor.until) {
162
+ pendingAnchor = null;
163
+ return null;
164
+ }
165
+ return pendingAnchor.id === id ? pendingAnchor.hash : null;
166
+ }
167
+
168
+ export function clearPendingAnchor(id?: string): void {
169
+ if (!id || pendingAnchor?.id === id) pendingAnchor = null;
170
+ }
171
+
172
+ // ------------------------------------------------------------
173
+ // 紙面のイベント
174
+ // ------------------------------------------------------------
175
+
176
+ /**
177
+ * 1 回目のクリックから移るまでの待ち。この間に 2 回目の mousedown が来たら
178
+ * ダブルクリック(⌘ + ダブルクリック = テキスト編集)として移動を取り消す
179
+ */
180
+ export const LINK_NAV_DELAY_MS = 350;
181
+
182
+ /** ⌘ / Ctrl を押してリンクの上にいる間、紙面の <html> に付ける(カーソルを指にする) */
183
+ export const LINK_NAV_ATTR = 'data-gg-link-nav';
184
+
185
+ const OVERLAY_SELECTOR =
186
+ '.selection-box,.marquee-selection-box,[data-editor-overlay],#gg-smart-guides,#gg-measure-layer';
187
+ /** 描画・テキスト・コメント・手のひら・トリミングの間は、クリックに別の意味がある */
188
+ const BLOCKING_BODY_CLASSES = ['draw-mode', 'text-mode', 'comment-mode', 'move-mode', 'pan-mode', 'panning', 'gg-cropping'];
189
+
190
+ const isMac = () => {
191
+ if (typeof navigator === 'undefined') return false;
192
+ const nav = navigator as Navigator & { userAgentData?: { platform?: string } };
193
+ return /mac|iphone|ipad/i.test(nav.userAgentData?.platform || nav.platform || nav.userAgent || '');
194
+ };
195
+
196
+ function describe(target: LinkTarget, mod: string): string | null {
197
+ switch (target.kind) {
198
+ case 'page':
199
+ return `${mod}クリックで移動: ${target.title}`;
200
+ case 'anchor':
201
+ return `${mod}クリックで移動: このページの${target.hash ? ` #${target.hash}` : '先頭'}`;
202
+ case 'external': {
203
+ let host = target.url;
204
+ try {
205
+ host = new URL(target.url).host;
206
+ } catch {
207
+ /* URL のまま */
208
+ }
209
+ return `${mod}クリックで新しいタブ: ${host}`;
210
+ }
211
+ case 'unresolved':
212
+ return 'このリンク先はページ一覧にありません';
213
+ default:
214
+ return null;
215
+ }
216
+ }
217
+
218
+ export interface LinkNavigationOptions {
219
+ /** href(属性の値そのまま)をリンク先に解く。呼ぶたびに最新のページ一覧で解く */
220
+ resolve: (href: string) => LinkTarget;
221
+ /**
222
+ * 移る。知らせたいこと(移れなかった理由など)があれば文字列を返す。
223
+ * iframeDoc はクリックされた紙面
224
+ */
225
+ navigate: (target: LinkTarget, iframeDoc: Document) => string | void | Promise<string | void>;
226
+ }
227
+
228
+ /**
229
+ * 紙面(iframe の文書)に ⌘ / Ctrl + クリックの移動を付ける。戻り値は後始末
230
+ */
231
+ export function attachLinkNavigation(iframeDoc: Document, options: LinkNavigationOptions): () => void {
232
+ const win = iframeDoc.defaultView;
233
+ const frameEl = (win?.frameElement as HTMLElement | null) ?? null;
234
+ const hostDoc = frameEl?.ownerDocument ?? null;
235
+ const hostWin = hostDoc?.defaultView ?? null;
236
+ const mac = isMac();
237
+ const mod = mac ? '⌘ ' : 'Ctrl + ';
238
+ const navKey = (e: MouseEvent | KeyboardEvent) => (mac ? e.metaKey : e.ctrlKey || e.metaKey);
239
+ const isNavKeyName = (key: string) => key === 'Meta' || (!mac && key === 'Control');
240
+
241
+ let pointer: { x: number; y: number } | null = null;
242
+ let modifier = false;
243
+ let pending: { href: string; link: HTMLAnchorElement; x: number; y: number } | null = null;
244
+ let timer: ReturnType<typeof setTimeout> | null = null;
245
+ let hint: HTMLDivElement | null = null;
246
+ let notice: HTMLDivElement | null = null;
247
+ let noticeTimer: ReturnType<typeof setTimeout> | null = null;
248
+ /** 後始末の後に(非同期の navigate の結果で)吹き出しを作り直さない */
249
+ let disposed = false;
250
+
251
+ // ---- 案内の吹き出し(親の文書に置く: キャンバスの外側の倍率で文字が縮まないように)
252
+ const bubble = (role: 'hint' | 'notice'): HTMLDivElement | null => {
253
+ if (disposed || !hostDoc?.body) return null;
254
+ const el = hostDoc.createElement('div');
255
+ el.setAttribute('data-link-nav-' + role, '');
256
+ if (role === 'notice') {
257
+ el.setAttribute('role', 'status');
258
+ el.setAttribute('aria-live', 'polite');
259
+ }
260
+ Object.assign(el.style, {
261
+ position: 'fixed',
262
+ left: '0px',
263
+ top: '0px',
264
+ zIndex: '2147483000',
265
+ pointerEvents: 'none',
266
+ maxWidth: '360px',
267
+ padding: '4px 8px',
268
+ borderRadius: '6px',
269
+ background: role === 'notice' ? 'rgba(30, 30, 30, 0.95)' : 'rgba(0, 0, 0, 0.85)',
270
+ color: '#fff',
271
+ font: '500 12px/1.5 system-ui, -apple-system, "Hiragino Sans", "Segoe UI", sans-serif',
272
+ whiteSpace: 'nowrap',
273
+ overflow: 'hidden',
274
+ textOverflow: 'ellipsis',
275
+ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)',
276
+ } satisfies Partial<CSSStyleDeclaration>);
277
+ el.hidden = true;
278
+ hostDoc.body.appendChild(el);
279
+ return el;
280
+ };
281
+ /** 紙面の座標(iframe のビューポート) → 親の画面の座標。キャンバスでは iframe ごと縮んでいる */
282
+ const toHost = (x: number, y: number) => {
283
+ if (!frameEl) return { x, y };
284
+ const r = frameEl.getBoundingClientRect();
285
+ const s = r.width / (frameEl.clientWidth || r.width) || 1;
286
+ return { x: r.left + x * s, y: r.top + y * s };
287
+ };
288
+ const place = (el: HTMLElement, x: number, y: number) => {
289
+ const p = toHost(x, y);
290
+ const vw = hostWin?.innerWidth ?? 0;
291
+ const vh = hostWin?.innerHeight ?? 0;
292
+ const w = el.offsetWidth;
293
+ const h = el.offsetHeight;
294
+ const left = Math.max(4, Math.min(p.x + 14, vw - w - 4));
295
+ const top = p.y + 20 + h > vh - 4 ? p.y - h - 8 : p.y + 20;
296
+ el.style.left = `${left}px`;
297
+ el.style.top = `${Math.max(4, top)}px`;
298
+ };
299
+ const showNotice = (text: string, x: number, y: number) => {
300
+ notice ??= bubble('notice');
301
+ if (!notice) return;
302
+ notice.textContent = text;
303
+ notice.hidden = false;
304
+ place(notice, x, y);
305
+ if (noticeTimer) clearTimeout(noticeTimer);
306
+ noticeTimer = setTimeout(() => {
307
+ noticeTimer = null;
308
+ if (notice) notice.hidden = true;
309
+ }, 2400);
310
+ };
311
+
312
+ const blocked = () => BLOCKING_BODY_CLASSES.some((c) => iframeDoc.body?.classList.contains(c));
313
+
314
+ /** 点の下のリンク。選択枠の上から押しても、その下の紙面のリンクを拾う */
315
+ const linkAt = (x: number, y: number) => {
316
+ const stack =
317
+ typeof iframeDoc.elementsFromPoint === 'function'
318
+ ? iframeDoc.elementsFromPoint(x, y)
319
+ : [iframeDoc.elementFromPoint(x, y)].filter((e): e is Element => !!e);
320
+ const under = stack.find((el) => !el.closest(OVERLAY_SELECTOR));
321
+ const link = under?.closest<HTMLAnchorElement>('a[href]');
322
+ if (!under || !link || !link.closest('#artboard')) return null;
323
+ // テキスト編集中の要素の中では移らない(文字を直している途中にページが変わらないように)
324
+ if (under.closest('[contenteditable="true"]')) return null;
325
+ return link;
326
+ };
327
+
328
+ const disarm = () => {
329
+ iframeDoc.documentElement?.removeAttribute(LINK_NAV_ATTR);
330
+ if (hint) hint.hidden = true;
331
+ };
332
+
333
+ /** ⌘ / Ctrl を押してリンクの上にいれば、指のカーソルと行き先の案内を出す */
334
+ const update = (buttons = 0) => {
335
+ if (!modifier || !pointer || buttons !== 0 || blocked()) {
336
+ disarm();
337
+ return;
338
+ }
339
+ const link = linkAt(pointer.x, pointer.y);
340
+ const target = link ? options.resolve(link.getAttribute('href') ?? '') : null;
341
+ const label = target ? describe(target, mod) : null;
342
+ if (!target || !label) {
343
+ disarm();
344
+ return;
345
+ }
346
+ if (target.kind === 'unresolved') iframeDoc.documentElement?.removeAttribute(LINK_NAV_ATTR);
347
+ else iframeDoc.documentElement?.setAttribute(LINK_NAV_ATTR, '');
348
+ hint ??= bubble('hint');
349
+ if (!hint) return;
350
+ if (hint.textContent !== label) hint.textContent = label;
351
+ hint.hidden = false;
352
+ place(hint, pointer.x, pointer.y);
353
+ };
354
+
355
+ const cancel = () => {
356
+ pending = null;
357
+ if (timer) {
358
+ clearTimeout(timer);
359
+ timer = null;
360
+ }
361
+ };
362
+
363
+ const onMouseMove = (e: MouseEvent) => {
364
+ pointer = { x: e.clientX, y: e.clientY };
365
+ const next = navKey(e);
366
+ if (next || modifier) {
367
+ modifier = next;
368
+ update(e.buttons);
369
+ }
370
+ };
371
+ const onMouseLeave = () => {
372
+ pointer = null;
373
+ disarm();
374
+ };
375
+ // mouseleave は文書に届かないことがあるので、文書の外へ出た mouseout(relatedTarget が無い)でも消す
376
+ const onMouseOut = (e: MouseEvent) => {
377
+ if (!e.relatedTarget) onMouseLeave();
378
+ };
379
+ const onKey = (e: KeyboardEvent) => {
380
+ if (e.key === 'Escape') cancel();
381
+ if (!isNavKeyName(e.key)) {
382
+ // ⌘ を離したことに気づけなかった(別のウィンドウで離した等)ときの戻し
383
+ if (modifier && !navKey(e)) {
384
+ modifier = false;
385
+ disarm();
386
+ }
387
+ return;
388
+ }
389
+ modifier = e.type === 'keydown' ? true : navKey(e);
390
+ update();
391
+ };
392
+ const onBlur = () => {
393
+ modifier = false;
394
+ disarm();
395
+ };
396
+
397
+ const onMouseDown = (e: MouseEvent) => {
398
+ // 2 回目の mousedown(ダブルクリック)・別のクリックが来たら、前の移動は取り消す
399
+ cancel();
400
+ if (e.button !== 0 || !navKey(e) || e.shiftKey || e.altKey) return;
401
+ if (e.detail > 1 || blocked()) return;
402
+ // 選択枠のハンドル(リサイズ・回転)を押したのはハンドルの操作。下にリンクがあっても移らない
403
+ if ((e.target as Element | null)?.closest?.('[data-handle]')) return;
404
+ const link = linkAt(e.clientX, e.clientY);
405
+ if (!link) return;
406
+ pending = { href: link.getAttribute('href') ?? '', link, x: e.clientX, y: e.clientY };
407
+ };
408
+ const moved = (e: MouseEvent, p: { x: number; y: number }) =>
409
+ Math.abs(e.clientX - p.x) > MARQUEE_DRAG_THRESHOLD || Math.abs(e.clientY - p.y) > MARQUEE_DRAG_THRESHOLD;
410
+ const onMouseUp = (e: MouseEvent) => {
411
+ // ⌘ + ドラッグ(自由配置)だった
412
+ if (pending && moved(e, pending)) pending = null;
413
+ };
414
+ const onClick = (e: MouseEvent) => {
415
+ const p = pending;
416
+ pending = null;
417
+ if (!p || e.button !== 0 || e.detail > 1) return;
418
+ if (!navKey(e) || e.shiftKey || e.altKey || moved(e, p)) return;
419
+ timer = setTimeout(() => {
420
+ timer = null;
421
+ // 待っている間にテキスト編集に入った・紙面が差し替わった・モードが変わった
422
+ if (!p.link.isConnected || p.link.closest('[contenteditable="true"]') || blocked()) return;
423
+ if (linkAt(p.x, p.y) !== p.link) return;
424
+ disarm();
425
+ const target = options.resolve(p.href);
426
+ void Promise.resolve(options.navigate(target, iframeDoc)).then((message) => {
427
+ if (message) showNotice(message, p.x, p.y);
428
+ });
429
+ }, LINK_NAV_DELAY_MS);
430
+ };
431
+
432
+ const capture = { capture: true } as const;
433
+ iframeDoc.addEventListener('mousemove', onMouseMove, { capture: true, passive: true });
434
+ iframeDoc.addEventListener('mouseleave', onMouseLeave);
435
+ iframeDoc.addEventListener('mouseout', onMouseOut);
436
+ iframeDoc.addEventListener('keydown', onKey, capture);
437
+ iframeDoc.addEventListener('keyup', onKey, capture);
438
+ iframeDoc.addEventListener('mousedown', onMouseDown, capture);
439
+ iframeDoc.addEventListener('mouseup', onMouseUp, capture);
440
+ iframeDoc.addEventListener('click', onClick, capture);
441
+ iframeDoc.addEventListener('dblclick', cancel, capture);
442
+ iframeDoc.addEventListener('contextmenu', cancel, capture);
443
+ win?.addEventListener('blur', onBlur);
444
+ hostWin?.addEventListener('keydown', onKey, capture);
445
+ hostWin?.addEventListener('keyup', onKey, capture);
446
+ hostWin?.addEventListener('blur', onBlur);
447
+
448
+ return () => {
449
+ disposed = true;
450
+ cancel();
451
+ iframeDoc.removeEventListener('mousemove', onMouseMove, capture);
452
+ iframeDoc.removeEventListener('mouseleave', onMouseLeave);
453
+ iframeDoc.removeEventListener('mouseout', onMouseOut);
454
+ iframeDoc.removeEventListener('keydown', onKey, capture);
455
+ iframeDoc.removeEventListener('keyup', onKey, capture);
456
+ iframeDoc.removeEventListener('mousedown', onMouseDown, capture);
457
+ iframeDoc.removeEventListener('mouseup', onMouseUp, capture);
458
+ iframeDoc.removeEventListener('click', onClick, capture);
459
+ iframeDoc.removeEventListener('dblclick', cancel, capture);
460
+ iframeDoc.removeEventListener('contextmenu', cancel, capture);
461
+ win?.removeEventListener('blur', onBlur);
462
+ hostWin?.removeEventListener('keydown', onKey, capture);
463
+ hostWin?.removeEventListener('keyup', onKey, capture);
464
+ hostWin?.removeEventListener('blur', onBlur);
465
+ if (noticeTimer) clearTimeout(noticeTimer);
466
+ try {
467
+ iframeDoc.documentElement?.removeAttribute(LINK_NAV_ATTR);
468
+ } catch {
469
+ /* 文書が既に捨てられている */
470
+ }
471
+ hint?.remove();
472
+ notice?.remove();
473
+ };
474
+ }
@@ -459,6 +459,26 @@ export function extractElementInfo(
459
459
 
460
460
  const rawWidth = extractRawStyleValue(element.style.width, 'width');
461
461
  const rawHeight = extractRawStyleValue(element.style.height, 'height');
462
+ // 最小・最大の幅と高さ(サイズ欄)。インラインの指定 → Tailwind の任意値・特殊値のクラス →
463
+ // 同じ系統の名前付きクラス(max-w-6xl など。値をクラス名から戻せない)なら計算値。
464
+ // none / auto は「指定なし」と同じ扱い
465
+ const readSizeLimit = (
466
+ property: 'minWidth' | 'maxWidth' | 'minHeight' | 'maxHeight',
467
+ classPrefix: string,
468
+ ): string | undefined => {
469
+ const usable = (v: string | null | undefined) => (v && v !== 'none' && v !== 'auto' ? v : undefined);
470
+ const inline = usable(extractRawStyleValue(element.style[property], property));
471
+ if (inline) return inline;
472
+ const fromClass = usable(extractValueFromTailwindClass(element.getAttribute('class') ?? '', property));
473
+ if (fromClass) return fromClass;
474
+ const classes = (element.getAttribute('class') ?? '').split(/\s+/);
475
+ if (classes.some((c) => c.startsWith(classPrefix))) return usable(style[property]);
476
+ return undefined;
477
+ };
478
+ const rawMinWidth = readSizeLimit('minWidth', 'min-w-');
479
+ const rawMaxWidth = readSizeLimit('maxWidth', 'max-w-');
480
+ const rawMinHeight = readSizeLimit('minHeight', 'min-h-');
481
+ const rawMaxHeight = readSizeLimit('maxHeight', 'max-h-');
462
482
  const rawPaddingTop = extractRawStyleValue(element.style.paddingTop, 'paddingTop');
463
483
  const rawPaddingRight = extractRawStyleValue(element.style.paddingRight, 'paddingRight');
464
484
  const rawPaddingBottom = extractRawStyleValue(element.style.paddingBottom, 'paddingBottom');
@@ -623,6 +643,10 @@ export function extractElementInfo(
623
643
  // CSS Variable references (raw inline style values if they contain var())
624
644
  rawWidth,
625
645
  rawHeight,
646
+ rawMinWidth,
647
+ rawMaxWidth,
648
+ rawMinHeight,
649
+ rawMaxHeight,
626
650
  rawPaddingTop,
627
651
  rawPaddingRight,
628
652
  rawPaddingBottom,
@@ -373,6 +373,12 @@ function isArbitraryValueClass(className: string): boolean {
373
373
  * 既存CSSルールとの競合を避けるため、インラインスタイルも併用する
374
374
  */
375
375
  const LAYOUT_CRITICAL_PROPERTIES = new Set([
376
+ // 最小・最大の幅と高さ(サイズ欄)は、特殊値(100% → min-w-full など)でもインラインの指定を残す。
377
+ // ページの Tailwind がそのクラスを持っていなくても紙面に効き、欄にも読み戻せるように
378
+ 'minWidth',
379
+ 'maxWidth',
380
+ 'minHeight',
381
+ 'maxHeight',
376
382
  'display',
377
383
  'flexDirection',
378
384
  'flexWrap',
@@ -440,6 +446,12 @@ export function applyTailwindStyles(
440
446
  // 競合クラスを削除
441
447
  removeConflictingClasses(element, property);
442
448
 
449
+ // 空の値は「指定を消す」。競合クラス(上)に加えて、インラインの指定も外す
450
+ if (value === '') {
451
+ clearInlineStyle(element, property);
452
+ return;
453
+ }
454
+
443
455
  // 変換
444
456
  const { className, styleValue } = cssToTailwindClass(property, value);
445
457
 
@@ -0,0 +1,121 @@
1
+ /**
2
+ * テキスト編集中の Enter を「同じ要素の中の改行(<br>)」にする。
3
+ *
4
+ * [なぜ要るか]
5
+ * contenteditable の既定の Enter は段落を分ける。Chrome は <p> の中に <div> を差し込み
6
+ * (`<p>前半<div>後半</div></p>`)、HTML では p の中に div を置けないので、保存して読み直すと
7
+ * 段落が閉じ、後半が段落のクラス(文字の大きさ・行間)を持たない div になっていた。
8
+ * Shift+Enter は同じ要素の中に <br> が入るだけで正しいので、Enter もそれにそろえる。
9
+ * p / 見出し / li / td / ボタン / span など、テキスト編集に入るすべての要素で同じ。
10
+ *
11
+ * [なぜ execCommand('insertLineBreak') か]
12
+ * Shift+Enter と同じブラウザの処理なので、範囲選択の削除・行末での改行(次の行を作る 2 つ目の <br>)・
13
+ * 文字単位の取り消し(編集中の ⌘Z 1 回)・input イベント(共同編集の送信)がそのまま付いてくる。
14
+ * 自前で Range に <br> を差し込むと、ブラウザの取り消しの履歴に載らず ⌘Z で戻らない。
15
+ * 使えないブラウザ(insertLineBreak を持たない)でだけ自前で差し込む。
16
+ *
17
+ * [日本語入力]
18
+ * 変換を確定する Enter は改行ではない。ここを誤ると確定するたびに改行が入る。
19
+ * Chrome は確定の keydown に isComposing=true / keyCode 229 を付け、Safari は compositionend の
20
+ * 直後に keyCode 229 の keydown を送る。確定の直後(keyup まで)の Enter も見送る。
21
+ * 見送った Enter がそれでも段落を分けようとしたとき(beforeinput の insertParagraph)は、そこで改行に直す。
22
+ */
23
+
24
+ const EDITING_HOST = '[contenteditable="true"]';
25
+
26
+ function editingHostOf(target: EventTarget | null): HTMLElement | null {
27
+ const el = target as Element | null;
28
+ if (!el || typeof el.closest !== 'function') return null;
29
+ return el.closest<HTMLElement>(EDITING_HOST);
30
+ }
31
+
32
+ /** 改行の後ろに、次の行を作る中身(文字・画像・改行)があるか */
33
+ function hasContentAfter(doc: Document, node: Node, host: HTMLElement): boolean {
34
+ const range = doc.createRange();
35
+ range.setStartAfter(node);
36
+ range.setEnd(host, host.childNodes.length);
37
+ const rest = range.cloneContents();
38
+ if ((rest.textContent ?? '').length > 0) return true;
39
+ return !!rest.querySelector('br, img, svg, video, iframe, input, canvas');
40
+ }
41
+
42
+ /** insertLineBreak を持たないブラウザ向け。選択を消して <br> を入れ、キャレットを次の行に置く */
43
+ function insertLineBreakManually(doc: Document, host: HTMLElement): void {
44
+ const selection = doc.getSelection();
45
+ if (!selection || selection.rangeCount === 0) return;
46
+ const range = selection.getRangeAt(0);
47
+ if (!host.contains(range.commonAncestorContainer)) return;
48
+ range.deleteContents();
49
+ const br = doc.createElement('br');
50
+ range.insertNode(br);
51
+ // 行末の <br> 1 つだけでは次の行ができない(キャレットが前の行に残る)ので、もう 1 つ置く
52
+ if (!hasContentAfter(doc, br, host)) br.after(doc.createElement('br'));
53
+ const caret = doc.createRange();
54
+ caret.setStartAfter(br);
55
+ caret.collapse(true);
56
+ selection.removeAllRanges();
57
+ selection.addRange(caret);
58
+ const InputEventCtor = doc.defaultView?.InputEvent ?? InputEvent;
59
+ host.dispatchEvent(new InputEventCtor('input', { bubbles: true, inputType: 'insertLineBreak' }));
60
+ }
61
+
62
+ function insertLineBreak(doc: Document, host: HTMLElement): void {
63
+ let done = false;
64
+ try {
65
+ done = doc.execCommand('insertLineBreak');
66
+ } catch {
67
+ done = false;
68
+ }
69
+ if (!done) insertLineBreakManually(doc, host);
70
+ }
71
+
72
+ /**
73
+ * iframe の紙面に「Enter = 改行」を張る。編集に入る経路(ダブルクリック・テキストツール)によらず、
74
+ * 編集中の要素(contenteditable="true")の中で押された Enter だけを扱う。
75
+ * @returns 外す関数
76
+ */
77
+ export function setupLineBreakOnEnter(doc: Document): () => void {
78
+ // 変換の確定から keyup までの間(Safari は確定の後に Enter の keydown が来る)
79
+ let composingEnter = false;
80
+
81
+ const onCompositionEnd = () => {
82
+ composingEnter = true;
83
+ };
84
+ const onKeyUp = () => {
85
+ composingEnter = false;
86
+ };
87
+
88
+ const onKeyDown = (e: KeyboardEvent) => {
89
+ // 日本語入力の変換中・確定の Enter には触らない(最初に見る)
90
+ if (e.isComposing || e.keyCode === 229) return;
91
+ if (e.key !== 'Enter') return;
92
+ if (composingEnter) return;
93
+ // Shift+Enter はブラウザの既定がすでに改行。⌘ / Ctrl との組み合わせはショートカットに譲る
94
+ if (e.shiftKey || e.metaKey || e.ctrlKey) return;
95
+ if (e.defaultPrevented) return;
96
+ const host = editingHostOf(e.target);
97
+ if (!host) return;
98
+ e.preventDefault();
99
+ insertLineBreak(doc, host);
100
+ };
101
+
102
+ // 上で見送った Enter や、キーを経ない段落分け(Android の IME など)も改行にする
103
+ const onBeforeInput = (e: InputEvent) => {
104
+ if (e.inputType !== 'insertParagraph' || e.isComposing) return;
105
+ const host = editingHostOf(e.target);
106
+ if (!host) return;
107
+ e.preventDefault();
108
+ insertLineBreak(doc, host);
109
+ };
110
+
111
+ doc.addEventListener('compositionend', onCompositionEnd, true);
112
+ doc.addEventListener('keyup', onKeyUp, true);
113
+ doc.addEventListener('keydown', onKeyDown, true);
114
+ doc.addEventListener('beforeinput', onBeforeInput as EventListener, true);
115
+ return () => {
116
+ doc.removeEventListener('compositionend', onCompositionEnd, true);
117
+ doc.removeEventListener('keyup', onKeyUp, true);
118
+ doc.removeEventListener('keydown', onKeyDown, true);
119
+ doc.removeEventListener('beforeinput', onBeforeInput as EventListener, true);
120
+ };
121
+ }
package/src/version.ts CHANGED
@@ -6,4 +6,4 @@
6
6
  * 利用側はこの値と node_modules の package.json を見比べて、食い違っていれば
7
7
  * 「再起動してください」と出せる(構成ラフのテンプレートが行っている)
8
8
  */
9
- export const EDITOR_VERSION = '0.6.0';
9
+ export const EDITOR_VERSION = '0.7.1';