@growgroup/visual-editor 0.3.1 → 0.4.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.
- package/README.md +6 -1
- package/dist/editor.css +1 -1
- package/package.json +1 -1
- package/src/editor/EditorContext.tsx +3 -2
- package/src/editor/EditorToolbar.tsx +8 -2
- package/src/editor/FrontendVisualEditor.tsx +114 -44
- package/src/editor/components/EditorCanvas.tsx +73 -15
- package/src/editor/components/EditorFooter.tsx +2 -0
- package/src/editor/components/EditorHeader.tsx +3 -2
- package/src/editor/components/FigmaColorPicker.tsx +2 -1
- package/src/editor/components/JsEditorDialog.tsx +1 -0
- package/src/editor/components/PageSettingsDialog.tsx +1 -0
- package/src/editor/components/comments/CommentBoard.tsx +9 -6
- package/src/editor/components/multi-page/CanvasFrameDecor.tsx +172 -0
- package/src/editor/components/multi-page/MultiPageCanvasView.tsx +173 -77
- package/src/editor/components/multi-page/PageFramePreview.tsx +96 -20
- package/src/editor/components/multi-page/index.ts +1 -0
- package/src/editor/components/ppt/PptChrome.tsx +6 -2
- package/src/editor/components/ppt/PptComments.tsx +411 -71
- package/src/editor/constants.ts +16 -1
- package/src/editor/contexts/EditorArtboardContext.tsx +8 -0
- package/src/editor/contexts/EditorComponentsContext.tsx +31 -27
- package/src/editor/contexts/EditorUIStateContext.tsx +4 -3
- package/src/editor/contexts/EditorVariablesContext.tsx +5 -4
- package/src/editor/contexts/MultiPageCanvasContext.tsx +26 -2
- package/src/editor/hooks/useAiReplace.ts +4 -3
- package/src/editor/hooks/useBrowserZoomPrevention.ts +2 -1
- package/src/editor/hooks/useComponentEditMode.ts +27 -26
- package/src/editor/hooks/useEditorMessages.ts +3 -2
- package/src/editor/hooks/useElementActions.ts +27 -26
- package/src/editor/hooks/useElementSelection.ts +19 -16
- package/src/editor/hooks/useFocusManagement.ts +7 -6
- package/src/editor/hooks/useGoogleFonts.ts +3 -2
- package/src/editor/hooks/useIframeInitializer.ts +6 -3
- package/src/editor/hooks/useIframeSetup.ts +4 -3
- package/src/editor/hooks/useImageUpload.ts +4 -3
- package/src/editor/hooks/useMarqueeSelection.ts +2 -1
- package/src/editor/hooks/usePageSettingsManager.ts +24 -23
- package/src/editor/hooks/useRichPaste.ts +11 -10
- package/src/editor/parts.ts +7 -25
- package/src/editor/utils/component-renderer.ts +8 -7
- package/src/editor/utils/component-sync.ts +16 -15
- package/src/editor/utils/debug.ts +52 -0
- package/src/editor/utils/dom-utils.ts +11 -10
- package/src/editor/utils/drop-target.ts +135 -0
- package/src/editor/utils/eyedropper.ts +3 -1
- package/src/editor/utils/figma-export.ts +6 -5
- package/src/editor/utils/figma-kiwi-decoder.ts +28 -27
- package/src/editor/utils/figma-kiwi-encoder.ts +9 -8
- package/src/editor/utils/figma-paste.ts +57 -56
- package/src/editor/utils/flex-utils.ts +12 -10
- package/src/editor/utils/override-apply.ts +3 -2
- package/src/editor/utils/override-detection.ts +11 -10
- package/src/editor/utils/tailwind-utils.ts +4 -3
- package/src/io.ts +22 -1
- package/src/lib/firebase/css-variables.ts +4 -3
- package/src/lib/firebase/storage.ts +3 -2
- package/src/styles/skin.css +9 -4
- package/src/types/editor.ts +6 -2
- 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
|
-
/**
|
|
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
|
-
/**
|
|
89
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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:
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
}, [
|
|
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 }}>
|
|
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.
|
|
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
|
-
{
|
|
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
|
}
|
package/src/editor/constants.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|