@growgroup/visual-editor 0.4.1 → 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 (64) hide show
  1. package/README.md +92 -0
  2. package/dist/editor.css +1 -1
  3. package/package.json +7 -2
  4. package/src/editor/FrontendVisualEditor.tsx +244 -17
  5. package/src/editor/collab/CollabPresence.tsx +172 -0
  6. package/src/editor/collab/binding.ts +269 -0
  7. package/src/editor/collab/color.ts +28 -0
  8. package/src/editor/collab/connection.ts +110 -0
  9. package/src/editor/collab/layer.ts +168 -0
  10. package/src/editor/collab/merge.ts +112 -0
  11. package/src/editor/collab/morph.ts +113 -0
  12. package/src/editor/collab/room.ts +82 -0
  13. package/src/editor/collab/runtime.ts +442 -0
  14. package/src/editor/collab/signals.ts +11 -0
  15. package/src/editor/collab/store.ts +192 -0
  16. package/src/editor/collab/useCollab.ts +266 -0
  17. package/src/editor/components/AiPromptPopover.tsx +3 -2
  18. package/src/editor/components/EditorCanvas.tsx +27 -0
  19. package/src/editor/components/EditorContextMenu.tsx +80 -0
  20. package/src/editor/components/EditorPropertyPanel.tsx +248 -80
  21. package/src/editor/components/EditorSidebar.tsx +2 -2
  22. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +41 -19
  23. package/src/editor/components/ppt/PptChrome.tsx +46 -4
  24. package/src/editor/components/ppt/PptComments.tsx +3 -8
  25. package/src/editor/components/property-panel/VariableAwareSizeInput.tsx +94 -50
  26. package/src/editor/components/property-panel/VariableAwareUnitInput.tsx +12 -6
  27. package/src/editor/components/shell/EditorTopBar.tsx +13 -1
  28. package/src/editor/components/shell/PagesPanel.tsx +3 -0
  29. package/src/editor/constants.ts +24 -0
  30. package/src/editor/contexts/EditorAppearanceContext.tsx +5 -5
  31. package/src/editor/contexts/EditorHistoryContext.tsx +2 -0
  32. package/src/editor/contexts/MultiPageCanvasContext.tsx +120 -15
  33. package/src/editor/hooks/index.ts +3 -0
  34. package/src/editor/hooks/useDragResize.ts +30 -5
  35. package/src/editor/hooks/useElementActions.ts +41 -9
  36. package/src/editor/hooks/useElementSelection.ts +4 -2
  37. package/src/editor/hooks/useGoogleFonts.ts +6 -8
  38. package/src/editor/hooks/useIframeSetup.ts +10 -1
  39. package/src/editor/hooks/useInlineTextSelection.ts +16 -0
  40. package/src/editor/hooks/useKeepArtboardInPlace.ts +87 -0
  41. package/src/editor/hooks/useLinkNavigation.ts +160 -0
  42. package/src/editor/hooks/useResizablePanel.ts +3 -2
  43. package/src/editor/parts.ts +2 -0
  44. package/src/editor/types.ts +4 -0
  45. package/src/editor/utils/debug.ts +2 -5
  46. package/src/editor/utils/dom-utils.ts +45 -0
  47. package/src/editor/utils/drop-target.ts +1 -1
  48. package/src/editor/utils/flex-reorder.ts +1 -1
  49. package/src/editor/utils/free-layout-tree.ts +562 -0
  50. package/src/editor/utils/free-layout.ts +794 -0
  51. package/src/editor/utils/geometry.ts +41 -3
  52. package/src/editor/utils/html-utils.ts +4 -1
  53. package/src/editor/utils/inline-text-style.ts +426 -0
  54. package/src/editor/utils/link-navigation.ts +474 -0
  55. package/src/editor/utils/restore-flow.ts +1 -1
  56. package/src/editor/utils/smart-guides.ts +1 -1
  57. package/src/editor/utils/storage.ts +47 -0
  58. package/src/editor/utils/style-utils.ts +24 -0
  59. package/src/editor/utils/tailwind-utils.ts +12 -0
  60. package/src/editor/utils/text-line-break.ts +121 -0
  61. package/src/index.ts +15 -0
  62. package/src/io.ts +29 -0
  63. package/src/styles/skin.css +17 -0
  64. 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
+ }
@@ -122,7 +122,7 @@ export function relaxContainerSize(container: HTMLElement): void {
122
122
  /** 並べ替え・整列の対象にしない、エディタが描いている飾り */
123
123
  const OVERLAY_SELECTOR =
124
124
  '.selection-box,.marquee-selection-box,.resize-handle,.rotation-handle,' +
125
- '.size-label,.element-breadcrumb,.gg-comment-layer,.gg-crop-ui,' +
125
+ '.size-label,.element-breadcrumb,.gg-comment-layer,.gg-collab-layer,.gg-crop-ui,' +
126
126
  '#gg-measure-layer,#gg-smart-guides,#gg-reorder-indicator';
127
127
 
128
128
  /** 実体のある直接の子だけを取り出す */
@@ -24,7 +24,7 @@ export const SNAP_THRESHOLD_SCREEN_PX = 4;
24
24
  /** ガイドの候補にしないオーバーレイ類(エディタが描いている飾り) */
25
25
  const OVERLAY_SELECTOR =
26
26
  '.selection-box,.marquee-selection-box,.resize-handle,.rotation-handle,' +
27
- '.size-label,.element-breadcrumb,.gg-comment-layer,.gg-crop-ui,' +
27
+ '.size-label,.element-breadcrumb,.gg-comment-layer,.gg-collab-layer,.gg-crop-ui,' +
28
28
  '#gg-measure-layer,#gg-smart-guides,[data-drag-ghost],[data-flex-drop-indicator]';
29
29
 
30
30
  /** 整列の候補座標。x/y ともに昇順ソート済み */
@@ -0,0 +1,47 @@
1
+ /**
2
+ * ブラウザの記憶(localStorage / sessionStorage)への安全な出入口。
3
+ *
4
+ * 【なぜ関数越しにするのか】
5
+ * 共有ドロップに置く静的なエディタは `Content-Security-Policy: sandbox`(allow-same-origin 無し)で
6
+ * 配られる。この文脈は **opaque origin** で、`localStorage` は読むだけで SecurityError を投げる
7
+ * (`typeof localStorage` でも投げる ── プロパティの getter が走るため)。
8
+ * 「サイトデータをブロック」設定や、srcdoc / sandbox の紙面の中でも同じことが起きる。
9
+ *
10
+ * 記憶できるかどうかで挙動を変えないこと。ここは全部「読めなければ既定値、書けなければ黙って続行」。
11
+ */
12
+
13
+ function storage(kind: 'local' | 'session'): Storage | null {
14
+ try {
15
+ const s = kind === 'local' ? window.localStorage : window.sessionStorage;
16
+ return s ?? null;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ /** 読む。使えない・入っていなければ null */
23
+ export function readStorage(key: string, kind: 'local' | 'session' = 'local'): string | null {
24
+ try {
25
+ return storage(kind)?.getItem(key) ?? null;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ /** 書く。使えない・容量が一杯なら黙って諦める(表示や編集は続ける) */
32
+ export function writeStorage(key: string, value: string, kind: 'local' | 'session' = 'local'): void {
33
+ try {
34
+ storage(kind)?.setItem(key, value);
35
+ } catch {
36
+ /* 記憶できなくても動作は継続 */
37
+ }
38
+ }
39
+
40
+ /** 消す */
41
+ export function removeStorage(key: string, kind: 'local' | 'session' = 'local'): void {
42
+ try {
43
+ storage(kind)?.removeItem(key);
44
+ } catch {
45
+ /* 消せなくても動作は継続 */
46
+ }
47
+ }
@@ -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