@growgroup/visual-editor 0.3.1 → 0.4.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 (60) hide show
  1. package/README.md +6 -1
  2. package/dist/editor.css +1 -1
  3. package/package.json +1 -1
  4. package/src/editor/EditorContext.tsx +3 -2
  5. package/src/editor/EditorToolbar.tsx +8 -2
  6. package/src/editor/FrontendVisualEditor.tsx +123 -44
  7. package/src/editor/components/EditorCanvas.tsx +73 -15
  8. package/src/editor/components/EditorFooter.tsx +2 -0
  9. package/src/editor/components/EditorHeader.tsx +3 -2
  10. package/src/editor/components/FigmaColorPicker.tsx +2 -1
  11. package/src/editor/components/JsEditorDialog.tsx +1 -0
  12. package/src/editor/components/PageSettingsDialog.tsx +1 -0
  13. package/src/editor/components/comments/CommentBoard.tsx +9 -6
  14. package/src/editor/components/multi-page/CanvasFrameDecor.tsx +172 -0
  15. package/src/editor/components/multi-page/MultiPageCanvasView.tsx +179 -77
  16. package/src/editor/components/multi-page/PageFramePreview.tsx +99 -21
  17. package/src/editor/components/multi-page/index.ts +1 -0
  18. package/src/editor/components/ppt/PptChrome.tsx +6 -2
  19. package/src/editor/components/ppt/PptComments.tsx +411 -71
  20. package/src/editor/constants.ts +16 -1
  21. package/src/editor/contexts/EditorArtboardContext.tsx +8 -0
  22. package/src/editor/contexts/EditorComponentsContext.tsx +31 -27
  23. package/src/editor/contexts/EditorUIStateContext.tsx +4 -3
  24. package/src/editor/contexts/EditorVariablesContext.tsx +5 -4
  25. package/src/editor/contexts/MultiPageCanvasContext.tsx +194 -26
  26. package/src/editor/hooks/useAiReplace.ts +4 -3
  27. package/src/editor/hooks/useBrowserZoomPrevention.ts +2 -1
  28. package/src/editor/hooks/useComponentEditMode.ts +27 -26
  29. package/src/editor/hooks/useEditorMessages.ts +3 -2
  30. package/src/editor/hooks/useElementActions.ts +27 -26
  31. package/src/editor/hooks/useElementSelection.ts +19 -16
  32. package/src/editor/hooks/useFocusManagement.ts +7 -6
  33. package/src/editor/hooks/useGoogleFonts.ts +3 -2
  34. package/src/editor/hooks/useIframeInitializer.ts +6 -3
  35. package/src/editor/hooks/useIframeSetup.ts +4 -3
  36. package/src/editor/hooks/useImageUpload.ts +4 -3
  37. package/src/editor/hooks/useMarqueeSelection.ts +2 -1
  38. package/src/editor/hooks/usePageSettingsManager.ts +24 -23
  39. package/src/editor/hooks/useRichPaste.ts +11 -10
  40. package/src/editor/parts.ts +7 -25
  41. package/src/editor/utils/component-renderer.ts +8 -7
  42. package/src/editor/utils/component-sync.ts +16 -15
  43. package/src/editor/utils/debug.ts +52 -0
  44. package/src/editor/utils/dom-utils.ts +43 -11
  45. package/src/editor/utils/drop-target.ts +135 -0
  46. package/src/editor/utils/eyedropper.ts +3 -1
  47. package/src/editor/utils/figma-export.ts +6 -5
  48. package/src/editor/utils/figma-kiwi-decoder.ts +28 -27
  49. package/src/editor/utils/figma-kiwi-encoder.ts +9 -8
  50. package/src/editor/utils/figma-paste.ts +57 -56
  51. package/src/editor/utils/flex-utils.ts +12 -10
  52. package/src/editor/utils/override-apply.ts +3 -2
  53. package/src/editor/utils/override-detection.ts +11 -10
  54. package/src/editor/utils/tailwind-utils.ts +4 -3
  55. package/src/io.ts +22 -1
  56. package/src/lib/firebase/css-variables.ts +4 -3
  57. package/src/lib/firebase/storage.ts +3 -2
  58. package/src/styles/skin.css +9 -4
  59. package/src/types/editor.ts +6 -2
  60. package/src/version.ts +1 -1
@@ -6,19 +6,22 @@
6
6
  * - スレッド形式(返信・解決/再開・削除)。deck.json のエントリに永続化
7
7
  * - 要素アンカー: 追加時に要素を選択していれば、その要素の刻印(data-gg-src / data-wf-src)に
8
8
  * 紐づき、キャンバス上にコメントバブル(マーカー)が出る。クリックでパネルの該当スレッドへ。
9
- * 投稿する前に「何に対するコメントか」を必ず出す(選択中の要素名 / ページ全体)
9
+ * 刻印の無い HTML(構成ラフの HTML 正本)では、紙面からの CSS パス(`css:…`)を刻印の代わりにする
10
+ * - 範囲アンカー: コメントツール(C)で紙面をドラッグすると、その矩形(紙面の px)に紐づく。
11
+ * クリックだけなら点。gg-manager のフィードバックシートの「指摘」と同じ考え方
12
+ * 投稿する前に「何に対するコメントか」を必ず出す(指定した範囲 / 選択中の要素名 / ページ全体)
10
13
  * - マーカーは iframe 内のオーバーレイ層(.gg-comment-layer)に描く。
