mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat(web): add collapsible comment side panel (#1607)
This commit is contained in:
parent
a7bebd926f
commit
2a8ebff11a
3 changed files with 201 additions and 2 deletions
|
|
@ -1993,9 +1993,11 @@ function commentAvatarInitial(comment: PreviewComment): string {
|
|||
return seed.charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
function CommentSidePanel({
|
||||
export function CommentSidePanel({
|
||||
comments,
|
||||
selectedIds,
|
||||
collapsed,
|
||||
onCollapsedChange,
|
||||
onToggleSelect,
|
||||
onClearSelection,
|
||||
onReply,
|
||||
|
|
@ -2005,6 +2007,8 @@ function CommentSidePanel({
|
|||
}: {
|
||||
comments: PreviewComment[];
|
||||
selectedIds: Set<string>;
|
||||
collapsed: boolean;
|
||||
onCollapsedChange: (collapsed: boolean) => void;
|
||||
onToggleSelect: (commentId: string) => void;
|
||||
onClearSelection: () => void;
|
||||
onReply: (comment: PreviewComment) => void;
|
||||
|
|
@ -2015,8 +2019,41 @@ function CommentSidePanel({
|
|||
const sorted = [...comments].sort((a, b) => b.createdAt - a.createdAt);
|
||||
const visibleSelectedIds = new Set(comments.filter((comment) => selectedIds.has(comment.id)).map((comment) => comment.id));
|
||||
const selectedCount = visibleSelectedIds.size;
|
||||
const commentsLabel = t('chat.tabComments');
|
||||
if (collapsed) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="comment-side-rail"
|
||||
data-testid="comment-side-collapsed-rail"
|
||||
aria-label={t('preview.showSidebar', { label: commentsLabel })}
|
||||
title={t('preview.showSidebar', { label: commentsLabel })}
|
||||
onClick={() => onCollapsedChange(false)}
|
||||
>
|
||||
<Icon name="comment" size={14} />
|
||||
<span>{commentsLabel}</span>
|
||||
{comments.length > 0 ? <strong>{comments.length}</strong> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="comment-side-panel" data-testid="comment-side-panel" aria-label={t('chat.tabComments')}>
|
||||
<aside className="comment-side-panel" data-testid="comment-side-panel" aria-label={commentsLabel}>
|
||||
<div className="comment-side-header">
|
||||
<div className="comment-side-title">
|
||||
<Icon name="comment" size={14} />
|
||||
<span>{commentsLabel}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="comment-side-collapse"
|
||||
aria-label={t('preview.hideSidebar', { label: commentsLabel })}
|
||||
title={t('preview.hideSidebar', { label: commentsLabel })}
|
||||
onClick={() => onCollapsedChange(true)}
|
||||
>
|
||||
<Icon name="chevron-right" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="comment-side-list">
|
||||
{sorted.length === 0 ? (
|
||||
<div className="comment-side-empty">
|
||||
|
|
@ -3672,6 +3709,7 @@ function HtmlViewer({
|
|||
const [sendingBoardBatch, setSendingBoardBatch] = useState(false);
|
||||
const [commentSavedToast, setCommentSavedToast] = useState<string | null>(null);
|
||||
const [selectedSideCommentIds, setSelectedSideCommentIds] = useState<Set<string>>(() => new Set());
|
||||
const [commentSidePanelCollapsed, setCommentSidePanelCollapsed] = useState(false);
|
||||
const [strokePoints, setStrokePoints] = useState<StrokePoint[]>([]);
|
||||
const previewStateKey = `${projectId}:${file.name}`;
|
||||
const previewScale = zoom / 100;
|
||||
|
|
@ -5721,6 +5759,8 @@ function HtmlViewer({
|
|||
<CommentSidePanel
|
||||
comments={visibleSideComments}
|
||||
selectedIds={selectedSideCommentIds}
|
||||
collapsed={commentSidePanelCollapsed}
|
||||
onCollapsedChange={setCommentSidePanelCollapsed}
|
||||
onToggleSelect={(commentId) => {
|
||||
setSelectedSideCommentIds((current) => {
|
||||
const next = new Set(current);
|
||||
|
|
|
|||
|
|
@ -9714,6 +9714,93 @@ button.connector-action.is-loading {
|
|||
z-index: 30;
|
||||
overflow: hidden;
|
||||
}
|
||||
.comment-side-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-panel, #fff);
|
||||
}
|
||||
.comment-side-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 7px;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.comment-side-title span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.comment-side-collapse {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.comment-side-collapse:hover {
|
||||
background: var(--bg-subtle);
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
.comment-side-rail {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
z-index: 30;
|
||||
width: 42px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-panel, #fff);
|
||||
color: var(--text-muted);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||
cursor: pointer;
|
||||
}
|
||||
.comment-side-rail:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--border-strong);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
.comment-side-rail span {
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.comment-side-rail strong {
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: #ff5a3c;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.comment-side-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { useState } from 'react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
|
@ -21,6 +22,7 @@ vi.mock('../../src/state/projects', async () => {
|
|||
});
|
||||
|
||||
import {
|
||||
CommentSidePanel,
|
||||
FileViewer,
|
||||
LiveArtifactViewer,
|
||||
LiveArtifactRefreshHistoryPanel,
|
||||
|
|
@ -34,6 +36,7 @@ import {
|
|||
import type { InspectOverrideMap } from '../../src/components/FileViewer';
|
||||
import type { LiveArtifact, LiveArtifactWorkspaceEntry, ProjectFile } from '../../src/types';
|
||||
import { I18nProvider } from '../../src/i18n';
|
||||
import type { Dict } from '../../src/i18n/types';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
|
|
@ -970,6 +973,17 @@ describe('FileViewer SVG artifacts', () => {
|
|||
});
|
||||
|
||||
describe('FileViewer tweaks toolbar', () => {
|
||||
const t = (key: keyof Dict) => {
|
||||
const labels: Partial<Record<keyof Dict, string>> = {
|
||||
'chat.tabComments': 'Comments',
|
||||
'chat.comments.emptySaved': 'No saved comments.',
|
||||
'common.close': 'Close',
|
||||
'preview.showSidebar': 'Show Comments',
|
||||
'preview.hideSidebar': 'Hide Comments',
|
||||
};
|
||||
return labels[key] ?? key;
|
||||
};
|
||||
|
||||
function htmlPreviewFile(): ProjectFile {
|
||||
return baseFile({
|
||||
name: 'preview.html',
|
||||
|
|
@ -1066,6 +1080,64 @@ describe('FileViewer tweaks toolbar', () => {
|
|||
expect(screen.queryByRole('button', { name: 'Send' })).toBeNull();
|
||||
expect(screen.queryByText('Queues while working')).toBeNull();
|
||||
});
|
||||
|
||||
it('collapses the comment side panel into a narrow reopen rail', () => {
|
||||
const onCollapseChange = vi.fn();
|
||||
|
||||
function Harness() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
return (
|
||||
<CommentSidePanel
|
||||
comments={[
|
||||
{
|
||||
id: 'comment-1',
|
||||
projectId: 'project-1',
|
||||
conversationId: 'conversation-1',
|
||||
filePath: 'preview.html',
|
||||
elementId: 'button.sso-btn',
|
||||
selector: '[data-od-id="button.sso-btn"]',
|
||||
label: 'button.sso-btn',
|
||||
text: 'GitHub',
|
||||
htmlHint: '<button>GitHub</button>',
|
||||
position: { x: 16, y: 24, width: 160, height: 48 },
|
||||
note: '不要github,换成微信',
|
||||
status: 'open',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
]}
|
||||
selectedIds={new Set(['comment-1'])}
|
||||
collapsed={collapsed}
|
||||
onCollapsedChange={(next) => {
|
||||
onCollapseChange(next);
|
||||
setCollapsed(next);
|
||||
}}
|
||||
onToggleSelect={() => {}}
|
||||
onClearSelection={() => {}}
|
||||
onReply={() => {}}
|
||||
onSendSelected={() => {}}
|
||||
sending={false}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
|
||||
expect(screen.getByTestId('comment-side-panel')).toBeTruthy();
|
||||
expect(screen.getByText('不要github,换成微信')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /hide comments/i }));
|
||||
|
||||
expect(onCollapseChange).toHaveBeenLastCalledWith(true);
|
||||
expect(screen.queryByText('不要github,换成微信')).toBeNull();
|
||||
expect(screen.queryByTestId('comment-side-selectbar')).toBeNull();
|
||||
expect(screen.getByTestId('comment-side-collapsed-rail')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /show comments/i }));
|
||||
|
||||
expect(onCollapseChange).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyInspectOverridesToSource', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue