feat: refine notes workspace and add canvas zoom
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
Simplify sidebar selection and note record framing, compact the empty composer, and emphasize note titles. Replace redundant AI comment headings with an accessible panel toggle. Add view-only canvas zoom and fit width, correct PDF import/export icons, and allow image files up to 20 MiB. Include regression coverage and updated design documentation. Release note: Notes have clearer navigation and a more compact writing area. Canvases support zoom and fit width, and image imports accept files up to 20 MiB.
This commit is contained in:
@@ -219,6 +219,22 @@ describe('production preload -> registered IPC -> SQLite canvas persistence', ()
|
||||
expect(await api.get(note.id)).toEqual(saved)
|
||||
})
|
||||
|
||||
it('saves and reopens a 20 MiB canvas image through create and update IPC', async () => {
|
||||
const content = canvas()
|
||||
const bytes = Buffer.alloc(20 * 1024 * 1024)
|
||||
Buffer.from(png.split(',')[1]!, 'base64').copy(bytes)
|
||||
content.assets[0]!.dataUrl = `data:image/png;base64,${bytes.toString('base64')}`
|
||||
const note = await api.create({ title: 'Large canvas image' })
|
||||
const created = await api.createEntry({ noteId: note.id, content })
|
||||
await reopen()
|
||||
const entry = (await api.get(note.id)).entries.find(item => item.id === created.createdEntryId)!
|
||||
expect(entry.content).toEqual(content)
|
||||
content.flow!.ops[0] = { insert: 'Edited large image note' }
|
||||
await api.updateEntry({ entryId: entry.id, expectedRevision: entry.revision, content })
|
||||
await reopen()
|
||||
expect((await api.get(note.id)).entries.find(item => item.id === entry.id)!.content).toEqual(content)
|
||||
})
|
||||
|
||||
it('preserves a real generated PDF and all page references through three reopen/save cycles', async () => {
|
||||
const { jsPDF } = await import('jspdf')
|
||||
const pdf = new jsPDF()
|
||||
|
||||
@@ -387,6 +387,9 @@ describe('MagicNotesWorkspace overview navigation', () => {
|
||||
const card = screen.getByRole('button', { name: /发布笔记.*条记录/ })
|
||||
await openNote()
|
||||
expect(overview).not.toBeVisible()
|
||||
expect(screen.getByRole('heading', { level: 1, name: detail.title })).toBeVisible()
|
||||
expect(document.querySelector('.page-header__description')).toBeNull()
|
||||
expect(screen.getByRole('textbox', { name: '笔记标题' })).toHaveValue(detail.title)
|
||||
expect(screen.queryByRole('button', { name: '切换到待办' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '新建笔记' })).not.toBeInTheDocument()
|
||||
@@ -398,6 +401,7 @@ describe('MagicNotesWorkspace overview navigation', () => {
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '返回总览' })).toHaveFocus())
|
||||
back()
|
||||
expect(overview).toBeVisible()
|
||||
expect(screen.getByRole('heading', { level: 1, name: '魔法笔记' })).toBeVisible()
|
||||
expect(search).toHaveValue('发布')
|
||||
expect(overview.scrollTop).toBe(180)
|
||||
expect(screen.queryByRole('region', { name: '笔记记录' })).not.toBeInTheDocument()
|
||||
@@ -802,11 +806,25 @@ describe('MagicNotesWorkspace detail behavior', () => {
|
||||
const view = render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
showAi()
|
||||
const toggle = screen.getByRole('button', { name: '隐藏 AI 评论', expanded: true })
|
||||
expect(toggle).toHaveClass('icon-button', 'icon-button--active')
|
||||
expect(toggle).toHaveAttribute('title', '隐藏 AI 评论')
|
||||
expect(toggle).toHaveAttribute('aria-controls', 'magic-notes-ai-pane')
|
||||
expect(toggle.textContent).toBe('')
|
||||
const pane = screen.getByRole('complementary', { name: 'AI 评论' })
|
||||
expect(pane).toHaveAttribute('id', toggle.getAttribute('aria-controls'))
|
||||
expect(within(pane).queryByText('AI 评论', { exact: true })).not.toBeInTheDocument()
|
||||
expect(pane.querySelector('.magic-notes-pane-heading')).toBeNull()
|
||||
expect(screen.queryByRole('group', { name: 'AI 评论形式' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('combobox', { name: 'AI 评论方向' })).toBeVisible()
|
||||
fireEvent.keyDown(screen.getByRole('separator', { name: '调整编辑区与 AI 评论宽度' }), { key: 'End' })
|
||||
const width = screen.getByRole('separator', { name: '调整编辑区与 AI 评论宽度' }).getAttribute('aria-valuenow')
|
||||
fireEvent.click(screen.getAllByRole('button', { name: '隐藏 AI 评论' })[0]!)
|
||||
toggle.focus()
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle).toHaveFocus()
|
||||
expect(screen.getByRole('button', { name: '显示 AI 评论', expanded: false })).toBe(toggle)
|
||||
expect(toggle).toHaveAttribute('title', '显示 AI 评论')
|
||||
expect(toggle).not.toHaveClass('icon-button--active')
|
||||
expect(screen.queryByRole('complementary', { name: 'AI 评论' })).not.toBeInTheDocument()
|
||||
back()
|
||||
await openNote()
|
||||
@@ -845,6 +863,13 @@ describe('MagicNotesWorkspace detail behavior', () => {
|
||||
await openNote()
|
||||
expect(screen.getByRole('separator')).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(screen.getByRole('separator')).toHaveAttribute('tabindex', '-1')
|
||||
const toggle = screen.getByRole('button', { name: '隐藏 AI 评论', expanded: true })
|
||||
fireEvent.click(toggle)
|
||||
expect(screen.queryByRole('complementary', { name: 'AI 评论' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '显示 AI 评论', expanded: false })).toBe(toggle)
|
||||
fireEvent.click(toggle)
|
||||
expect(screen.getByRole('complementary', { name: 'AI 评论' })).toBeVisible()
|
||||
expect(screen.getByRole('combobox', { name: 'AI 评论方向' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('resizes the record column, clamps against AI, and preserves preferences across hidden and narrow layouts', async () => {
|
||||
@@ -1158,7 +1183,7 @@ describe('MagicNotesWorkspace canvas integration', () => {
|
||||
expect(document.querySelector('.magic-notes-layout')).toHaveClass('magic-notes-layout--index-hidden')
|
||||
expect(screen.queryByRole('navigation', { name: '记录' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('complementary', { name: 'AI 评论' })).toBeVisible()
|
||||
fireEvent.click(screen.getAllByRole('button', { name: '隐藏 AI 评论' })[0]!)
|
||||
fireEvent.click(screen.getByRole('button', { name: '隐藏 AI 评论' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '展开记录索引' }))
|
||||
expect(screen.getByRole('navigation', { name: '记录' })).toBeVisible()
|
||||
expect(screen.queryByRole('complementary', { name: 'AI 评论' })).not.toBeInTheDocument()
|
||||
|
||||
@@ -1946,8 +1946,10 @@ export function MagicNotesWorkspace({
|
||||
<button
|
||||
aria-controls="magic-notes-ai-pane"
|
||||
aria-expanded={aiPaneOpen}
|
||||
className="secondary-button"
|
||||
aria-label={t(aiPaneOpen ? 'actions.hideAiComments' : 'actions.showAiComments')}
|
||||
className={`icon-button${aiPaneOpen ? ' icon-button--active' : ''}`}
|
||||
onClick={() => setAiPaneOpen((current) => !current)}
|
||||
title={t(aiPaneOpen ? 'actions.hideAiComments' : 'actions.showAiComments')}
|
||||
type="button"
|
||||
>
|
||||
{aiPaneOpen ? (
|
||||
@@ -1955,11 +1957,6 @@ export function MagicNotesWorkspace({
|
||||
) : (
|
||||
<PanelRightOpen aria-hidden="true" size={15} />
|
||||
)}
|
||||
{t(
|
||||
aiPaneOpen
|
||||
? 'actions.hideAiComments'
|
||||
: 'actions.showAiComments'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{!detailView && (
|
||||
@@ -1992,11 +1989,11 @@ export function MagicNotesWorkspace({
|
||||
)}
|
||||
</>
|
||||
}
|
||||
description={t('page.description')}
|
||||
description={detailView ? undefined : t('page.description')}
|
||||
headingId="magic-notes-title"
|
||||
icon={<Sparkles size={20} />}
|
||||
scope={{ kind: 'global' }}
|
||||
title={t('page.title')}
|
||||
title={detailView && detail ? detail.title : t('page.title')}
|
||||
/>
|
||||
|
||||
{loadStatus === 'error' ? (
|
||||
@@ -2882,18 +2879,6 @@ export function MagicNotesWorkspace({
|
||||
hidden={!aiPaneOpen}
|
||||
id="magic-notes-ai-pane"
|
||||
>
|
||||
<div className="magic-notes-pane-heading">
|
||||
<strong>{t('comments.paneLabel')}</strong>
|
||||
<button
|
||||
aria-label={t('actions.hideAiComments')}
|
||||
className="icon-button"
|
||||
onClick={() => setAiPaneOpen(false)}
|
||||
title={t('actions.hideAiComments')}
|
||||
type="button"
|
||||
>
|
||||
<PanelRightClose aria-hidden="true" size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="magic-notes-ai-controls">
|
||||
<label>
|
||||
<span>{t('comments.directionLabel')}</span>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
background: var(--surface-subtle);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
.magic-canvas-editor .canvas-note-tool-group, .magic-canvas-editor .canvas-note-page-controls {
|
||||
.magic-canvas-editor .canvas-note-tool-group, .magic-canvas-editor .canvas-note-page-controls, .magic-canvas-editor .canvas-note-zoom-controls {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
@@ -37,6 +37,8 @@
|
||||
}
|
||||
.magic-canvas-editor .canvas-note-tool-group { border-right: 1px solid var(--border-subtle); padding-right: var(--space-1); }
|
||||
.magic-canvas-editor .canvas-note-page-controls { margin-left: auto; }
|
||||
.magic-canvas-editor .canvas-note-zoom-controls { flex-wrap: nowrap; }
|
||||
.magic-canvas-editor .canvas-note-zoom-reset { min-width: 56px; font-variant-numeric: tabular-nums; }
|
||||
.magic-canvas-editor .canvas-note-button, .magic-canvas-editor .canvas-note-toolbar select, .magic-canvas-editor .canvas-note-color, .magic-canvas-open {
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
@@ -61,6 +63,7 @@
|
||||
.magic-canvas-editor :focus-visible, .magic-canvas-open:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.magic-canvas-editor .canvas-note-viewport { min-width: 0; min-height: 0; flex: 0 0 auto; overflow: auto; padding: var(--space-4); background: var(--surface-canvas); }
|
||||
.magic-canvas-editor .canvas-note-page { position: relative; margin: 0 auto; flex-shrink: 0; overflow: hidden; box-shadow: var(--shadow-card); }
|
||||
.magic-canvas-editor .canvas-note-page-surface { position: absolute; top: 0; left: 0; transform-origin: top left; }
|
||||
.magic-canvas-editor .canvas-note-background { z-index: 0; display: block; }
|
||||
.magic-canvas-editor .canvas-note-fabric-container { z-index: 2; }
|
||||
.magic-canvas-editor .canvas-note-fabric-container canvas { display: block; }
|
||||
@@ -83,6 +86,8 @@
|
||||
.magic-canvas-editor .canvas-flow-toolbar .ql-picker-options { color: var(--text-primary); background: var(--surface-raised); border-color: var(--border-control); box-shadow: var(--shadow-card); }
|
||||
.magic-canvas-editor .canvas-flow-toolbar button.ql-active .ql-stroke { stroke: var(--accent); }
|
||||
.magic-canvas-content .canvas-note-tool-group, .magic-canvas-content .canvas-note-add-page, .magic-canvas-content .canvas-note-delete-page { display: none; }
|
||||
.magic-note-entry > .magic-canvas-content .canvas-note-root { border: 0; }
|
||||
.magic-note-entry > .magic-canvas-content .canvas-note-toolbar { background: transparent; border-bottom: 0; }
|
||||
.magic-canvas-open { align-self: flex-end; margin-bottom: var(--space-2); padding-inline: var(--space-3); }
|
||||
@media (max-width: 720px) {
|
||||
.magic-canvas-editor .canvas-note-page-controls { flex-basis: 100%; justify-content: flex-end; }
|
||||
|
||||
@@ -63,7 +63,7 @@ type MagicCanvasContentProps = {
|
||||
}
|
||||
```
|
||||
|
||||
The viewer supports previous/next page. When `onEdit` is supplied, it displays
|
||||
The viewer supports previous/next page and the same zoom controls as the editor. When `onEdit` is supplied, it displays
|
||||
an edit action for the workspace to open the editor. Passing a new content
|
||||
reference refreshes the viewer. It does not own a modal or persistence.
|
||||
|
||||
@@ -102,12 +102,15 @@ following limits on input and serialization.
|
||||
| Flow text | 20,000 UTF-16 code units (`string.length`), excluding exactly one terminal newline. Internal newlines count; page-break embeds do not. |
|
||||
| Flow operations | 5,000 input Delta operations, including newline and page-break operations. |
|
||||
| Pages | 50, including automatic flow pages and PDF pages. Initial page normalization currently keeps the first 50. Flow beyond 50 pages reports an error. |
|
||||
| Images | 2 MiB per JPEG/PNG/GIF/WebP image. |
|
||||
| Images | 20 MiB (`20 * 1024 * 1024` bytes) per JPEG/PNG/GIF/WebP file, inclusive. The same derived Data URL length bound applies when restoring image objects. |
|
||||
| PDF file input | 32 MiB per input file; imported pages also count toward the 50-page limit. |
|
||||
| Page dimensions | Positive dimensions at most 3,000 per side, rounded to integers; invalid dimensions fall back to 794 by 1123 defaults per axis. |
|
||||
| List indent | Integer levels 1 through 8, or omitted for level zero. |
|
||||
|
||||
Input validation and serialization use the same flow text/operation checks.
|
||||
Canvas assets do not use the rich-text image or aggregate attachment budgets.
|
||||
The shared canvas schema and Main validation accept this image size; persistence
|
||||
stores decoded assets in files and hydrates them on read without a smaller byte cap.
|
||||
Exactly 20,000 body code units plus Quill's terminal newline round-trip intact.
|
||||
Oversized user edits revert to the preceding Delta and call `onError`.
|
||||
Oversized initial/API content throws a `RangeError`; `flush()` rejects rather
|
||||
@@ -130,6 +133,29 @@ saved viewers grow naturally to the current page's displayed height plus their
|
||||
toolbars and padding; vertical scrolling belongs to the surrounding note stream.
|
||||
Pagination replaces the current page within the same entry, including its height.
|
||||
|
||||
### View Zoom
|
||||
|
||||
Editor and viewer toolbars provide minus/plus (25 percentage points), a current
|
||||
percentage button that resets to 100%, and Fit width. The range is 25%-300%, with
|
||||
100% as the initial value. Ctrl/Command + wheel continuously zooms over the paper;
|
||||
ordinary wheel and Shift horizontal scrolling retain their existing behavior.
|
||||
Fit width follows the actual viewport's width minus its computed padding, within
|
||||
the same range. Resizing the window or a workspace column updates it automatically.
|
||||
Page navigation preserves the selected fixed scale or fit policy. Remounting a
|
||||
document starts at 100%; the view preference is not persisted.
|
||||
|
||||
`canvas-note-page` is the displayed-size layout box. Its absolutely positioned
|
||||
logical-size surface applies one CSS scale to the PDF/template, Fabric and Quill
|
||||
layers. Fabric's existing client-to-canvas coordinate conversion handles pointer
|
||||
input; Quill column and selection measurements divide screen coordinates by the
|
||||
display scale. Page dimensions, object coordinates, flow widths and export
|
||||
canvases remain logical. Zoom never enters the model, history or `onChange`.
|
||||
The width observer defers updates to the next animation frame and is disconnected,
|
||||
with any pending frame cancelled, on destroy. Flow pagination does not reset an
|
||||
unchanged background bitmap's dimensions, which would clear the rendered PDF.
|
||||
|
||||
The UI contract is in [UI-DESIGN.md](../../../../UI-DESIGN.md#137-魔法笔记).
|
||||
|
||||
## Lifecycle And Validation
|
||||
|
||||
The controller is returned synchronously so React cleanup can immediately abort
|
||||
|
||||
@@ -218,11 +218,12 @@ export function mountFlowText(layerHost, toolbarHost, initialContent, options =
|
||||
|
||||
function pageCount() {
|
||||
const rootRect = surface.getBoundingClientRect();
|
||||
const scale = rootRect.width / layout.width || 1;
|
||||
const span = layout.width + layout.gap;
|
||||
let maxColumn = 0;
|
||||
for (const child of surface.children) {
|
||||
for (const rect of child.getClientRects()) {
|
||||
const relativeLeft = rect.left - rootRect.left;
|
||||
const relativeLeft = (rect.left - rootRect.left) / scale;
|
||||
maxColumn = Math.max(maxColumn, Math.max(0, Math.round(relativeLeft / span)));
|
||||
}
|
||||
}
|
||||
@@ -248,8 +249,9 @@ export function mountFlowText(layerHost, toolbarHost, initialContent, options =
|
||||
if (!range) return layout.pageIndex;
|
||||
const index = Math.min(Math.max(0, range.index), Math.max(0, quill.getLength() - 1));
|
||||
const bounds = quill.getBounds(index, Math.max(0, range.length));
|
||||
const scale = surface.getBoundingClientRect().width / layout.width || 1;
|
||||
const span = layout.width + layout.gap;
|
||||
return Math.max(0, Math.round((Number(bounds?.left) || 0) / span));
|
||||
return Math.max(0, layout.pageIndex + Math.round((Number(bounds?.left) || 0) / scale / span));
|
||||
}
|
||||
|
||||
function followSelection() {
|
||||
|
||||
@@ -50,6 +50,16 @@ function mount(initial) {
|
||||
}
|
||||
|
||||
describe('canvas flow limits', () => {
|
||||
it.each([0.25, 0.5, 2, 3])('measures logical columns and selection at %sx display scale', (scale) => {
|
||||
const { editor, quill } = mount({ version: 1, ops: [{ insert: 'Body\n' }] });
|
||||
editor.setLayout(640, 960, 1);
|
||||
vi.spyOn(quill.root, 'getBoundingClientRect').mockReturnValue({ left: 10, width: 640 * scale });
|
||||
vi.spyOn(quill.root.firstElementChild, 'getClientRects').mockReturnValue([{ left: 10 + 688 * 2 * scale }]);
|
||||
vi.spyOn(quill, 'getSelection').mockReturnValue({ index: 0, length: 0 });
|
||||
vi.spyOn(quill, 'getBounds').mockReturnValue({ left: 688 * scale });
|
||||
expect(editor.pageCount()).toBe(3);
|
||||
expect(editor.activePageIndex()).toBe(2);
|
||||
});
|
||||
it('round trips exactly 20,000 characters with a separate formatted terminal newline', () => {
|
||||
const content = { version: 1, ops: [{ insert: '中'.repeat(20000) }, { insert: '\n', attributes: { list: 'checked', indent: 2 } }] };
|
||||
expect(normalizeFlowContent(content)).toEqual(content);
|
||||
|
||||
@@ -14,11 +14,13 @@ import { mountFlowText, normalizeFlowContent } from './canvas-flow.mjs';
|
||||
const VERSION = 2;
|
||||
const MAX_PAGES = 50;
|
||||
const HISTORY_LIMIT = 50;
|
||||
const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
||||
const MAX_IMAGE_DATA_LENGTH = Math.ceil(MAX_IMAGE_BYTES / 3) * 4 + 128;
|
||||
const DEFAULT_WIDTH = 794;
|
||||
const DEFAULT_HEIGHT = 1123;
|
||||
const MAX_DIMENSION = 3000;
|
||||
const MIN_ZOOM = 0.25;
|
||||
const MAX_ZOOM = 3;
|
||||
const MAX_CANVAS_PIXELS = 24 * 1024 * 1024;
|
||||
const SERIAL_PROPS = ['canvasKind'];
|
||||
const COMMON_OBJECT_KEYS = [
|
||||
@@ -319,6 +321,8 @@ function drawTemplate(context, width, height, template) {
|
||||
}
|
||||
|
||||
const BUTTON_ICONS = Object.freeze({
|
||||
'zoom-out': '<path d="M5 12h14"/>',
|
||||
'zoom-in': '<path d="M5 12h14M12 5v14"/>',
|
||||
'tool-select': '<path d="m5 3 13 9-6 1.5L9 19Z"/><path d="m13 14 4 6"/>',
|
||||
'tool-pen': '<path d="m4 20 4.5-1 10-10a2 2 0 0 0-3-3l-10 10Z"/><path d="m14 7 3 3M4 20l1.5-4"/>',
|
||||
'tool-highlight': '<path d="m7 15 8-11 4 3-8 11H7Z"/><path d="m13 7 4 3M4 20h16"/>',
|
||||
@@ -328,8 +332,8 @@ const BUTTON_ICONS = Object.freeze({
|
||||
'tool-flow-text': '<path d="M4 5h16M8 5v14M5 19h6"/><path d="M14 11h6M14 15h6M14 19h6"/>',
|
||||
undo: '<path d="m9 7-5 5 5 5"/><path d="M5 12h8a6 6 0 0 1 6 6"/>',
|
||||
redo: '<path d="m15 7 5 5-5 5"/><path d="M19 12h-8a6 6 0 0 0-6 6"/>',
|
||||
'import-pdf': '<path d="M6 3h9l4 4v14H6Z"/><path d="M14 3v5h5M12 10v7M9 14l3 3 3-3"/>',
|
||||
'export-pdf': '<path d="M6 3h9l4 4v14H6Z"/><path d="M14 3v5h5M12 18v-7M9 14l3-3 3 3"/>',
|
||||
'import-pdf': '<path d="M6 3h9l4 4v14H6Z"/><path d="M14 3v5h5M12 18v-7M9 14l3-3 3 3"/>',
|
||||
'export-pdf': '<path d="M6 3h9l4 4v14H6Z"/><path d="M14 3v5h5M12 10v7M9 14l3 3 3-3"/>',
|
||||
'previous-page': '<path d="m14 6-6 6 6 6"/>',
|
||||
'next-page': '<path d="m10 6 6 6-6 6"/>',
|
||||
'add-page': '<path d="M6 3h9l4 4v14H6Z"/><path d="M14 3v5h5M9 14h6M12 11v6"/>',
|
||||
@@ -408,6 +412,8 @@ export function mountCanvasNote(host, initialContent, options = {}) {
|
||||
let destroyPromise = null;
|
||||
let suggestedPdfName = 'GoodBuddy-笔记.pdf';
|
||||
let readOnly = Boolean(options.disabled);
|
||||
let zoom = 1;
|
||||
let fitWidth = false;
|
||||
|
||||
const pdfDocuments = new Map();
|
||||
const backgroundCache = new Map();
|
||||
@@ -509,7 +515,16 @@ export function mountCanvasNote(host, initialContent, options = {}) {
|
||||
const addPageButton = makeButton('加页', '添加页面', 'add-page');
|
||||
const deletePageButton = makeButton('删页', '删除当前页面', 'delete-page');
|
||||
pageControls.append(previousButton, pageCounter, nextButton, addPageButton, deletePageButton);
|
||||
toolbar.append(toolGroup, styleGroup, historyGroup, documentGroup, pageControls);
|
||||
const zoomControls = document.createElement('span');
|
||||
zoomControls.className = 'canvas-note-zoom-controls';
|
||||
zoomControls.setAttribute('role', 'group');
|
||||
zoomControls.setAttribute('aria-label', '画布缩放');
|
||||
const zoomOutButton = makeButton('-', '缩小(最低 25%;Ctrl/Command + 滚轮)', 'zoom-out');
|
||||
const zoomResetButton = makeButton('100%', '还原为 100%', 'zoom-reset');
|
||||
const zoomInButton = makeButton('+', '放大(最高 300%;Ctrl/Command + 滚轮)', 'zoom-in');
|
||||
const zoomFitButton = makeButton('适应宽度', '适应宽度(随窗口调整)', 'zoom-fit');
|
||||
zoomControls.append(zoomOutButton, zoomResetButton, zoomInButton, zoomFitButton);
|
||||
toolbar.append(toolGroup, styleGroup, historyGroup, documentGroup, zoomControls, pageControls);
|
||||
|
||||
const imageInput = document.createElement('input');
|
||||
imageInput.type = 'file';
|
||||
@@ -527,6 +542,8 @@ export function mountCanvasNote(host, initialContent, options = {}) {
|
||||
const pageShell = document.createElement('div');
|
||||
pageShell.className = 'canvas-note-page';
|
||||
pageShell.style.position = 'relative';
|
||||
const pageSurface = document.createElement('div');
|
||||
pageSurface.className = 'canvas-note-page-surface';
|
||||
|
||||
const backgroundElement = document.createElement('canvas');
|
||||
backgroundElement.className = 'canvas-note-background';
|
||||
@@ -540,7 +557,8 @@ export function mountCanvasNote(host, initialContent, options = {}) {
|
||||
const flowLayer = document.createElement('div');
|
||||
flowLayer.className = 'canvas-flow-layer';
|
||||
|
||||
pageShell.append(backgroundElement, annotationElement, flowLayer);
|
||||
pageSurface.append(backgroundElement, annotationElement, flowLayer);
|
||||
pageShell.appendChild(pageSurface);
|
||||
viewport.appendChild(pageShell);
|
||||
root.append(toolbar, flowToolbarHost, viewport, imageInput);
|
||||
host.textContent = '';
|
||||
@@ -684,9 +702,14 @@ export function mountCanvasNote(host, initialContent, options = {}) {
|
||||
}
|
||||
if (readOnly) {
|
||||
for (const control of toolbar.querySelectorAll('button, input, select')) {
|
||||
if (control !== previousButton && control !== nextButton && control !== exportButton) control.disabled = true;
|
||||
if (control !== previousButton && control !== nextButton && control !== exportButton
|
||||
&& !zoomControls.contains(control)) control.disabled = true;
|
||||
}
|
||||
}
|
||||
zoomOutButton.disabled = busy || zoom <= MIN_ZOOM;
|
||||
zoomInButton.disabled = busy || zoom >= MAX_ZOOM;
|
||||
zoomResetButton.disabled = busy;
|
||||
zoomFitButton.disabled = busy;
|
||||
}
|
||||
|
||||
function pushHistory(emit = true) {
|
||||
@@ -761,11 +784,39 @@ export function mountCanvasNote(host, initialContent, options = {}) {
|
||||
if (tool === 'flow-text') flowEditor.focus();
|
||||
}
|
||||
|
||||
function applyZoom() {
|
||||
if (destroyed) return;
|
||||
const page = currentPage();
|
||||
if (fitWidth && viewport.clientWidth > 0) {
|
||||
const css = globalThis.getComputedStyle(viewport);
|
||||
const available = viewport.clientWidth - (parseFloat(css.paddingLeft) || 0) - (parseFloat(css.paddingRight) || 0);
|
||||
zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, available / page.width));
|
||||
}
|
||||
// Only the outer box participates in layout; all three layers share logical coordinates.
|
||||
pageShell.style.width = `${page.width * zoom}px`;
|
||||
pageShell.style.height = `${page.height * zoom}px`;
|
||||
pageSurface.style.transform = `scale(${zoom})`;
|
||||
fabricCanvas.calcOffset?.();
|
||||
zoomResetButton.textContent = `${Math.round(zoom * 100)}%`;
|
||||
zoomResetButton.setAttribute('aria-label', `当前缩放 ${Math.round(zoom * 100)}%,还原为 100%`);
|
||||
zoomFitButton.setAttribute('aria-pressed', String(fitWidth));
|
||||
zoomFitButton.classList.toggle('canvas-note-active', fitWidth);
|
||||
updateControls();
|
||||
}
|
||||
|
||||
function setZoom(value, fit = false) {
|
||||
if (destroyed || busy) return;
|
||||
fitWidth = fit;
|
||||
zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value));
|
||||
applyZoom();
|
||||
}
|
||||
|
||||
function setPageDimensions(page) {
|
||||
pageShell.style.width = `${page.width}px`;
|
||||
pageShell.style.height = `${page.height}px`;
|
||||
backgroundElement.width = page.width;
|
||||
backgroundElement.height = page.height;
|
||||
pageSurface.style.width = `${page.width}px`;
|
||||
pageSurface.style.height = `${page.height}px`;
|
||||
// Flow pagination can update layout without repainting the current background.
|
||||
if (backgroundElement.width !== page.width) backgroundElement.width = page.width;
|
||||
if (backgroundElement.height !== page.height) backgroundElement.height = page.height;
|
||||
fabricCanvas.setDimensions({ width: page.width, height: page.height });
|
||||
if (fabricContainer) {
|
||||
fabricContainer.style.width = `${page.width}px`;
|
||||
@@ -781,6 +832,7 @@ export function mountCanvasNote(host, initialContent, options = {}) {
|
||||
);
|
||||
flowLayer.style.top = `${Math.max(0, Math.round((page.height - writableHeight) / 2))}px`;
|
||||
flowEditor.setLayout(writableWidth, writableHeight, currentIndex);
|
||||
applyZoom();
|
||||
}
|
||||
|
||||
async function getPdfDocument(background, suppliedBytes = null) {
|
||||
@@ -1084,7 +1136,7 @@ export function mountCanvasNote(host, initialContent, options = {}) {
|
||||
throw new Error('请选择 JPEG、PNG、GIF 或 WebP 图片');
|
||||
}
|
||||
if (!Number.isFinite(file.size) || file.size <= 0 || file.size > MAX_IMAGE_BYTES) {
|
||||
throw new Error('单张图片不能超过 2 MB');
|
||||
throw new Error('单张图片不能超过 20 MB');
|
||||
}
|
||||
const dataUrl = await readFileDataUrl(file);
|
||||
if (!isSafeImageDataUrl(dataUrl)) {
|
||||
@@ -1475,7 +1527,13 @@ export function mountCanvasNote(host, initialContent, options = {}) {
|
||||
signal: uiAbort.signal
|
||||
});
|
||||
viewport.addEventListener('wheel', (event) => {
|
||||
if (event.defaultPrevented || event.ctrlKey) return;
|
||||
if (event.defaultPrevented) return;
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? viewport.clientHeight : 1;
|
||||
if (event.deltaY) setZoom(zoom * Math.exp(-event.deltaY * unit * 0.002));
|
||||
return;
|
||||
}
|
||||
// Some drivers already map Shift to deltaX; only remap a vertical-only wheel.
|
||||
const horizontal = event.shiftKey && event.deltaX === 0;
|
||||
const dx = horizontal ? event.deltaY : event.deltaX;
|
||||
@@ -1489,6 +1547,23 @@ export function mountCanvasNote(host, initialContent, options = {}) {
|
||||
// Leave boundary/no-overflow events available for normal ancestor scrolling.
|
||||
if (viewport.scrollLeft !== left || viewport.scrollTop !== top) event.preventDefault();
|
||||
}, { passive: false, signal: uiAbort.signal });
|
||||
zoomOutButton.addEventListener('click', () => setZoom(zoom - 0.25), { signal: uiAbort.signal });
|
||||
zoomInButton.addEventListener('click', () => setZoom(zoom + 0.25), { signal: uiAbort.signal });
|
||||
zoomResetButton.addEventListener('click', () => setZoom(1), { signal: uiAbort.signal });
|
||||
zoomFitButton.addEventListener('click', () => setZoom(zoom, true), { signal: uiAbort.signal });
|
||||
let resizeFrame = 0;
|
||||
let viewportWidth = -1;
|
||||
const resizeObserver = globalThis.ResizeObserver ? new globalThis.ResizeObserver(([entry]) => {
|
||||
const width = entry.contentRect.width;
|
||||
if (width === viewportWidth) return;
|
||||
viewportWidth = width;
|
||||
if (resizeFrame) globalThis.cancelAnimationFrame(resizeFrame);
|
||||
resizeFrame = globalThis.requestAnimationFrame(() => {
|
||||
resizeFrame = 0;
|
||||
if (fitWidth) applyZoom();
|
||||
});
|
||||
}) : null;
|
||||
resizeObserver?.observe(viewport);
|
||||
imageInput.addEventListener('change', () => {
|
||||
const file = imageInput.files?.[0] || null;
|
||||
imageInput.value = '';
|
||||
@@ -1616,6 +1691,8 @@ export function mountCanvasNote(host, initialContent, options = {}) {
|
||||
captureCurrentObjects();
|
||||
destroyed = true;
|
||||
uiAbort.abort();
|
||||
resizeObserver?.disconnect();
|
||||
if (resizeFrame) globalThis.cancelAnimationFrame(resizeFrame);
|
||||
clearDeleteConfirmation();
|
||||
flowEditor.destroy();
|
||||
for (const task of renderTasks) {
|
||||
|
||||
@@ -101,11 +101,10 @@ it('clamps at edges and only consumes events that can move the viewport', () =>
|
||||
expect(wheel().defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves unscrollable axes, Ctrl zoom and previously handled events alone', () => {
|
||||
it('leaves unscrollable axes and previously handled events alone', () => {
|
||||
Object.defineProperty(viewport, 'scrollWidth', { value: 500 });
|
||||
expect(wheel({ deltaX: 100 }).defaultPrevented).toBe(false);
|
||||
expect(wheel({ deltaY: 100, shiftKey: true }).defaultPrevented).toBe(false);
|
||||
expect(wheel({ deltaY: 100, ctrlKey: true }).defaultPrevented).toBe(false);
|
||||
target.addEventListener('wheel', event => event.preventDefault(), { once: true });
|
||||
wheel({ deltaY: 100 });
|
||||
expect([viewport.scrollLeft, viewport.scrollTop]).toEqual([0, 0]);
|
||||
@@ -113,6 +112,37 @@ it('leaves unscrollable axes, Ctrl zoom and previously handled events alone', ()
|
||||
expect(wheel({ deltaY: 100 }).defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['ctrlKey', 'metaKey'])('zooms with %s without scrolling or changing content, including readonly', (modifier) => {
|
||||
editor.setDisabled(true);
|
||||
const before = editor.content();
|
||||
expect(wheel({ deltaY: -100, [modifier]: true }).defaultPrevented).toBe(true);
|
||||
expect(parseFloat(host.querySelector('.canvas-note-zoom-reset').textContent)).toBeGreaterThan(100);
|
||||
expect([viewport.scrollLeft, viewport.scrollTop]).toEqual([0, 0]);
|
||||
expect(editor.content()).toEqual(before);
|
||||
});
|
||||
|
||||
it('clamps toolbar zoom, resets to 100%, and keeps logical sizes and readonly controls', () => {
|
||||
const before = editor.content();
|
||||
editor.setDisabled(true);
|
||||
const click = suffix => host.querySelector(`.canvas-note-zoom-${suffix}`).click();
|
||||
for (let i = 0; i < 20; i++) click('in');
|
||||
expect(host.querySelector('.canvas-note-zoom-reset').textContent).toBe('300%');
|
||||
expect(host.querySelector('.canvas-note-zoom-in').disabled).toBe(true);
|
||||
expect(host.querySelector('.canvas-note-page').style.height).toBe('3369px');
|
||||
for (let i = 0; i < 20; i++) click('out');
|
||||
expect(host.querySelector('.canvas-note-zoom-reset').textContent).toBe('25%');
|
||||
expect(host.querySelector('.canvas-note-zoom-out').disabled).toBe(true);
|
||||
click('reset');
|
||||
expect(host.querySelector('.canvas-note-zoom-reset').textContent).toBe('100%');
|
||||
expect(host.querySelector('.canvas-note-page-surface').style.width).toBe('794px');
|
||||
click('fit');
|
||||
expect(host.querySelector('.canvas-note-zoom-fit').getAttribute('aria-pressed')).toBe('true');
|
||||
expect(parseFloat(host.querySelector('.canvas-note-page').style.width)).toBeCloseTo(500);
|
||||
click('in');
|
||||
expect(host.querySelector('.canvas-note-zoom-fit').getAttribute('aria-pressed')).toBe('false');
|
||||
expect(editor.content()).toEqual(before);
|
||||
});
|
||||
|
||||
it('removes the wheel listener on destroy', async () => {
|
||||
await editor.destroy();
|
||||
expect(wheel({ deltaX: 100, deltaY: 100 }).defaultPrevented).toBe(false);
|
||||
|
||||
+110
-68
@@ -1343,6 +1343,10 @@
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.magic-notes-index-resize-handle {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.magic-notes-overview,
|
||||
.magic-notes-stream-pane,
|
||||
.magic-notes-ai-pane {
|
||||
@@ -1355,21 +1359,28 @@
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.magic-notes-ai-pane > .magic-notes-pane-heading {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--surface-subtle);
|
||||
z-index: 1;
|
||||
padding-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.magic-notes-index-pane {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: var(--surface-subtle);
|
||||
background: transparent;
|
||||
}
|
||||
.magic-notes-index-pane[hidden] { display: none; }
|
||||
@media (forced-colors: none) and (pointer: fine) {
|
||||
.magic-notes-page :where(
|
||||
.magic-notes-overview,
|
||||
.magic-notes-index-pane,
|
||||
.magic-notes-stream-pane,
|
||||
.magic-notes-ai-pane,
|
||||
.magic-notes-list,
|
||||
.magic-todo-detail,
|
||||
.magic-note-editor__content .ql-editor
|
||||
) {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-control) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.magic-note-records { display: grid; gap: var(--space-3); padding: var(--space-2); }
|
||||
.magic-note-records[hidden] { display: none; }
|
||||
.magic-note-record {
|
||||
@@ -1378,20 +1389,26 @@
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border-control);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface-raised);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.magic-note-record[aria-current="true"] { border-color: var(--accent); background: var(--accent-subtle); }
|
||||
.magic-note-record:hover { border-color: var(--accent); }
|
||||
.magic-note-record:hover { background: var(--surface-subtle); }
|
||||
.magic-note-record[aria-current="true"] {
|
||||
background: var(--accent-subtle);
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
.magic-note-record[aria-current="true"] { border-color: Highlight; }
|
||||
}
|
||||
.magic-note-record:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.magic-note-record time { color: var(--text-secondary); font-size: var(--font-caption); }
|
||||
.magic-note-record__summary { display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
|
||||
.magic-note-record__image { display: block; min-width: 0; text-align: center; }
|
||||
.magic-note-record__image img { display: block; width: 100%; max-height: 180px; object-fit: contain; }
|
||||
.magic-note-record__image img { display: block; width: auto; max-width: 100%; max-height: 180px; margin-inline: auto; border: 1px solid var(--border-subtle); }
|
||||
.magic-note-thumbnail-renderer { position: fixed; left: -10000px; top: 0; width: 850px; height: 1200px; visibility: hidden; pointer-events: none; }
|
||||
|
||||
.magic-notes-overview[hidden],
|
||||
@@ -1486,7 +1503,7 @@
|
||||
.magic-notes-ai-controls label {
|
||||
display: grid;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
font-size: var(--font-body);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
@@ -1502,8 +1519,8 @@
|
||||
}
|
||||
|
||||
.magic-notes-ai-controls > small {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-body);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -1870,7 +1887,7 @@
|
||||
.magic-note-list-item__preview {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
font-size: var(--font-caption);
|
||||
font-size: var(--font-body);
|
||||
line-height: 1.5;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
@@ -1929,8 +1946,8 @@
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
font-size: var(--font-body);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.magic-note-detail-header > input:hover,
|
||||
@@ -1968,6 +1985,12 @@
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.magic-note-entry:not(:has(.magic-note-entry__editor)) {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.magic-note-draft-confirmation {
|
||||
display: grid;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
@@ -1995,8 +2018,9 @@
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-body);
|
||||
line-height: 1.5;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
@@ -2027,6 +2051,10 @@
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.magic-note-entry:not(:has(.magic-note-entry__editor)) > header {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.magic-note-entry > header > div {
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
@@ -2190,6 +2218,11 @@
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.magic-note-composer > .magic-note-editor .magic-note-editor__content,
|
||||
.magic-note-composer > .magic-note-editor .magic-note-editor__content .ql-editor {
|
||||
min-height: calc(4 * 1.65em + 2 * var(--space-3));
|
||||
}
|
||||
|
||||
.magic-note-editor__content .ql-editor.ql-blank::before {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -2385,7 +2418,7 @@
|
||||
width: 168px;
|
||||
overflow: auto;
|
||||
align-content: start;
|
||||
background: var(--surface-subtle);
|
||||
background: var(--surface-raised);
|
||||
border-right: 1px solid var(--border-default);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
@@ -3428,14 +3461,14 @@ textarea:focus-visible {
|
||||
display: flex;
|
||||
min-width: 248px;
|
||||
align-items: center;
|
||||
padding: 11px 12px;
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--accent-solid);
|
||||
border-radius: var(--radius-control);
|
||||
margin-bottom: 11px;
|
||||
margin-bottom: var(--space-3);
|
||||
background: var(--accent-solid);
|
||||
color: var(--text-on-accent);
|
||||
cursor: pointer;
|
||||
gap: 9px;
|
||||
gap: var(--space-2);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -3452,9 +3485,9 @@ textarea:focus-visible {
|
||||
|
||||
.new-chat kbd {
|
||||
padding: 2px 5px;
|
||||
border: 1px solid rgb(255 255 255 / 35%);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
background: rgb(255 255 255 / 12%);
|
||||
background: transparent;
|
||||
color: var(--text-on-accent);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
@@ -3468,10 +3501,15 @@ textarea:focus-visible {
|
||||
padding-right: 40px;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-control);
|
||||
margin-bottom: 15px;
|
||||
margin-bottom: var(--space-4);
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-muted);
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.sidebar-search:focus-within {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sidebar-search input {
|
||||
@@ -3481,7 +3519,7 @@ textarea:focus-visible {
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-size: var(--font-body);
|
||||
}
|
||||
|
||||
.sidebar-search input::placeholder {
|
||||
@@ -3505,38 +3543,47 @@ textarea:focus-visible {
|
||||
display: flex;
|
||||
min-width: 248px;
|
||||
flex-direction: column;
|
||||
padding-bottom: 13px;
|
||||
padding-bottom: var(--space-4);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
gap: 3px;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
min-height: var(--control-height);
|
||||
align-items: center;
|
||||
padding: 9px 10px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
gap: 10px;
|
||||
gap: var(--space-3);
|
||||
text-align: left;
|
||||
transition: background var(--motion-fast) ease-out,
|
||||
color var(--motion-fast) ease-out;
|
||||
}
|
||||
|
||||
.nav-item > svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-item--active,
|
||||
.nav-item--active:hover {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.nav-item--active {
|
||||
border-color: var(--accent-selected);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--accent-selected) 34%,
|
||||
var(--accent-subtle)
|
||||
);
|
||||
.nav-item--active > svg {
|
||||
color: var(--accent);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.nav-item span:nth-child(2) {
|
||||
@@ -3545,6 +3592,10 @@ textarea:focus-visible {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-item--active span:nth-child(2) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-item__hint {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
@@ -3552,14 +3603,15 @@ textarea:focus-visible {
|
||||
|
||||
.nav-item__badge {
|
||||
display: grid;
|
||||
min-width: 17px;
|
||||
height: 17px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--danger-solid);
|
||||
color: var(--text-on-accent);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
place-items: center;
|
||||
}
|
||||
@@ -3567,7 +3619,7 @@ textarea:focus-visible {
|
||||
.conversation-list {
|
||||
min-width: 248px;
|
||||
flex: 1;
|
||||
padding-top: 14px;
|
||||
padding-top: var(--space-4);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -3576,8 +3628,8 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
.section-label {
|
||||
padding: 0 9px;
|
||||
margin: 0 0 7px;
|
||||
padding: 0 var(--space-3);
|
||||
margin: 0 0 var(--space-2);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
font-weight: 600;
|
||||
@@ -3600,19 +3652,15 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.conversation-item--active {
|
||||
border-color: var(--accent-selected);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--accent-selected) 34%,
|
||||
var(--accent-subtle)
|
||||
);
|
||||
.conversation-item--active,
|
||||
.conversation-item--active:hover {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
font-weight: 650;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.conversation-item__primary {
|
||||
@@ -16184,8 +16232,6 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] :where(
|
||||
.nav-item,
|
||||
.conversation-item,
|
||||
.user-card,
|
||||
.icon-button,
|
||||
.assistant-sidebar__row,
|
||||
@@ -16234,9 +16280,6 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
:root[data-theme='dark'] :where(
|
||||
.icon-button:hover,
|
||||
.icon-button--active,
|
||||
.nav-item:hover,
|
||||
.conversation-item:hover,
|
||||
.conversation-item--active,
|
||||
.user-card:hover,
|
||||
.secondary-button:hover:not(:disabled),
|
||||
.settings-tabs button[aria-selected='true']
|
||||
@@ -16252,7 +16295,6 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] :where(
|
||||
.nav-item--active,
|
||||
.brand__mark
|
||||
) {
|
||||
border-color: var(--accent-selected);
|
||||
|
||||
Reference in New Issue
Block a user