11
14
  * 保存時に丸ごと剥がされるため、上書きHTML・原本TSXには一切混入しない
12
15
  * - 名前は localStorage に記憶(ローカルツールなのでアカウントは要求しない)
13
16
  */
14
17
 
15
18
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
16
- import { Check, CornerUpLeft, Loader2, MapPin, RotateCcw, Send, MessageSquare, Sparkles, Trash2, X } from 'lucide-react';
19
+ import { Check, CornerUpLeft, Crosshair, Loader2, MapPin, RotateCcw, Send, MessageSquare, Sparkles, Trash2, X } from 'lucide-react';
17
20
  import { useEditorContext } from '../../EditorContext';
18
21
  import { useCanvasViewStateOptional } from '../../contexts/MultiPageCanvasContext';
19
22
  import { useDeck, applyDeck } from '../../../components/viewer/useDeck';
20
23
  import { commentAction, type SlideComment } from '../../../lib/deck';
21
- import { can, io, type EditorDeck } from '../../../io';
24
+ import { can, io, type CommentRect, type EditorDeck } from '../../../io';
22
25
  import { useResizablePanel } from '../../hooks/useResizablePanel';
23
26
  import { ConfirmDialog } from '../shell/ConfirmDialog';
24
27
  import { PPT_PALETTES, type PptTheme } from './PptChrome';
@@ -37,7 +40,11 @@ const AUTHOR_KEY = 'gg-editor:comment-author';
37
40
  */
38
41
  const ANCHOR_ATTRS = ['data-gg-src', 'data-wf-src'] as const;
39
42
 
40
- /** 要素からアンカー値を作る。刻印が無ければ null(=ページ全体へのコメント) */
43
+ /**
44
+ * 要素からアンカー値を作る。
45
+ * 刻印があればそれ、無ければ紙面(`#artboard`)からの CSS パス(`css:#artboard>main>section:nth-of-type(2)>h2`)。
46
+ * どちらも作れなければ null(=ページ全体へのコメント)
47
+ */
41
48
  export function anchorValueOf(root: HTMLElement, el: HTMLElement): string | null {
42
49
  for (const attr of ANCHOR_ATTRS) {
43
50
  const raw = el.getAttribute(attr);
@@ -47,6 +54,34 @@ export function anchorValueOf(root: HTMLElement, el: HTMLElement): string | null
47
54
  const index = same.indexOf(el);
48
55
  return index <= 0 ? raw : `${raw}#${index}`;
49
56
  }
57
+ return cssPathOf(el);
58
+ }
59
+
60
+ const CSS_ANCHOR = 'css:';
61
+
62
+ /**
63
+ * 刻印の無い HTML 向けの要素アンカー。紙面(`#artboard`)から要素までを
64
+ * `tag:nth-of-type(n)` で辿る短いパス。途中に一意な id があればそこから始める。
65
+ * エディタが付ける data-element-id は読み込みのたびに変わるので使えない。
66
+ */
67
+ function cssPathOf(el: HTMLElement): string | null {
68
+ const doc = el.ownerDocument;
69
+ const parts: string[] = [];
70
+ let cur: HTMLElement | null = el;
71
+ while (cur) {
72
+ const tag = cur.tagName.toLowerCase();
73
+ if (tag === 'body' || tag === 'html') return null;
74
+ const id = cur.id;
75
+ if (id && /^[A-Za-z][\w-]*$/.test(id) && doc.querySelectorAll(`#${CSS.escape(id)}`).length === 1) {
76
+ parts.unshift(`#${id}`);
77
+ return CSS_ANCHOR + parts.join('>');
78
+ }
79
+ const parent: HTMLElement | null = cur.parentElement;
80
+ if (!parent) return null;
81
+ const same = Array.from(parent.children).filter((sibling) => sibling.tagName === cur!.tagName);
82
+ parts.unshift(same.length > 1 ? `${tag}:nth-of-type(${same.indexOf(cur) + 1})` : tag);
83
+ cur = parent;
84
+ }
50
85
  return null;
51
86
  }
52
87
 
@@ -85,8 +120,29 @@ export function describeElement(el: HTMLElement): { label: string; kind: 'text'
85
120
  return { label: `<${el.tagName.toLowerCase()}>`, kind: 'tag' };
86
121
  }
87
122
 
88
- /** アンカー値から要素を引く。`#N` が付いていれば N 番目 */
89
- function findAnchored(root: HTMLElement, value: string): HTMLElement | null {
123
+ /**
124
+ * アンカー値から要素を引く。刻印なら `#N` が N 番目。
125
+ * `css:` パスは見つからなければ、辿れる所まで戻ってその下から同じタグ・同じラベルの要素を
126
+ * 1 つだけ探す(構造が少し変わっても付いてくる。ページ全体を文字だけで探すことはしない)
127
+ */
128
+ export function findAnchored(root: HTMLElement, value: string, label?: string): HTMLElement | null {
129
+ if (value.startsWith(CSS_ANCHOR)) {
130
+ const path = value.slice(CSS_ANCHOR.length);
131
+ const doc = root.ownerDocument;
132
+ const q = (sel: string) => { try { return doc.querySelector<HTMLElement>(sel); } catch { return null; } };
133
+ const exact = q(path);
134
+ if (exact) return exact;
135
+ const segments = path.split('>');
136
+ const tag = (segments[segments.length - 1] ?? '').replace(/:nth-of-type\(\d+\)$/, '');
137
+ if (!label || !tag || tag.startsWith('#')) return null;
138
+ for (let depth = segments.length - 1; depth >= 1; depth -= 1) {
139
+ const scope = q(segments.slice(0, depth).join('>'));
140
+ if (!scope) continue;
141
+ const hits = Array.from(scope.querySelectorAll<HTMLElement>(tag)).filter((el) => describeElement(el).label === label);
142
+ return hits.length === 1 ? hits[0] : null;
143
+ }
144
+ return null;
145
+ }
90
146
  const hash = value.lastIndexOf('#');
91
147
  const index = hash >= 0 ? Number(value.slice(hash + 1)) : NaN;
92
148
  const raw = Number.isFinite(index) ? value.slice(0, hash) : value;
@@ -142,18 +198,76 @@ function Avatar({ name }: { name: string }) {
142
198
 
143
199
  /* ============================ キャンバス上のマーカー ============================ */
144
200
 
201
+ /** マーカーを描く表示専用の層。保存時に丸ごと剥がされる(html-utils が除く) */
202
+ export function ensureCommentLayer(doc: Document, artboard: HTMLElement): HTMLElement {
203
+ let layer = artboard.querySelector<HTMLElement>(':scope > .gg-comment-layer');
204
+ if (!layer) {
205
+ layer = doc.createElement('div');
206
+ layer.className = 'gg-comment-layer';
207
+ layer.style.cssText = 'position:absolute;inset:0;pointer-events:none;z-index:9000;';
208
+ artboard.appendChild(layer);
209
+ }
210
+ return layer;
211
+ }
212
+
213
+ /** 紙面の倍率(iframe 内の transform × 外側のキャンバス倍率)。ピンや枠線を画面上で同じ太さに保つ */
214
+ function artboardScale(artboard: HTMLElement, outerZoom: number) {
215
+ const rootRect = artboard.getBoundingClientRect();
216
+ const innerScale = rootRect.width / artboard.offsetWidth || 1;
217
+ return { rootRect, innerScale, scale: innerScale * outerZoom || 1 };
218
+ }
219
+
220
+ /** 点(クリックで置いたピン)か */
221
+ export function isPointRect(r: CommentRect): boolean {
222
+ return r.width < 1 && r.height < 1;
223
+ }
224
+
225
+ /** 保存した矩形を今の紙面に合わせる。幅が変わっていたら横方向だけ比で補正する */
226
+ export function fitRect(rect: CommentRect, artboard: HTMLElement): CommentRect {
227
+ const sx = rect.ref?.width ? artboard.offsetWidth / rect.ref.width : 1;
228
+ return { x: rect.x * sx, y: rect.y, width: rect.width * sx, height: rect.height };
229
+ }
230
+
231
+ /** 矩形を人が読む形に(「範囲 (120, 840) 600×240」「点 (120, 840)」) */
232
+ export function describeRect(r: CommentRect): string {
233
+ const n = (v: number) => String(Math.round(v));
234
+ return isPointRect(r) ? `点 (${n(r.x)}, ${n(r.y)})` : `範囲 (${n(r.x)}, ${n(r.y)}) ${n(r.width)}×${n(r.height)}`;
235
+ }
236
+
237
+ /** ピンの文字。通し番号があれば番号、無ければ投稿者の頭文字 */
238
+ function pinText(c: SlideComment): string {
239
+ return c.seq != null ? String(c.seq) : (c.author || '?').slice(0, 1);
240
+ }
241
+
242
+ /** 同じ値の再代入で MutationObserver を起こさない(フォーカス中の DOM も保つ) */
243
+ function applyStyles(el: HTMLElement, styles: Partial<CSSStyleDeclaration>) {
244
+ const style = el.style as unknown as Record<string, string>;
245
+ for (const [key, value] of Object.entries(styles)) {
246
+ if (style[key] !== value) style[key] = value as string;
247
+ }
248
+ }
249
+
145
250
  /**
146
- * アンカー付きコメントのバブルを iframe 内へ描く。
251
+ * アンカー付きコメントのマーカーを iframe 内へ描く。
147
252
  * 座標はアートボード座標(要素の offset 系)なので、ズーム・パンに自動追従する。
253
+ *
254
+ * - 要素アンカー: 要素の右上に丸いピン(同じ要素に複数あれば縦に積む)
255
+ * - 範囲アンカー: 矩形の枠 + 左上にピン。点なら ピンだけ
256
+ * - 下書き(まだ投稿していない範囲): 破線の枠
257
+ * パネルで開いているスレッドのマーカーは太く出す
148
258
  */
149
259
  export function useCommentMarkers({
150
260
  page,
151
261
  active,
152
262
  onOpenThread,
263
+ activeThreadId = null,
264
+ draftRect = null,
153
265
  }: {
154
266
  page: number;
155
267
  active: boolean;
156
268
  onOpenThread: (commentId: string) => void;
269
+ activeThreadId?: string | null;
270
+ draftRect?: CommentRect | null;
157
271
  }) {
158
272
  const { getIframeDoc } = useEditorContext();
159
273
  const deck = useDeck();
@@ -172,66 +286,114 @@ export function useCommentMarkers({
172
286
  const doc = getIframeDoc();
173
287
  const artboard = doc?.getElementById('artboard');
174
288
  if (!doc || !artboard) return;
175
- let layer = artboard.querySelector<HTMLElement>(':scope > .gg-comment-layer');
176
- if (!layer) {
177
- layer = doc.createElement('div');
178
- layer.className = 'gg-comment-layer';
179
- // 保存時に丸ごと剥がされる前提の表示専用レイヤー
180
- layer.style.cssText = 'position:absolute;inset:0;pointer-events:none;z-index:9000;';
181
- artboard.appendChild(layer);
182
- }
183
- const anchored = comments.filter((c) => c.anchorSrc && !c.resolved);
184
- const wanted = new Set(anchored.map((c) => c.id));
185
- layer.querySelectorAll<HTMLButtonElement>('button').forEach((button) => {
186
- if (!wanted.has(button.dataset.commentId!)) button.remove();
289
+ const layer = ensureCommentLayer(doc, artboard);
290
+ const shown = comments.filter((c) => (c.anchorRect || c.anchorSrc) && !c.resolved);
291
+ const wanted = new Set(shown.map((c) => c.id));
292
+ layer.querySelectorAll<HTMLElement>('[data-comment-id]').forEach((el) => {
293
+ if (!wanted.has(el.dataset.commentId!)) el.remove();
187
294
  });
188
- const rootRect = artboard.getBoundingClientRect();
189
- const innerScale = rootRect.width / artboard.offsetWidth || 1;
190
- const scale = innerScale * outerZoom;
295
+ const { rootRect, innerScale, scale } = artboardScale(artboard, outerZoom);
191
296
  const size = 32 / scale;
297
+ const maxX = Math.max(0, artboard.offsetWidth - size);
192
298
  const stacks = new Map<string, number>();
193
- for (const c of anchored) {
194
- const target = findAnchored(artboard, c.anchorSrc!);
195
- // 要素が削除されたコメントは一覧から参照できる。別の場所に誤って付けない。
196
- if (!target) {
197
- layer.querySelector(`[data-comment-id="${CSS.escape(c.id)}"]`)?.remove();
198
- continue;
199
- }
200
- let bubble = layer.querySelector<HTMLButtonElement>(`[data-comment-id="${CSS.escape(c.id)}"]`);
201
- if (!bubble) {
202
- bubble = doc.createElement('button');
203
- bubble.type = 'button';
204
- bubble.setAttribute('data-comment-id', c.id);
205
- bubble.addEventListener('mousedown', (e) => { e.stopPropagation(); });
206
- bubble.addEventListener('click', (e) => {
299
+
300
+ const ensure = <T extends HTMLElement>(c: SlideComment, role: 'pin' | 'rect'): T => {
301
+ const sel = `[data-comment-id="${CSS.escape(c.id)}"][data-comment-${role}]`;
302
+ let el = layer.querySelector<T>(sel);
303
+ if (!el) {
304
+ el = doc.createElement(role === 'pin' ? 'button' : 'div') as unknown as T;
305
+ if (role === 'pin') (el as unknown as HTMLButtonElement).type = 'button';
306
+ el.setAttribute('data-comment-id', c.id);
307
+ el.setAttribute(`data-comment-${role}`, '');
308
+ el.addEventListener('mousedown', (e) => { e.stopPropagation(); });
309
+ el.addEventListener('pointerdown', (e) => { e.stopPropagation(); });
310
+ el.addEventListener('click', (e) => {
207
311
  e.preventDefault(); e.stopPropagation(); onOpenRef.current(c.id);
208
312
  });
209
- bubble.addEventListener('focus', () => { bubble!.style.outline = '3px solid #2459c4'; });
210
- bubble.addEventListener('blur', () => { bubble!.style.outline = ''; });
211
- layer.appendChild(bubble);
313
+ if (role === 'pin') {
314
+ el.addEventListener('focus', () => { el!.style.outline = '3px solid #2459c4'; });
315
+ el.addEventListener('blur', () => { el!.style.outline = ''; });
316
+ }
317
+ layer.appendChild(el);
318
+ }
319
+ return el;
320
+ };
321
+
322
+ for (const c of shown) {
323
+ const isActive = c.id === activeThreadId;
324
+ let x = 0;
325
+ let y = 0;
326
+ if (c.anchorRect) {
327
+ const r = fitRect(c.anchorRect, artboard);
328
+ if (isPointRect(r)) {
329
+ layer.querySelector(`[data-comment-id="${CSS.escape(c.id)}"][data-comment-rect]`)?.remove();
330
+ x = r.x - size / 2;
331
+ y = r.y - size / 2;
332
+ } else {
333
+ const box = ensure<HTMLDivElement>(c, 'rect');
334
+ box.title = `${c.author}のコメント:${c.text.slice(0, 80)}`;
335
+ applyStyles(box, {
336
+ position: 'absolute', left: `${r.x}px`, top: `${r.y}px`, width: `${r.width}px`, height: `${r.height}px`,
337
+ boxSizing: 'border-box', border: `${(isActive ? 3 : 2) / scale}px solid #2459c4`,
338
+ borderRadius: `${3 / scale}px`, background: isActive ? 'rgba(36,89,196,.16)' : 'rgba(36,89,196,.07)',
339
+ pointerEvents: 'auto', cursor: 'pointer',
340
+ });
341
+ // ピンは矩形の左上の角に掛ける
342
+ x = r.x - size / 2;
343
+ y = r.y - size / 2;
344
+ }
345
+ } else {
346
+ const target = findAnchored(artboard, c.anchorSrc!, c.anchorLabel);
347
+ // 要素が削除されたコメントは一覧から参照できる。別の場所に誤って付けない。
348
+ if (!target) {
349
+ layer.querySelectorAll(`[data-comment-id="${CSS.escape(c.id)}"]`).forEach((el) => el.remove());
350
+ continue;
351
+ }
352
+ const rect = target.getBoundingClientRect();
353
+ const stack = stacks.get(c.anchorSrc!) ?? 0;
354
+ stacks.set(c.anchorSrc!, stack + 1);
355
+ x = (rect.right - rootRect.left) / innerScale - size / 2;
356
+ y = (rect.top - rootRect.top) / innerScale - size / 2 + stack * (size + 4 / scale);
212
357
  }
213
- const label = `${c.author}のコメント:${c.text.slice(0, 80)}`;
214
- bubble.title = label;
215
- bubble.setAttribute('aria-label', label);
216
- const letter = (c.author || '?').slice(0, 1);
217
- if (bubble.textContent !== letter) bubble.textContent = letter;
218
- const rect = target.getBoundingClientRect();
219
- const stack = stacks.get(c.anchorSrc!) ?? 0;
220
- stacks.set(c.anchorSrc!, stack + 1);
221
- const x = Math.max(0, Math.min(artboard.offsetWidth - size, (rect.right - rootRect.left) / innerScale - size / 2));
222
- const y = Math.max(0, (rect.top - rootRect.top) / innerScale - size / 2) + stack * (size + 4 / scale);
223
- const styles: Partial<CSSStyleDeclaration> = {
224
- position: 'absolute', left: `${x}px`, top: `${y}px`, width: `${size}px`, height: `${size}px`,
225
- borderRadius: '50% 50% 50% 4px', background: '#2459c4', color: '#fff', fontSize: `${13 / scale}px`,
358
+ const pin = ensure<HTMLButtonElement>(c, 'pin');
359
+ const label = `${c.seq != null ? `#${c.seq} ` : ''}${c.author}のコメント:${c.text.slice(0, 80)}`;
360
+ pin.title = label;
361
+ pin.setAttribute('aria-label', label);
362
+ const text = pinText(c);
363
+ if (pin.textContent !== text) pin.textContent = text;
364
+ applyStyles(pin, {
365
+ position: 'absolute', left: `${Math.max(0, Math.min(maxX, x))}px`, top: `${Math.max(0, y)}px`,
366
+ width: `${size}px`, height: `${size}px`,
367
+ borderRadius: '50% 50% 50% 4px', background: '#2459c4', color: '#fff',
368
+ fontSize: `${(text.length > 1 ? 12 : 13) / scale}px`,
226
369
  fontFamily: 'system-ui, sans-serif', fontWeight: '700', border: `${2 / scale}px solid #fff`,
227
- boxShadow: '0 2px 8px #0003', cursor: 'pointer', pointerEvents: 'auto', display: 'flex',
228
- alignItems: 'center', justifyContent: 'center', padding: '0px',
229
- };
230
- // 同じ値を再代入してMutationObserverを起こさない。フォーカス中のDOMも維持する。
231
- const style = bubble.style as unknown as Record<string, string>;
232
- for (const [key, value] of Object.entries(styles)) {
233
- if (style[key] !== value) style[key] = value as string;
370
+ boxShadow: isActive ? `0 0 0 ${3 / scale}px rgba(36,89,196,.35), 0 2px 8px #0003` : '0 2px 8px #0003',
371
+ cursor: 'pointer', pointerEvents: 'auto', display: 'flex',
372
+ alignItems: 'center', justifyContent: 'center', padding: '0px', lineHeight: '1',
373
+ });
374
+ }
375
+
376
+ // 下書き(投稿前の範囲)。破線で出し、投稿・解除で消える
377
+ let draft = layer.querySelector<HTMLElement>('[data-comment-draft]');
378
+ if (draftRect) {
379
+ if (!draft) {
380
+ draft = doc.createElement('div');
381
+ draft.setAttribute('data-comment-draft', '');
382
+ layer.appendChild(draft);
234
383
  }
384
+ const r = fitRect(draftRect, artboard);
385
+ const point = isPointRect(r);
386
+ const d = 14 / scale;
387
+ applyStyles(draft, {
388
+ position: 'absolute',
389
+ left: `${point ? r.x - d / 2 : r.x}px`, top: `${point ? r.y - d / 2 : r.y}px`,
390
+ width: `${point ? d : r.width}px`, height: `${point ? d : r.height}px`,
391
+ boxSizing: 'border-box', border: `${2 / scale}px dashed #2459c4`,
392
+ borderRadius: point ? '50%' : `${3 / scale}px`, background: 'rgba(36,89,196,.10)',
393
+ pointerEvents: 'none',
394
+ });
395
+ } else {
396
+ draft?.remove();
235
397
  }
236
398
  };
237
399
 
@@ -244,7 +406,114 @@ export function useCommentMarkers({
244
406
  const layer = getIframeDoc()?.querySelector('.gg-comment-layer');
245
407
  layer?.remove();
246
408
  };
247
- }, [active, page, getIframeDoc, outerZoom, JSON.stringify(comments.map((c) => [c.id, c.anchorSrc, c.resolved, c.author, c.text]))]);
409
+ }, [
410
+ active, page, getIframeDoc, outerZoom, activeThreadId,
411
+ JSON.stringify(draftRect),
412
+ JSON.stringify(comments.map((c) => [c.id, c.anchorSrc, c.anchorRect, c.seq, c.resolved, c.author, c.text])),
413
+ ]);
414
+ }
415
+
416
+ /**
417
+ * コメントツール(C)。紙面をドラッグして範囲を決める。クリックだけなら点。
418
+ * iframe の document でポインタを捕まえ(要素は comment-mode で反応しない)、
419
+ * 紙面の外へ出ても離した位置まで追う(setPointerCapture)。
420
+ * 決まった矩形は onRegion に渡す(投稿はパネル側)。
421
+ */
422
+ export function useCommentRegionTool({
423
+ active,
424
+ onRegion,
425
+ }: {
426
+ active: boolean;
427
+ onRegion: (rect: CommentRect) => void;
428
+ }) {
429
+ const { getIframeDoc, iframeReady } = useEditorContext();
430
+ const onRegionRef = useRef(onRegion);
431
+ onRegionRef.current = onRegion;
432
+ const outerZoom = useCanvasViewStateOptional()?.canvasZoom ?? 1;
433
+
434
+ useEffect(() => {
435
+ if (!active) return;
436
+ const doc = getIframeDoc();
437
+ const artboard = doc?.getElementById('artboard');
438
+ if (!doc || !artboard) return;
439
+
440
+ let start: { x: number; y: number } | null = null;
441
+ let box: HTMLElement | null = null;
442
+
443
+ const clamp = (v: number, max: number) => Math.max(0, Math.min(max, v));
444
+ const toArtboard = (e: PointerEvent) => {
445
+ const r = artboard.getBoundingClientRect();
446
+ const s = r.width / artboard.offsetWidth || 1;
447
+ return {
448
+ x: clamp((e.clientX - r.left) / s, artboard.offsetWidth),
449
+ y: clamp((e.clientY - r.top) / s, artboard.offsetHeight),
450
+ };
451
+ };
452
+ const rectOf = (a: { x: number; y: number }, b: { x: number; y: number }) => ({
453
+ x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), width: Math.abs(a.x - b.x), height: Math.abs(a.y - b.y),
454
+ });
455
+
456
+ const onDown = (e: PointerEvent) => {
457
+ if (e.button !== 0) return;
458
+ // 既にあるピンのクリックは「スレッドを開く」。矩形の上からは新しい範囲を描ける(comment-mode で矩形は反応しない)
459
+ if ((e.target as HTMLElement | null)?.closest?.('[data-comment-pin]')) return;
460
+ e.preventDefault();
461
+ e.stopPropagation();
462
+ start = toArtboard(e);
463
+ try { artboard.setPointerCapture(e.pointerId); } catch { /* 捕まえられなくても続行 */ }
464
+ const { scale } = artboardScale(artboard, outerZoom);
465
+ box = doc.createElement('div');
466
+ box.setAttribute('data-comment-drafting', '');
467
+ box.style.cssText = `position:absolute;left:${start.x}px;top:${start.y}px;width:0;height:0;box-sizing:border-box;` +
468
+ `border:${2 / scale}px dashed #2459c4;background:rgba(36,89,196,.10);pointer-events:none;`;
469
+ ensureCommentLayer(doc, artboard).appendChild(box);
470
+ };
471
+ const onMove = (e: PointerEvent) => {
472
+ if (!start || !box) return;
473
+ e.preventDefault();
474
+ const r = rectOf(start, toArtboard(e));
475
+ box.style.left = `${r.x}px`;
476
+ box.style.top = `${r.y}px`;
477
+ box.style.width = `${r.width}px`;
478
+ box.style.height = `${r.height}px`;
479
+ };
480
+ const finish = (e: PointerEvent, cancelled: boolean) => {
481
+ if (!start) return;
482
+ const from = start;
483
+ const r = rectOf(from, toArtboard(e));
484
+ start = null;
485
+ try { artboard.releasePointerCapture(e.pointerId); } catch { /* 捕まえていない */ }
486
+ box?.remove();
487
+ box = null;
488
+ if (cancelled) return;
489
+ e.preventDefault();
490
+ e.stopPropagation();
491
+ // 4px 未満の動きはクリック=点
492
+ const point = r.width < 4 && r.height < 4;
493
+ const round = (v: number) => Math.round(v);
494
+ onRegionRef.current({
495
+ x: round(point ? from.x : r.x),
496
+ y: round(point ? from.y : r.y),
497
+ width: point ? 0 : round(r.width),
498
+ height: point ? 0 : round(r.height),
499
+ ref: { width: artboard.offsetWidth, height: artboard.offsetHeight },
500
+ });
501
+ };
502
+ const onUp = (e: PointerEvent) => finish(e, false);
503
+ const onCancel = (e: PointerEvent) => finish(e, true);
504
+
505
+ doc.addEventListener('pointerdown', onDown, true);
506
+ doc.addEventListener('pointermove', onMove, true);
507
+ doc.addEventListener('pointerup', onUp, true);
508
+ doc.addEventListener('pointercancel', onCancel, true);
509
+ return () => {
510
+ doc.removeEventListener('pointerdown', onDown, true);
511
+ doc.removeEventListener('pointermove', onMove, true);
512
+ doc.removeEventListener('pointerup', onUp, true);
513
+ doc.removeEventListener('pointercancel', onCancel, true);
514
+ box?.remove();
515
+ };
516
+ }, [active, iframeReady, getIframeDoc, outerZoom]);
248
517
  }
249
518
 
250
519
  /* ============================ 右パネル ============================ */
@@ -256,6 +525,9 @@ export function PptCommentsPanel({
256
525
  focusSignal,
257
526
  activeThreadId,
258
527
  onActiveThread,
528
+ pendingRect = null,
529
+ onClearPendingRect,
530
+ onPickRegion,
259
531
  }: {
260
532
  page: number;
261
533
  theme: PptTheme;
@@ -264,6 +536,11 @@ export function PptCommentsPanel({
264
536
  focusSignal: number;
265
537
  activeThreadId: string | null;
266
538
  onActiveThread: (id: string | null) => void;
539
+ /** コメントツールで指定した範囲(投稿前)。投稿か解除で消える */
540
+ pendingRect?: CommentRect | null;
541
+ onClearPendingRect?: () => void;
542
+ /** 「範囲を指定」= コメントツールに切り替える */
543
+ onPickRegion?: () => void;
267
544
  }) {
268
545
  const pal = PPT_PALETTES[theme];
269
546
  const deck = useDeck();
@@ -419,15 +696,17 @@ export function PptCommentsPanel({
419
696
  const post = () => {
420
697
  const text = draft.trim();
421
698
  if (!text) return;
699
+ // 範囲を指定していればそれが対象(要素の選択より優先)
422
700
  void run(() =>
423
701
  commentAction(page, {
424
702
  action: 'add',
425
703
  author: author.trim() || 'ゲスト',
426
704
  text,
427
- anchorSrc: currentAnchor?.src,
428
- anchorLabel: currentAnchor?.label,
705
+ anchorSrc: pendingRect ? undefined : currentAnchor?.src,
706
+ anchorLabel: pendingRect ? describeRect(pendingRect) : currentAnchor?.label,
707
+ anchorRect: pendingRect ?? undefined,
429
708
  }),
430
- ).then((ok) => { if (ok) setDraft(''); });
709
+ ).then((ok) => { if (ok) { setDraft(''); onClearPendingRect?.(); } });
431
710
  };
432
711
 
433
712
  const open = comments.filter((c) => !c.resolved);
@@ -461,7 +740,10 @@ export function PptCommentsPanel({
461
740
  <div className="flex items-center gap-2">
462
741
  <Avatar name={c.author} />
463
742
  <div className="min-w-0 flex-1">
464
- <div className="truncate text-[12px] font-medium" style={{ color: pal.text }}>{c.author}</div>
743
+ <div className="truncate text-[12px] font-medium" style={{ color: pal.text }}>
744
+ {c.seq != null && <span className="mr-1 tabular-nums" style={{ color: 'var(--ed-accent)' }}>#{c.seq}</span>}
745
+ {c.author}
746
+ </div>
465
747
  <div className="text-[10px]" style={{ color: pal.sub }}>{fmtTime(c.createdAt)}</div>
466
748
  </div>
467
749
  {c.resolved ? (
@@ -493,7 +775,12 @@ export function PptCommentsPanel({
493
775
  </button>
494
776
  </div>
495
777
 
496
- {c.anchorSrc && (
778
+ {c.anchorRect ? (
779
+ <div className="mt-1.5 flex items-center gap-1 text-[10px]" style={{ color: 'var(--ed-accent)' }} data-comment-rect-label>
780
+ <Crosshair className="h-3 w-3" />
781
+ {describeRect(c.anchorRect)}
782
+ </div>
783
+ ) : c.anchorSrc && (
497
784
  <div className="mt-1.5 flex items-center gap-1 text-[10px]" style={{ color: 'var(--ed-accent)' }}>
498
785
  <MapPin className="h-3 w-3" />
499
786
  {c.anchorLabel ? `「${c.anchorLabel}」` : '要素に添付'}
@@ -652,10 +939,37 @@ export function PptCommentsPanel({
652
939
  style={{ backgroundColor: pal.control, borderColor: pal.border, color: pal.text }}
653
940
  />
654
941
  </div>
655
- {/* 何に対するコメントかを、投稿する前に必ず見せる */}
656
- {currentAnchor ? (
942
+ {/* 何に対するコメントかを、投稿する前に必ず見せる(範囲 > 要素 > ページ全体) */}
943
+ {pendingRect ? (
657
944
  <div
658
945
  className="mt-1.5 flex items-start gap-1.5 rounded border px-1.5 py-1 text-[10px]"
946
+ data-comment-pending-rect
947
+ style={{
948
+ color: 'var(--ed-accent)',
949
+ borderColor: 'var(--ed-accent)',
950
+ backgroundColor: 'rgba(15,108,189,.08)',
951
+ }}
952
+ >
953
+ <Crosshair className="mt-px h-3 w-3 shrink-0" />
954
+ <span className="min-w-0 flex-1">
955
+ 指定した<span className="font-bold">{describeRect(pendingRect)}</span>へのコメント
956
+ </span>
957
+ {onClearPendingRect && (
958
+ <button
959
+ type="button"
960
+ onClick={onClearPendingRect}
961
+ aria-label="範囲の指定を解除"
962
+ title="範囲の指定を解除"
963
+ className="shrink-0 rounded p-0.5"
964
+ >
965
+ <X className="h-3 w-3" />
966
+ </button>
967
+ )}
968
+ </div>
969
+ ) : currentAnchor ? (
970
+ <div
971
+ className="mt-1.5 flex items-start gap-1.5 rounded border px-1.5 py-1 text-[10px]"
972
+ data-comment-anchor="element"
659
973
  style={{
660
974
  color: 'var(--ed-accent)',
661
975
  borderColor: 'var(--ed-accent)',
@@ -680,14 +994,28 @@ export function PptCommentsPanel({
680
994
  ) : (
681
995
  <div
682
996
  className="mt-1.5 flex items-start gap-1.5 rounded border border-dashed px-1.5 py-1 text-[10px]"
997
+ data-comment-anchor="page"
683
998
  style={{ color: pal.sub, borderColor: pal.border }}
684
999
  >
685
1000
  <MapPin className="mt-px h-3 w-3 shrink-0" />
686
1001
  <span>
687
- ページ全体へのコメント(要素を選ぶと、その要素へのコメントになります)
1002
+ ページ全体へのコメント(要素を選ぶとその要素へ、「範囲を指定」で紙面の場所へ)
688
1003
  </span>
689
1004
  </div>
690
1005
  )}
1006
+ {onPickRegion && !pendingRect && (
1007
+ <button
1008
+ type="button"
1009
+ onClick={onPickRegion}
1010
+ data-comment-pick-region
1011
+ title="紙面をドラッグして、コメントする範囲を指定します (C)"
1012
+ className="mt-1.5 flex items-center gap-1 rounded border px-1.5 py-1 text-[11px]"
1013
+ style={{ borderColor: pal.border, color: 'var(--ed-accent)' }}
1014
+ >
1015
+ <Crosshair className="h-3 w-3" />
1016
+ 範囲を指定 <span style={{ color: pal.sub }}>C</span>
1017
+ </button>
1018
+ )}
691
1019
  <div className="mt-1.5 flex items-end gap-1.5">
692
1020
  <textarea
693
1021
  ref={composerRef}
@@ -723,7 +1051,7 @@ export function PptCommentsPanel({
723
1051
  <div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-4">
724
1052
  {open.length === 0 && resolved.length === 0 && (
725
1053
  <p className="px-1 pt-2 text-[12px]" style={{ color: pal.sub }}>
726
- まだコメントはありません。要素を選択して投稿すると、その要素に吹き出しが付きます。
1054
+ まだコメントはありません。要素を選ぶか「範囲を指定」で場所を決めて投稿すると、紙面にピンが付きます。
727
1055
  </p>
728
1056
  )}
729
1057
  {open.length === 0 && resolved.length > 0 && <p className="py-2 text-xs text-gray-400">未解決のコメントはありません。</p>}
@@ -749,14 +1077,26 @@ export function PptCommentsPanel({
749
1077
  }
750
1078
 
751
1079
 
752
- /** マーカーだけを常駐させる薄いホスト(パネルが閉じていても吹き出しは見える) */
1080
+ /**
1081
+ * マーカーだけを常駐させる薄いホスト(パネルが閉じていても吹き出しは見える)。
1082
+ * コメントツールの範囲指定もここで動かす(パネルは開くまでマウントされないため)
1083
+ */
753
1084
  export function PptCommentMarkers({
754
1085
  page,
755
1086
  onOpenThread,
1087
+ activeThreadId = null,
1088
+ draftRect = null,
1089
+ regionActive = false,
1090
+ onRegion,
756
1091
  }: {
757
1092
  page: number;
758
1093
  onOpenThread: (commentId: string) => void;
1094
+ activeThreadId?: string | null;
1095
+ draftRect?: CommentRect | null;
1096
+ regionActive?: boolean;
1097
+ onRegion?: (rect: CommentRect) => void;
759
1098
  }) {
760
- useCommentMarkers({ page, active: true, onOpenThread });
1099
+ useCommentMarkers({ page, active: true, onOpenThread, activeThreadId, draftRect });
1100
+ useCommentRegionTool({ active: regionActive && !!onRegion, onRegion: (rect) => onRegion?.(rect) });
761
1101
  return null;
762
1102
  }
@@ -183,7 +183,22 @@ export const EDITOR_IFRAME_STYLES = `
183
183
  body.text-mode [data-editable="true"] {
184
184
  pointer-events: none;
185
185
  }
186
- body:not(.draw-mode):not(.text-mode):not(.move-mode) [data-editable="true"] {
186
+ /* コメントの範囲指定中: 要素は反応せず、紙面のドラッグが範囲になる */
187
+ body.comment-mode {
188
+ cursor: crosshair !important;
189
+ }
190
+ body.comment-mode [data-editable="true"] {
191
+ pointer-events: none;
192
+ }
193
+ body.comment-mode .selection-box,
194
+ body.comment-mode .hover-preview {
195
+ display: none !important;
196
+ }
197
+ /* 範囲の枠の上からも新しい範囲を描けるように(ピンはクリックでスレッドを開く) */
198
+ body.comment-mode .gg-comment-layer [data-comment-rect] {
199
+ pointer-events: none !important;
200
+ }
201
+ body:not(.draw-mode):not(.text-mode):not(.move-mode):not(.comment-mode) [data-editable="true"] {
187
202
  pointer-events: auto !important;
188
203
  cursor: pointer;
189
204
  }