@growgroup/visual-editor 0.1.2 → 0.1.4
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/dist/editor.css +1 -1
- package/package.json +2 -1
- package/src/editor/FrontendVisualEditor.tsx +28 -2
- package/src/editor/components/EditorHeader.tsx +5 -0
- package/src/editor/components/EditorLayerPanel.tsx +27 -10
- package/src/editor/components/comments/CommentBoard.tsx +499 -0
- package/src/editor/components/comments/CommentBoardPanel.tsx +133 -0
- package/src/editor/components/ppt/PptComments.tsx +9 -4
- package/src/editor/components/shell/LeftPanel.tsx +72 -8
- package/src/index.ts +15 -0
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* コメントボード(カンバン形式の一覧)の見た目。
|
|
5
|
+
*
|
|
6
|
+
* 右パネル(PptComments)が「いま開いているページのスレッド」を見るのに対し、
|
|
7
|
+
* こちらは「デッキ全体のコメント」を俯瞰する。列=ページ・カード=1スレッド。
|
|
8
|
+
* どのページに指摘が何件残っているかを一望し、ページを行き来せずに
|
|
9
|
+
* 解決・返信・削除まで済ませられる。クライアント指摘の管理画面として使う。
|
|
10
|
+
*
|
|
11
|
+
* ここは純粋な表示。取得も保存もせず、必要なものはすべて props で受け取る。
|
|
12
|
+
* パッケージ内の io に配線済みの版が欲しいときは CommentBoardPanel を使うこと。
|
|
13
|
+
*
|
|
14
|
+
* 操作UIは「渡された分だけ」出す(io の capability と同じ考え方)。
|
|
15
|
+
* onReply を渡さなければ返信欄は出ないし、onDelete が無ければゴミ箱も出ない。
|
|
16
|
+
* 押しても何も起きないボタンを残さないための決まり。
|
|
17
|
+
*
|
|
18
|
+
* 高さは親から与えること —— 列ごとの縦スクロールが `h-full` に依存している。
|
|
19
|
+
*
|
|
20
|
+
* <div className="h-screen"><CommentBoard deck={deck} author="" /></div>
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { useState } from 'react';
|
|
24
|
+
import { Check, CornerUpLeft, ExternalLink, MapPin, MessageSquare, RotateCcw, Send, Trash2 } from 'lucide-react';
|
|
25
|
+
import type { Deck, SlideComment } from '../../../lib/deck';
|
|
26
|
+
import { PPT_PALETTES, type PptTheme } from '../ppt/PptChrome';
|
|
27
|
+
import { fmtTime, unresolvedCount } from '../ppt/PptComments';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* アンカー・返信のリンク色。
|
|
31
|
+
*
|
|
32
|
+
* 明るい配色では右パネルと同じ青。暗い配色では明るい青に振る。
|
|
33
|
+
* ボードは一度に何十枚も読む面で、暗い背景 + 解決済の減光(opacity .72)が重なると
|
|
34
|
+
* 右パネルの青(#0F6CBD)では沈んで読めなくなるため。
|
|
35
|
+
*/
|
|
36
|
+
const accentOf = (theme: PptTheme) => (theme === 'dark' ? '#4CA5E8' : '#0F6CBD');
|
|
37
|
+
/** 未解決の橙(Avatarの配色から。解決済はパレットのグレーに落とす) */
|
|
38
|
+
const OPEN_COLOR = '#CA5010';
|
|
39
|
+
|
|
40
|
+
type Filter = 'all' | 'open' | 'resolved';
|
|
41
|
+
|
|
42
|
+
const FILTERS: { value: Filter; label: string }[] = [
|
|
43
|
+
{ value: 'all', label: 'すべて' },
|
|
44
|
+
{ value: 'open', label: '未解決' },
|
|
45
|
+
{ value: 'resolved', label: '解決済' },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const excerpt = (text: string, max = 28) => {
|
|
49
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
50
|
+
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 操作の戻り値。
|
|
55
|
+
* `false` を返したときだけ「失敗」とみなし、書きかけの返信を残す。
|
|
56
|
+
* 何も返さなければ成功扱い(利用側に boolean を強制しない)。
|
|
57
|
+
*/
|
|
58
|
+
type ActionResult = Promise<boolean | void> | boolean | void;
|
|
59
|
+
|
|
60
|
+
export type CommentBoardProps = {
|
|
61
|
+
/** 表示するデッキ。`slides` の並びがそのまま列の並びになる */
|
|
62
|
+
deck: Deck;
|
|
63
|
+
/** 取得中。列の代わりに読み込み中の表示を出す */
|
|
64
|
+
loading?: boolean;
|
|
65
|
+
/** 操作中。ボタンを止める */
|
|
66
|
+
busy?: boolean;
|
|
67
|
+
|
|
68
|
+
/** 投稿者名。返信に使う */
|
|
69
|
+
author: string;
|
|
70
|
+
/** 渡すと名前の入力欄を出す */
|
|
71
|
+
onAuthorChange?: (value: string) => void;
|
|
72
|
+
|
|
73
|
+
/** 解決状態の切り替え。渡さなければトグルを出さない */
|
|
74
|
+
onResolve?: (page: number, commentId: string, resolved: boolean) => ActionResult;
|
|
75
|
+
/** 返信の追加。渡さなければ返信欄を出さない */
|
|
76
|
+
onReply?: (page: number, commentId: string, text: string) => ActionResult;
|
|
77
|
+
/** スレッドの削除。渡さなければゴミ箱を出さない(確認はこの中で取る) */
|
|
78
|
+
onDelete?: (page: number, commentId: string) => ActionResult;
|
|
79
|
+
/** 列ヘッダーの「このページを開く」。渡さなければボタンを出さない */
|
|
80
|
+
onOpenPage?: (page: number) => void;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 配色。既定は light。
|
|
84
|
+
* initialPptTheme() を既定にしない —— localStorage/matchMedia を見るため、
|
|
85
|
+
* 利用側がサーバー描画するとハイドレーションがずれる。
|
|
86
|
+
* OS追従にしたい利用側は initialPptTheme() の結果を渡すこと。
|
|
87
|
+
*/
|
|
88
|
+
theme?: PptTheme;
|
|
89
|
+
/** 外枠に足すclass。高さは親が持つ前提 */
|
|
90
|
+
className?: string;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export function CommentBoard({
|
|
94
|
+
deck,
|
|
95
|
+
loading = false,
|
|
96
|
+
busy = false,
|
|
97
|
+
author,
|
|
98
|
+
onAuthorChange,
|
|
99
|
+
onResolve,
|
|
100
|
+
onReply,
|
|
101
|
+
onDelete,
|
|
102
|
+
onOpenPage,
|
|
103
|
+
theme = 'light',
|
|
104
|
+
className,
|
|
105
|
+
}: CommentBoardProps) {
|
|
106
|
+
const pal = PPT_PALETTES[theme];
|
|
107
|
+
const accent = accentOf(theme);
|
|
108
|
+
|
|
109
|
+
const [filter, setFilter] = useState<Filter>('all');
|
|
110
|
+
const [showEmpty, setShowEmpty] = useState(false);
|
|
111
|
+
|
|
112
|
+
const columns = deck.slides.map((slide, i) => {
|
|
113
|
+
const comments = slide.comments ?? [];
|
|
114
|
+
return {
|
|
115
|
+
page: i + 1,
|
|
116
|
+
title: slide.title || `ページ ${i + 1}`,
|
|
117
|
+
comments,
|
|
118
|
+
open: unresolvedCount(comments),
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const total = columns.reduce((n, c) => n + c.comments.length, 0);
|
|
123
|
+
const openTotal = columns.reduce((n, c) => n + c.open, 0);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 絞り込み後の列。既定では中身のある列だけを出す。
|
|
127
|
+
* サマリの数字は絞り込みに連動させない(全体像を見失うため)。
|
|
128
|
+
*/
|
|
129
|
+
const shown = columns
|
|
130
|
+
.map((col) => ({
|
|
131
|
+
...col,
|
|
132
|
+
visible: col.comments.filter((c) =>
|
|
133
|
+
filter === 'all' ? true : filter === 'open' ? !c.resolved : !!c.resolved,
|
|
134
|
+
),
|
|
135
|
+
}))
|
|
136
|
+
.filter((col) => showEmpty || col.visible.length > 0);
|
|
137
|
+
|
|
138
|
+
const tab = (active: boolean) => ({
|
|
139
|
+
backgroundColor: active ? pal.activeBg : 'transparent',
|
|
140
|
+
color: active ? pal.text : pal.sub,
|
|
141
|
+
borderColor: pal.border,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<div
|
|
146
|
+
className={`flex h-full min-h-0 flex-col ${className ?? ''}`}
|
|
147
|
+
style={{ backgroundColor: pal.canvas }}
|
|
148
|
+
>
|
|
149
|
+
{/* 上部バー: サマリ・絞り込み・名前 */}
|
|
150
|
+
<div
|
|
151
|
+
className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b px-3 py-2"
|
|
152
|
+
style={{ backgroundColor: pal.chrome, borderColor: pal.border }}
|
|
153
|
+
>
|
|
154
|
+
<span className="text-[13px] font-medium" style={{ color: pal.text }}>
|
|
155
|
+
コメントボード
|
|
156
|
+
</span>
|
|
157
|
+
<span className="text-[11px]" style={{ color: pal.sub }}>
|
|
158
|
+
総数 {total}
|
|
159
|
+
<span className="px-1">/</span>
|
|
160
|
+
未解決 <span style={{ color: openTotal > 0 ? OPEN_COLOR : pal.sub }}>{openTotal}</span>
|
|
161
|
+
<span className="px-1">/</span>
|
|
162
|
+
解決済 {total - openTotal}
|
|
163
|
+
</span>
|
|
164
|
+
|
|
165
|
+
<div className="flex overflow-hidden rounded border" style={{ borderColor: pal.border }}>
|
|
166
|
+
{FILTERS.map((f) => (
|
|
167
|
+
<button
|
|
168
|
+
key={f.value}
|
|
169
|
+
onClick={() => setFilter(f.value)}
|
|
170
|
+
className="px-2 py-0.5 text-[11px] transition-colors"
|
|
171
|
+
style={tab(filter === f.value)}
|
|
172
|
+
>
|
|
173
|
+
{f.label}
|
|
174
|
+
</button>
|
|
175
|
+
))}
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<button
|
|
179
|
+
onClick={() => setShowEmpty((v) => !v)}
|
|
180
|
+
title="コメントの無いページも列として出す"
|
|
181
|
+
className="rounded border px-2 py-0.5 text-[11px] transition-colors"
|
|
182
|
+
style={tab(showEmpty)}
|
|
183
|
+
>
|
|
184
|
+
すべてのページ
|
|
185
|
+
</button>
|
|
186
|
+
|
|
187
|
+
{onAuthorChange && (
|
|
188
|
+
<input
|
|
189
|
+
value={author}
|
|
190
|
+
onChange={(e) => onAuthorChange(e.target.value)}
|
|
191
|
+
placeholder="名前(記憶されます)"
|
|
192
|
+
className="ml-auto h-6 w-[160px] rounded border px-1.5 text-[11px] outline-none"
|
|
193
|
+
style={{ backgroundColor: pal.control, borderColor: pal.border, color: pal.text }}
|
|
194
|
+
/>
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
{loading ? (
|
|
199
|
+
<p className="p-4 text-[12px]" style={{ color: pal.sub }}>
|
|
200
|
+
読み込んでいます…
|
|
201
|
+
</p>
|
|
202
|
+
) : shown.length === 0 ? (
|
|
203
|
+
<p className="p-4 text-[12px]" style={{ color: pal.sub }}>
|
|
204
|
+
{total === 0
|
|
205
|
+
? 'まだコメントはありません。エディタでページを開き、要素を選んで投稿すると、ここに並びます。'
|
|
206
|
+
: 'この絞り込みに該当するコメントはありません。'}
|
|
207
|
+
</p>
|
|
208
|
+
) : (
|
|
209
|
+
<div className="min-h-0 flex-1 overflow-x-auto">
|
|
210
|
+
<div className="flex h-full gap-3 p-3">
|
|
211
|
+
{shown.map((col) => (
|
|
212
|
+
<BoardColumn
|
|
213
|
+
key={col.page}
|
|
214
|
+
pal={pal}
|
|
215
|
+
accent={accent}
|
|
216
|
+
page={col.page}
|
|
217
|
+
title={col.title}
|
|
218
|
+
comments={col.visible}
|
|
219
|
+
openCount={col.open}
|
|
220
|
+
totalCount={col.comments.length}
|
|
221
|
+
busy={busy}
|
|
222
|
+
onResolve={onResolve}
|
|
223
|
+
onReply={onReply}
|
|
224
|
+
onDelete={onDelete}
|
|
225
|
+
onOpenPage={onOpenPage}
|
|
226
|
+
/>
|
|
227
|
+
))}
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
)}
|
|
231
|
+
</div>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
type Palette = (typeof PPT_PALETTES)[PptTheme];
|
|
236
|
+
|
|
237
|
+
type CardCommon = {
|
|
238
|
+
pal: Palette;
|
|
239
|
+
accent: string;
|
|
240
|
+
page: number;
|
|
241
|
+
busy: boolean;
|
|
242
|
+
onResolve?: CommentBoardProps['onResolve'];
|
|
243
|
+
onReply?: CommentBoardProps['onReply'];
|
|
244
|
+
onDelete?: CommentBoardProps['onDelete'];
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
/** 1ページ分の列 */
|
|
248
|
+
function BoardColumn({
|
|
249
|
+
pal,
|
|
250
|
+
page,
|
|
251
|
+
title,
|
|
252
|
+
comments,
|
|
253
|
+
openCount,
|
|
254
|
+
totalCount,
|
|
255
|
+
onOpenPage,
|
|
256
|
+
...common
|
|
257
|
+
}: CardCommon & {
|
|
258
|
+
title: string;
|
|
259
|
+
comments: SlideComment[];
|
|
260
|
+
openCount: number;
|
|
261
|
+
totalCount: number;
|
|
262
|
+
onOpenPage?: (page: number) => void;
|
|
263
|
+
}) {
|
|
264
|
+
return (
|
|
265
|
+
<section
|
|
266
|
+
className="flex h-full w-[300px] shrink-0 flex-col rounded-lg border"
|
|
267
|
+
style={{ borderColor: pal.border, backgroundColor: pal.rail }}
|
|
268
|
+
>
|
|
269
|
+
<div className="shrink-0 border-b px-2.5 py-2" style={{ borderColor: pal.border }}>
|
|
270
|
+
<div className="flex items-baseline gap-1.5">
|
|
271
|
+
<span className="text-[11px] tabular-nums" style={{ color: pal.sub }}>
|
|
272
|
+
{String(page).padStart(2, '0')}
|
|
273
|
+
</span>
|
|
274
|
+
<span
|
|
275
|
+
className="min-w-0 flex-1 truncate text-[12.5px] font-medium"
|
|
276
|
+
style={{ color: pal.text }}
|
|
277
|
+
title={title}
|
|
278
|
+
>
|
|
279
|
+
{title}
|
|
280
|
+
</span>
|
|
281
|
+
{onOpenPage && (
|
|
282
|
+
<button
|
|
283
|
+
onClick={() => onOpenPage(page)}
|
|
284
|
+
title="このページを開く"
|
|
285
|
+
className="rounded p-1"
|
|
286
|
+
style={{ color: pal.sub }}
|
|
287
|
+
>
|
|
288
|
+
<ExternalLink className="h-3.5 w-3.5" />
|
|
289
|
+
</button>
|
|
290
|
+
)}
|
|
291
|
+
</div>
|
|
292
|
+
<div className="mt-0.5 text-[10px]" style={{ color: pal.sub }}>
|
|
293
|
+
<span style={{ color: openCount > 0 ? OPEN_COLOR : pal.sub }}>未解決 {openCount}</span>
|
|
294
|
+
<span className="px-1">/</span>全 {totalCount}
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-2">
|
|
299
|
+
{comments.length === 0 ? (
|
|
300
|
+
<p className="px-1 pt-1 text-[11px]" style={{ color: pal.sub }}>
|
|
301
|
+
コメントなし
|
|
302
|
+
</p>
|
|
303
|
+
) : (
|
|
304
|
+
comments.map((c) => <BoardCard key={c.id} comment={c} {...common} pal={pal} page={page} />)
|
|
305
|
+
)}
|
|
306
|
+
</div>
|
|
307
|
+
</section>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* 1スレッドのカード。
|
|
313
|
+
*
|
|
314
|
+
* モジュールの直下に置くこと。親の中で定義すると再描画のたびに
|
|
315
|
+
* 別のコンポーネント扱いになり、返信欄が作り直されて日本語入力が壊れる
|
|
316
|
+
* (PptComments の renderThread に同じ経緯の注意書きがある)。
|
|
317
|
+
*/
|
|
318
|
+
function BoardCard({
|
|
319
|
+
comment,
|
|
320
|
+
pal,
|
|
321
|
+
accent,
|
|
322
|
+
page,
|
|
323
|
+
busy,
|
|
324
|
+
onResolve,
|
|
325
|
+
onReply,
|
|
326
|
+
onDelete,
|
|
327
|
+
}: CardCommon & { comment: SlideComment }) {
|
|
328
|
+
const [expanded, setExpanded] = useState(false);
|
|
329
|
+
const [replyDraft, setReplyDraft] = useState('');
|
|
330
|
+
|
|
331
|
+
const replies = comment.replies ?? [];
|
|
332
|
+
const last = replies[replies.length - 1];
|
|
333
|
+
const open = !comment.resolved;
|
|
334
|
+
|
|
335
|
+
const sendReply = async () => {
|
|
336
|
+
const text = replyDraft.trim();
|
|
337
|
+
if (!text || !onReply) return;
|
|
338
|
+
// 失敗(false)のときは書いたものを残す。消えると戻せない
|
|
339
|
+
const ok = await onReply(page, comment.id, text);
|
|
340
|
+
if (ok !== false) setReplyDraft('');
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
return (
|
|
344
|
+
<div
|
|
345
|
+
className="rounded-lg border p-2.5"
|
|
346
|
+
style={{
|
|
347
|
+
borderColor: open ? OPEN_COLOR : pal.border,
|
|
348
|
+
backgroundColor: pal.control,
|
|
349
|
+
opacity: comment.resolved ? 0.72 : 1,
|
|
350
|
+
}}
|
|
351
|
+
>
|
|
352
|
+
<div className="flex items-center gap-1.5">
|
|
353
|
+
<span
|
|
354
|
+
className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold"
|
|
355
|
+
style={
|
|
356
|
+
open
|
|
357
|
+
? { backgroundColor: OPEN_COLOR, color: '#ffffff' }
|
|
358
|
+
: { backgroundColor: pal.activeBg, color: pal.sub }
|
|
359
|
+
}
|
|
360
|
+
>
|
|
361
|
+
{open ? '未解決' : '解決済'}
|
|
362
|
+
</span>
|
|
363
|
+
<span className="min-w-0 flex-1 truncate text-[11px]" style={{ color: pal.sub }}>
|
|
364
|
+
{comment.author}
|
|
365
|
+
</span>
|
|
366
|
+
<span className="shrink-0 text-[10px]" style={{ color: pal.sub }}>
|
|
367
|
+
{fmtTime(comment.createdAt)}
|
|
368
|
+
</span>
|
|
369
|
+
</div>
|
|
370
|
+
|
|
371
|
+
{/* 何に対する指摘か。要素に紐づいていなければページ全体 */}
|
|
372
|
+
<div
|
|
373
|
+
className="mt-1.5 flex items-center gap-1 text-[10px]"
|
|
374
|
+
style={{ color: comment.anchorSrc ? accent : pal.sub }}
|
|
375
|
+
title={comment.anchorSrc}
|
|
376
|
+
>
|
|
377
|
+
<MapPin className="h-3 w-3 shrink-0" />
|
|
378
|
+
<span className="truncate">
|
|
379
|
+
{comment.anchorSrc ? (comment.anchorLabel ? `「${comment.anchorLabel}」` : '要素に添付') : 'ページ全体'}
|
|
380
|
+
</span>
|
|
381
|
+
</div>
|
|
382
|
+
|
|
383
|
+
{/* 本文。長いものは3行で畳み、クリックで全文と返信を出す */}
|
|
384
|
+
<button
|
|
385
|
+
onClick={() => setExpanded((v) => !v)}
|
|
386
|
+
aria-expanded={expanded}
|
|
387
|
+
className="mt-1.5 block w-full text-left"
|
|
388
|
+
>
|
|
389
|
+
{/*
|
|
390
|
+
畳むときに `block` を併記しない。line-clamp は display:-webkit-box で効くので、
|
|
391
|
+
同じ display 系の block を一緒に当てると打ち消されて3行に収まらない(実際に起きた)。
|
|
392
|
+
*/}
|
|
393
|
+
<span
|
|
394
|
+
className={`whitespace-pre-wrap text-[12.5px] leading-relaxed ${expanded ? 'block' : 'line-clamp-3'}`}
|
|
395
|
+
style={{ color: pal.text }}
|
|
396
|
+
>
|
|
397
|
+
{comment.text}
|
|
398
|
+
</span>
|
|
399
|
+
</button>
|
|
400
|
+
|
|
401
|
+
{!expanded && replies.length > 0 && (
|
|
402
|
+
<p className="mt-1.5 flex items-center gap-1 text-[10px]" style={{ color: pal.sub }}>
|
|
403
|
+
<MessageSquare className="h-3 w-3 shrink-0" />
|
|
404
|
+
<span className="truncate">
|
|
405
|
+
返信{replies.length}件・{last.author}「{excerpt(last.text)}」
|
|
406
|
+
</span>
|
|
407
|
+
</p>
|
|
408
|
+
)}
|
|
409
|
+
|
|
410
|
+
{expanded && (
|
|
411
|
+
<div className="mt-2">
|
|
412
|
+
{replies.map((r) => (
|
|
413
|
+
<div key={r.id} className="mt-2 border-l-2 pl-2.5" style={{ borderColor: pal.border }}>
|
|
414
|
+
<div className="flex items-baseline gap-1.5">
|
|
415
|
+
<span className="truncate text-[11px] font-medium" style={{ color: pal.text }}>
|
|
416
|
+
{r.author}
|
|
417
|
+
</span>
|
|
418
|
+
<span className="shrink-0 text-[10px]" style={{ color: pal.sub }}>
|
|
419
|
+
{fmtTime(r.createdAt)}
|
|
420
|
+
</span>
|
|
421
|
+
</div>
|
|
422
|
+
<p className="mt-1 whitespace-pre-wrap text-[12px] leading-relaxed" style={{ color: pal.text }}>
|
|
423
|
+
{r.text}
|
|
424
|
+
</p>
|
|
425
|
+
</div>
|
|
426
|
+
))}
|
|
427
|
+
|
|
428
|
+
{onReply && (
|
|
429
|
+
<div className="mt-2 flex items-end gap-1.5">
|
|
430
|
+
<textarea
|
|
431
|
+
value={replyDraft}
|
|
432
|
+
onChange={(e) => setReplyDraft(e.target.value)}
|
|
433
|
+
onKeyDown={(e) => {
|
|
434
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
435
|
+
e.preventDefault();
|
|
436
|
+
void sendReply();
|
|
437
|
+
}
|
|
438
|
+
}}
|
|
439
|
+
rows={2}
|
|
440
|
+
placeholder="返信… (⌘+Enterで送信)"
|
|
441
|
+
className="min-h-[40px] flex-1 resize-none rounded border p-1.5 text-[12px] outline-none"
|
|
442
|
+
style={{ backgroundColor: pal.chrome, borderColor: pal.border, color: pal.text }}
|
|
443
|
+
/>
|
|
444
|
+
<button
|
|
445
|
+
onClick={() => void sendReply()}
|
|
446
|
+
disabled={busy || !replyDraft.trim()}
|
|
447
|
+
title="返信を送信"
|
|
448
|
+
className="rounded p-1.5 disabled:opacity-40"
|
|
449
|
+
style={{ color: accent }}
|
|
450
|
+
>
|
|
451
|
+
<Send className="h-4 w-4" />
|
|
452
|
+
</button>
|
|
453
|
+
</div>
|
|
454
|
+
)}
|
|
455
|
+
</div>
|
|
456
|
+
)}
|
|
457
|
+
|
|
458
|
+
{(onResolve || onReply || onDelete) && (
|
|
459
|
+
<div className="mt-2 flex items-center gap-1 border-t pt-1.5" style={{ borderColor: pal.border }}>
|
|
460
|
+
{onResolve && (
|
|
461
|
+
<button
|
|
462
|
+
onClick={() => void onResolve(page, comment.id, open)}
|
|
463
|
+
disabled={busy}
|
|
464
|
+
title={open ? '解決済みにする' : 'スレッドを再開'}
|
|
465
|
+
className="flex items-center gap-1 rounded px-1 py-0.5 text-[11px] disabled:opacity-40"
|
|
466
|
+
style={{ color: pal.sub }}
|
|
467
|
+
>
|
|
468
|
+
{open ? <Check className="h-3.5 w-3.5" /> : <RotateCcw className="h-3.5 w-3.5" />}
|
|
469
|
+
{open ? '解決' : '再開'}
|
|
470
|
+
</button>
|
|
471
|
+
)}
|
|
472
|
+
|
|
473
|
+
{onReply && !expanded && (
|
|
474
|
+
<button
|
|
475
|
+
onClick={() => setExpanded(true)}
|
|
476
|
+
className="flex items-center gap-1 rounded px-1 py-0.5 text-[11px]"
|
|
477
|
+
style={{ color: accent }}
|
|
478
|
+
>
|
|
479
|
+
<CornerUpLeft className="h-3 w-3" />
|
|
480
|
+
返信
|
|
481
|
+
</button>
|
|
482
|
+
)}
|
|
483
|
+
|
|
484
|
+
{onDelete && (
|
|
485
|
+
<button
|
|
486
|
+
onClick={() => void onDelete(page, comment.id)}
|
|
487
|
+
disabled={busy}
|
|
488
|
+
title="スレッドを削除"
|
|
489
|
+
className="ml-auto rounded p-1 disabled:opacity-40"
|
|
490
|
+
style={{ color: pal.sub }}
|
|
491
|
+
>
|
|
492
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
493
|
+
</button>
|
|
494
|
+
)}
|
|
495
|
+
</div>
|
|
496
|
+
)}
|
|
497
|
+
</div>
|
|
498
|
+
);
|
|
499
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* コメントボードの配線済み版。props なしで置ける。
|
|
5
|
+
*
|
|
6
|
+
* <div className="h-screen"><CommentBoardPanel /></div>
|
|
7
|
+
*
|
|
8
|
+
* 取得は useDeck()、更新は commentAction() で、右パネル(PptComments)と同じ道を通る。
|
|
9
|
+
* 独自のAPIは持たないので、利用側は setEditorIO を渡してあれば何もしなくてよい。
|
|
10
|
+
* 名前も右パネルと同じ置き場(localStorage)を使うので、どちらで入れても引き継がれる。
|
|
11
|
+
*
|
|
12
|
+
* 見た目は CommentBoard(純UI)が持つ。ここは「置き場との配線」だけを持つ。
|
|
13
|
+
* エディタの外に単独で置ける(EditorProvider は要らない)。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useCallback, useState } from 'react';
|
|
17
|
+
import { useDeck, refreshDeck } from '../../../components/viewer/useDeck';
|
|
18
|
+
import { commentAction } from '../../../lib/deck';
|
|
19
|
+
import { can } from '../../../io';
|
|
20
|
+
import { flushAutoSave } from '../../autosave';
|
|
21
|
+
import type { PptTheme } from '../ppt/PptChrome';
|
|
22
|
+
import { loadAuthor, storeAuthor } from '../ppt/PptComments';
|
|
23
|
+
import { CommentBoard } from './CommentBoard';
|
|
24
|
+
|
|
25
|
+
export type CommentBoardPanelProps = {
|
|
26
|
+
/** 配色。既定は light(サーバー描画でずれないよう固定値にしている) */
|
|
27
|
+
theme?: PptTheme;
|
|
28
|
+
/**
|
|
29
|
+
* 列ヘッダーの「このページを開く」。
|
|
30
|
+
* 既定はエディタのページ遷移(#/edit/N)。
|
|
31
|
+
* 独自のルーティングを持つ利用側(例: /edit?path=…)はここで差し替える。
|
|
32
|
+
*/
|
|
33
|
+
onOpenPage?: (page: number) => void;
|
|
34
|
+
/** 外枠に足すclass。高さは親が持つ前提 */
|
|
35
|
+
className?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function CommentBoardPanel({ theme = 'light', onOpenPage, className }: CommentBoardPanelProps) {
|
|
39
|
+
const deck = useDeck();
|
|
40
|
+
/** 置き場が commentAction を渡していなければ、操作UIは出さず閲覧だけにする */
|
|
41
|
+
const editable = can('commentAction');
|
|
42
|
+
|
|
43
|
+
const [author, setAuthor] = useState(loadAuthor);
|
|
44
|
+
const [busy, setBusy] = useState(false);
|
|
45
|
+
|
|
46
|
+
const saveAuthor = useCallback((value: string) => {
|
|
47
|
+
setAuthor(value);
|
|
48
|
+
storeAuthor(value);
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* コメント操作の共通処理(右パネルの run と同じ形)。
|
|
53
|
+
* 違いは戻り値だけ —— 成否を返し、成功したときだけ入力欄を空にする。
|
|
54
|
+
* 失敗しても消えると、書いた返信が飛んで戻せない。
|
|
55
|
+
*/
|
|
56
|
+
const run = useCallback(
|
|
57
|
+
async (fn: () => Promise<unknown>): Promise<boolean> => {
|
|
58
|
+
if (busy) return false;
|
|
59
|
+
setBusy(true);
|
|
60
|
+
try {
|
|
61
|
+
await fn();
|
|
62
|
+
await refreshDeck();
|
|
63
|
+
return true;
|
|
64
|
+
} catch (e) {
|
|
65
|
+
window.alert(`コメント操作に失敗しました: ${String(e).slice(0, 120)}`);
|
|
66
|
+
return false;
|
|
67
|
+
} finally {
|
|
68
|
+
setBusy(false);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
[busy],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const handleResolve = useCallback(
|
|
75
|
+
(page: number, commentId: string, resolved: boolean) =>
|
|
76
|
+
run(() => commentAction(page, { action: 'resolve', commentId, resolved })),
|
|
77
|
+
[run],
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const handleReply = useCallback(
|
|
81
|
+
(page: number, commentId: string, text: string) =>
|
|
82
|
+
run(() =>
|
|
83
|
+
commentAction(page, {
|
|
84
|
+
action: 'reply',
|
|
85
|
+
commentId,
|
|
86
|
+
author: author.trim() || 'ゲスト',
|
|
87
|
+
text,
|
|
88
|
+
}),
|
|
89
|
+
),
|
|
90
|
+
[run, author],
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const handleDelete = useCallback(
|
|
94
|
+
async (page: number, commentId: string) => {
|
|
95
|
+
if (!window.confirm('このコメントスレッドを削除しますか?')) return false;
|
|
96
|
+
return run(() => commentAction(page, { action: 'delete', commentId }));
|
|
97
|
+
},
|
|
98
|
+
[run],
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const handleOpenPage = useCallback(
|
|
102
|
+
async (page: number) => {
|
|
103
|
+
if (onOpenPage) {
|
|
104
|
+
onOpenPage(page);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// 既定はサムネイル(PptThumbnails)と同じ導線。
|
|
108
|
+
// 未保存があれば保存してから移り、保存できなかったときだけ確認に落とす
|
|
109
|
+
if (!(await flushAutoSave())) {
|
|
110
|
+
if (!window.confirm('保存に失敗しました。変更を破棄して移動しますか?')) return;
|
|
111
|
+
}
|
|
112
|
+
window.location.hash = `#/edit/${page}`;
|
|
113
|
+
},
|
|
114
|
+
[onOpenPage],
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<CommentBoard
|
|
119
|
+
deck={deck}
|
|
120
|
+
loading={!deck.loaded}
|
|
121
|
+
busy={busy}
|
|
122
|
+
author={author}
|
|
123
|
+
theme={theme}
|
|
124
|
+
className={className}
|
|
125
|
+
onOpenPage={(page) => void handleOpenPage(page)}
|
|
126
|
+
/* 操作は commentAction がある場合だけ渡す。無ければUIごと出ない */
|
|
127
|
+
onAuthorChange={editable ? saveAuthor : undefined}
|
|
128
|
+
onResolve={editable ? handleResolve : undefined}
|
|
129
|
+
onReply={editable ? handleReply : undefined}
|
|
130
|
+
onDelete={editable ? handleDelete : undefined}
|
|
131
|
+
/>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -104,12 +104,19 @@ export function loadAuthor(): string {
|
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/** 名前を記憶する。右パネルとコメントボードで同じ置き場を使う */
|
|
108
|
+
export function storeAuthor(value: string): void {
|
|
109
|
+
try {
|
|
110
|
+
localStorage.setItem(AUTHOR_KEY, value);
|
|
111
|
+
} catch { /* 記憶できなくても続行 */ }
|
|
112
|
+
}
|
|
113
|
+
|
|
107
114
|
/** 未解決コメント数(サムネイルのバッジ用) */
|
|
108
115
|
export function unresolvedCount(comments?: SlideComment[]): number {
|
|
109
116
|
return (comments ?? []).filter((c) => !c.resolved).length;
|
|
110
117
|
}
|
|
111
118
|
|
|
112
|
-
const fmtTime = (iso: string) => {
|
|
119
|
+
export const fmtTime = (iso: string) => {
|
|
113
120
|
const d = new Date(iso);
|
|
114
121
|
if (Number.isNaN(d.getTime())) return '';
|
|
115
122
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
@@ -253,9 +260,7 @@ export function PptCommentsPanel({
|
|
|
253
260
|
|
|
254
261
|
const saveAuthor = (v: string) => {
|
|
255
262
|
setAuthor(v);
|
|
256
|
-
|
|
257
|
-
localStorage.setItem(AUTHOR_KEY, v);
|
|
258
|
-
} catch { /* 記憶できなくても続行 */ }
|
|
263
|
+
storeAuthor(v);
|
|
259
264
|
};
|
|
260
265
|
|
|
261
266
|
/**
|