refactor(editor): shadowless TOC with valilla extract css (#9856)

Close [BS-2474](https://linear.app/affine-design/issue/BS-2474/使用shadowlesselement重构toc)

This PR refactor TOC with `ShadowlessElement` and  `@valilla-extract/css`
This commit is contained in:
L-Sun
2025-01-22 16:24:30 +00:00
parent 088ae0ac0a
commit 4839f0f369
23 changed files with 911 additions and 970 deletions

View File

@@ -18,12 +18,15 @@
"@blocksuite/block-std": "workspace:*", "@blocksuite/block-std": "workspace:*",
"@blocksuite/blocks": "workspace:*", "@blocksuite/blocks": "workspace:*",
"@blocksuite/global": "workspace:*", "@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.2",
"@blocksuite/inline": "workspace:*", "@blocksuite/inline": "workspace:*",
"@blocksuite/store": "workspace:*", "@blocksuite/store": "workspace:*",
"@floating-ui/dom": "^1.6.10", "@floating-ui/dom": "^1.6.10",
"@lit/context": "^1.1.3",
"@lottiefiles/dotlottie-wc": "^0.4.0", "@lottiefiles/dotlottie-wc": "^0.4.0",
"@preact/signals-core": "^1.8.0", "@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.7", "@toeverything/theme": "^1.1.7",
"@vanilla-extract/css": "^1.17.0",
"lit": "^3.2.0", "lit": "^3.2.0",
"yjs": "^13.6.21", "yjs": "^13.6.21",
"zod": "^3.23.8" "zod": "^3.23.8"

View File

@@ -0,0 +1,86 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const outlineNotice = style({
position: 'absolute',
left: 0,
bottom: '8px',
padding: '10px 18px',
display: 'flex',
width: '100%',
boxSizing: 'border-box',
gap: '14px',
fontStyle: 'normal',
fontSize: '12px',
flexDirection: 'column',
borderRadius: '8px',
backgroundColor: cssVar('--affine-background-overlay-panel-color'),
});
export const outlineNoticeHeader = style({
display: 'flex',
width: '100%',
height: '20px',
alignItems: 'center',
justifyContent: 'space-between',
});
export const outlineNoticeLabel = style({
fontWeight: 600,
lineHeight: '20px',
color: cssVarV2('text/secondary'),
});
export const outlineNoticeCloseButton = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '20px',
height: '20px',
cursor: 'pointer',
color: cssVarV2('icon/primary'),
});
export const outlineNoticeBody = style({
display: 'flex',
width: '100%',
gap: '2px',
flexDirection: 'column',
});
const outlineNoticeItem = style({
display: 'flex',
height: '20px',
alignItems: 'center',
lineHeight: '20px',
color: cssVarV2('text/primary'),
});
export const notice = style([
outlineNoticeItem,
{
fontWeight: 400,
},
]);
export const button = style([
outlineNoticeItem,
{
display: 'flex',
gap: '2px',
fontWeight: 500,
textDecoration: 'underline',
cursor: 'pointer',
},
]);
export const buttonSpan = style({
display: 'flex',
alignItems: 'center',
lineHeight: '20px',
});
export const buttonSvg = style({
scale: 0.8,
});

View File

@@ -1,89 +1,14 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils'; import { WithDisposable } from '@blocksuite/global/utils';
import { css, html, LitElement, nothing } from 'lit'; import { html, nothing } from 'lit';
import { property } from 'lit/decorators.js'; import { property } from 'lit/decorators.js';
import { SmallCloseIcon, SortingIcon } from '../../_common/icons.js'; import { SmallCloseIcon, SortingIcon } from '../../_common/icons.js';
import * as styles from './outline-notice.css';
const styles = css`
:host {
width: 100%;
box-sizing: border-box;
position: absolute;
left: 0;
bottom: 8px;
padding: 0 8px;
}
.outline-notice-container {
display: flex;
width: 100%;
box-sizing: border-box;
gap: 14px;
padding: 10px;
font-style: normal;
font-size: 12px;
flex-direction: column;
border-radius: 8px;
background-color: var(--affine-background-overlay-panel-color);
}
.outline-notice-header {
display: flex;
width: 100%;
height: 20px;
align-items: center;
justify-content: space-between;
}
.outline-notice-label {
font-weight: 600;
line-height: 20px;
color: var(--affine-text-secondary-color);
}
.outline-notice-close-button {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
cursor: pointer;
color: var(--affine-icon-color);
}
.outline-notice-body {
display: flex;
width: 100%;
gap: 2px;
flex-direction: column;
}
.outline-notice-item {
display: flex;
height: 20px;
align-items: center;
line-height: 20px;
color: var(--affine-text-primary-color);
}
.outline-notice-item.notice {
font-weight: 400;
}
.outline-notice-item.button {
display: flex;
gap: 2px;
font-weight: 500;
text-decoration: underline;
cursor: pointer;
}
.outline-notice-item.button span {
display: flex;
align-items: center;
line-height: 20px;
}
.outline-notice-item.button svg {
scale: 0.8;
}
`;
export const AFFINE_OUTLINE_NOTICE = 'affine-outline-notice'; export const AFFINE_OUTLINE_NOTICE = 'affine-outline-notice';
export class OutlineNotice extends WithDisposable(LitElement) { export class OutlineNotice extends WithDisposable(ShadowlessElement) {
static override styles = styles;
private _handleNoticeButtonClick() { private _handleNoticeButtonClick() {
this.toggleNotesSorting(); this.toggleNotesSorting();
this.setNoticeVisibility(false); this.setNoticeVisibility(false);
@@ -94,29 +19,28 @@ export class OutlineNotice extends WithDisposable(LitElement) {
return nothing; return nothing;
} }
return html`<div class="outline-notice-container"> return html`
<div class="outline-notice-header"> <div class=${styles.outlineNotice}>
<span class="outline-notice-label">SOME CONTENTS HIDDEN</span> <div class=${styles.outlineNoticeHeader}>
<span <span class=${styles.outlineNoticeLabel}>SOME CONTENTS HIDDEN</span>
class="outline-notice-close-button" <span
@click=${() => this.setNoticeVisibility(false)} class=${styles.outlineNoticeCloseButton}
>${SmallCloseIcon}</span @click=${() => this.setNoticeVisibility(false)}
> >${SmallCloseIcon}</span
</div> >
<div class="outline-notice-body">
<div class="outline-notice-item notice">
Some contents are not visible on edgeless.
</div> </div>
<div <div class=${styles.outlineNoticeBody}>
class="outline-notice-item button" <div class="${styles.notice}">
@click=${this._handleNoticeButtonClick} Some contents are not visible on edgeless.
> </div>
<span>Click here or</span> <div class="${styles.button}" @click=${this._handleNoticeButtonClick}>
<span>${SortingIcon}</span> <span class=${styles.buttonSpan}>Click here or</span>
<span>to organize content.</span> <span class=${styles.buttonSpan}>${SortingIcon}</span>
<span class=${styles.buttonSpan}>to organize content.</span>
</div>
</div> </div>
</div> </div>
</div>`; `;
} }
@property({ attribute: false }) @property({ attribute: false })

View File

@@ -0,0 +1,62 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const outlinePanelBody = style({
position: 'relative',
alignItems: 'start',
boxSizing: 'border-box',
width: '100%',
height: '100%',
padding: '0 8px',
flexGrow: 1,
overflowY: 'scroll',
});
export const cardList = style({
position: 'relative',
});
export const edgelessCardListTitle = style({
width: '100%',
fontSize: '14px',
lineHeight: '24px',
fontWeight: 500,
color: cssVarV2('text/secondary'),
paddingLeft: '8px',
height: '40px',
boxSizing: 'border-box',
padding: '6px 8px',
marginTop: '8px',
});
export const insertIndicator = style({
height: '2px',
borderRadius: '1px',
backgroundColor: cssVar('brandColor'),
position: 'absolute',
top: 0,
left: 0,
right: 0,
contain: 'layout size',
width: '100%',
});
export const emptyPanel = style({
display: 'flex',
flexDirection: 'column',
width: '100%',
});
export const emptyPanelPlaceholder = style({
marginTop: '240px',
alignSelf: 'center',
width: '190px',
height: '48px',
color: cssVarV2('text/secondary'),
textAlign: 'center',
fontSize: '15px',
fontStyle: 'normal',
fontWeight: 400,
lineHeight: '24px',
});

View File

@@ -1,13 +1,9 @@
import { SurfaceSelection } from '@blocksuite/block-std'; import { ShadowlessElement, SurfaceSelection } from '@blocksuite/block-std';
import type { import type { NoteBlockModel } from '@blocksuite/blocks';
EdgelessRootBlockComponent,
NoteBlockModel,
} from '@blocksuite/blocks';
import { import {
BlocksUtils, BlocksUtils,
matchFlavours, matchFlavours,
NoteDisplayMode, NoteDisplayMode,
ThemeProvider,
} from '@blocksuite/blocks'; } from '@blocksuite/blocks';
import { import {
Bound, Bound,
@@ -15,30 +11,33 @@ import {
SignalWatcher, SignalWatcher,
WithDisposable, WithDisposable,
} from '@blocksuite/global/utils'; } from '@blocksuite/global/utils';
import type { Store } from '@blocksuite/store'; import { consume } from '@lit/context';
import { effect, signal } from '@preact/signals-core'; import { effect, signal } from '@preact/signals-core';
import { css, html, LitElement, nothing, type PropertyValues } from 'lit'; import { html, nothing, type PropertyValues } from 'lit';
import { property, query, state } from 'lit/decorators.js'; import { property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js'; import { repeat } from 'lit/directives/repeat.js';
import { when } from 'lit/directives/when.js';
import type { AffineEditorContainer } from '../../../editors/editor-container.js'; import type { AffineEditorContainer } from '../../../editors/editor-container';
import { editorContext } from '../config';
import type { import type {
ClickBlockEvent, ClickBlockEvent,
DisplayModeChangeEvent, DisplayModeChangeEvent,
FitViewEvent, FitViewEvent,
SelectEvent, SelectEvent,
} from '../utils/custom-events.js'; } from '../utils/custom-events';
import { startDragging } from '../utils/drag.js'; import { startDragging } from '../utils/drag';
import { import {
getHeadingBlocksFromDoc, getHeadingBlocksFromDoc,
getNotesFromDoc, getNotesFromDoc,
isHeadingBlock, isHeadingBlock,
} from '../utils/query.js'; } from '../utils/query';
import { import {
observeActiveHeadingDuringScroll, observeActiveHeadingDuringScroll,
scrollToBlockWithHighlight, scrollToBlockWithHighlight,
} from '../utils/scroll.js'; } from '../utils/scroll';
import * as styles from './outline-panel-body.css';
type OutlineNoteItem = { type OutlineNoteItem = {
note: NoteBlockModel; note: NoteBlockModel;
@@ -54,76 +53,19 @@ type OutlineNoteItem = {
number: number; number: number;
}; };
const styles = css`
.outline-panel-body-container {
position: relative;
display: flex;
align-items: start;
box-sizing: border-box;
flex-direction: column;
width: 100%;
height: 100%;
padding: 0 8px;
}
.panel-list {
position: relative;
width: 100%;
}
.panel-list .hidden-title {
width: 100%;
font-size: 14px;
line-height: 24px;
font-weight: 500;
color: var(--affine-text-secondary-color);
padding-left: 8px;
height: 40px;
box-sizing: border-box;
padding: 6px 8px;
margin-top: 8px;
}
.insert-indicator {
height: 2px;
border-radius: 1px;
background-color: var(--affine-brand-color);
border-radius: 1px;
position: absolute;
contain: layout size;
width: 100%;
}
.no-note-container {
display: flex;
flex-direction: column;
width: 100%;
}
.note-placeholder {
margin-top: 240px;
align-self: center;
width: 190px;
height: 48px;
color: var(--affine-text-secondary-color, #8e8d91);
text-align: center;
/* light/base */
font-size: 15px;
font-style: normal;
font-weight: 400;
line-height: 24px;
}
`;
export const AFFINE_OUTLINE_PANEL_BODY = 'affine-outline-panel-body'; export const AFFINE_OUTLINE_PANEL_BODY = 'affine-outline-panel-body';
export class OutlinePanelBody extends SignalWatcher( export class OutlinePanelBody extends SignalWatcher(
WithDisposable(LitElement) WithDisposable(ShadowlessElement)
) { ) {
static override styles = styles;
private readonly _activeHeadingId$ = signal<string | null>(null); private readonly _activeHeadingId$ = signal<string | null>(null);
private readonly _dragging$ = signal(false);
private readonly _pageVisibleNoteItems$ = signal<OutlineNoteItem[]>([]);
private readonly _edgelessOnlyNoteItems$ = signal<OutlineNoteItem[]>([]);
private _clearHighlightMask = () => {}; private _clearHighlightMask = () => {};
private _docDisposables: DisposableGroup | null = null; private _docDisposables: DisposableGroup | null = null;
@@ -132,6 +74,21 @@ export class OutlinePanelBody extends SignalWatcher(
private _lockActiveHeadingId = false; private _lockActiveHeadingId = false;
private get _shouldRenderEmptyPanel() {
return (
this._pageVisibleNoteItems$.value.length === 0 &&
this._edgelessOnlyNoteItems$.value.length === 0
);
}
private get doc() {
return this.editor.doc;
}
private get edgeless() {
return this.editor.querySelector('affine-edgeless-root');
}
get viewportPadding(): [number, number, number, number] { get viewportPadding(): [number, number, number, number] {
return this.fitPadding return this.fitPadding
? ([0, 0, 0, 0].map((val, idx) => ? ([0, 0, 0, 0].map((val, idx) =>
@@ -174,6 +131,8 @@ export class OutlinePanelBody extends SignalWatcher(
}; };
private _drag() { private _drag() {
const pageVisibleNotes = this._pageVisibleNoteItems$.peek();
const selectedVisibleNotes = this._selectedNotes$.peek().filter(id => { const selectedVisibleNotes = this._selectedNotes$.peek().filter(id => {
const model = this.doc.getBlock(id)?.model; const model = this.doc.getBlock(id)?.model;
return ( return (
@@ -185,7 +144,7 @@ export class OutlinePanelBody extends SignalWatcher(
if ( if (
selectedVisibleNotes.length === 0 || selectedVisibleNotes.length === 0 ||
!this._pageVisibleNotes.length || !pageVisibleNotes.length ||
!this.doc.root !this.doc.root
) )
return; return;
@@ -199,12 +158,11 @@ export class OutlinePanelBody extends SignalWatcher(
this._selectedNotes$.value = selectedVisibleNotes; this._selectedNotes$.value = selectedVisibleNotes;
} }
this._dragging = true; this._dragging$.value = true;
// cache the notes in case it is changed by other peers // cache the notes in case it is changed by other peers
const children = this.doc.root.children.slice() as NoteBlockModel[]; const children = this.doc.root.children.slice() as NoteBlockModel[];
const notes = this._pageVisibleNotes; const notesMap = pageVisibleNotes.reduce((map, note, index) => {
const notesMap = this._pageVisibleNotes.reduce((map, note, index) => {
map.set(note.note.id, { map.set(note.note.id, {
...note, ...note,
number: index + 1, number: index + 1,
@@ -215,11 +173,11 @@ export class OutlinePanelBody extends SignalWatcher(
startDragging({ startDragging({
container: this, container: this,
document: this.ownerDocument, document: this.ownerDocument,
host: this.domHost ?? this.ownerDocument, host: this.ownerDocument,
doc: this.doc, doc: this.doc,
outlineListContainer: this.panelListElement, outlineListContainer: this._pageVisibleList,
onDragEnd: insertIdx => { onDragEnd: insertIdx => {
this._dragging = false; this._dragging$.value = false;
this.insertIndex = undefined; this.insertIndex = undefined;
if (insertIdx === undefined) return; if (insertIdx === undefined) return;
@@ -228,7 +186,7 @@ export class OutlinePanelBody extends SignalWatcher(
insertIdx, insertIdx,
selectedVisibleNotes, selectedVisibleNotes,
notesMap, notesMap,
notes, pageVisibleNotes,
children children
); );
}, },
@@ -240,8 +198,11 @@ export class OutlinePanelBody extends SignalWatcher(
} }
private _EmptyPanel() { private _EmptyPanel() {
return html`<div class="no-note-container"> return html`<div class=${styles.emptyPanel}>
<div class="note-placeholder"> <div
data-testid="empty-panel-placeholder"
class=${styles.emptyPanelPlaceholder}
>
Use headings to create a table of contents. Use headings to create a table of contents.
</div> </div>
</div>`; </div>`;
@@ -330,77 +291,6 @@ export class OutlinePanelBody extends SignalWatcher(
}); });
} }
private _PanelList(withEdgelessOnlyNotes: boolean) {
const selectedNotesSet = new Set(this._selectedNotes$.value);
const theme = this.editor.std.get(ThemeProvider).theme;
return html`<div class="panel-list">
${this.insertIndex !== undefined
? html`<div
class="insert-indicator"
style=${`transform: translateY(${this._indicatorTranslateY}px)`}
></div>`
: nothing}
${this._pageVisibleNotes.length
? repeat(
this._pageVisibleNotes,
item => item.note.id,
(item, idx) => html`
<affine-outline-note-card
data-note-id=${item.note.id}
.note=${item.note}
.theme=${theme}
.number=${idx + 1}
.index=${item.index}
.doc=${this.doc}
.activeHeadingId=${this._activeHeadingId$.value}
.status=${selectedNotesSet.has(item.note.id)
? this._dragging
? 'placeholder'
: 'selected'
: undefined}
.showPreviewIcon=${this.showPreviewIcon}
.enableNotesSorting=${this.enableNotesSorting}
@select=${this._selectNote}
@drag=${this._drag}
@fitview=${this._fitToElement}
@clickblock=${(e: ClickBlockEvent) => {
this._scrollToBlock(e.detail.blockId).catch(console.error);
}}
@displaymodechange=${this._handleDisplayModeChange}
></affine-outline-note-card>
`
)
: html`${nothing}`}
${withEdgelessOnlyNotes
? html`<div class="hidden-title">Hidden Contents</div>
${repeat(
this._edgelessOnlyNotes,
note => note.note.id,
(item, idx) =>
html`<affine-outline-note-card
data-note-id=${item.note.id}
.note=${item.note}
.theme=${theme}
.number=${idx + 1}
.index=${item.index}
.doc=${this.doc}
.activeHeadingId=${this._activeHeadingId$.value}
.invisible=${true}
.showPreviewIcon=${this.showPreviewIcon}
.enableNotesSorting=${this.enableNotesSorting}
.status=${selectedNotesSet.has(item.note.id)
? 'selected'
: undefined}
@fitview=${this._fitToElement}
@select=${this._selectNote}
@displaymodechange=${this._handleDisplayModeChange}
></affine-outline-note-card>`
)} `
: nothing}
</div>`;
}
private _renderDocTitle() { private _renderDocTitle() {
if (!this.doc.root) return nothing; if (!this.doc.root) return nothing;
@@ -431,6 +321,61 @@ export class OutlinePanelBody extends SignalWatcher(
></affine-outline-block-preview>`; ></affine-outline-block-preview>`;
} }
private _renderNoteCards(items: OutlineNoteItem[]) {
return repeat(
items,
item => item.note.id,
(item, idx) =>
html`<affine-outline-note-card
data-note-id=${item.note.id}
.note=${item.note}
.number=${idx + 1}
.index=${item.index}
.activeHeadingId=${this._activeHeadingId$.value}
.showPreviewIcon=${this.showPreviewIcon}
.enableNotesSorting=${this.enableNotesSorting}
.status=${this._selectedNotes$.value.includes(item.note.id)
? this._dragging$.value
? 'placeholder'
: 'selected'
: 'normal'}
@fitview=${this._fitToElement}
@select=${this._selectNote}
@displaymodechange=${this._handleDisplayModeChange}
@drag=${this._drag}
@clickblock=${(e: ClickBlockEvent) => {
this._scrollToBlock(e.detail.blockId).catch(console.error);
}}
></affine-outline-note-card>`
);
}
private _renderPageVisibleCardList() {
return html`<div class=${`page-visible-card-list ${styles.cardList}`}>
${when(
this.insertIndex !== undefined,
() =>
html`<div
class=${styles.insertIndicator}
style=${`transform: translateY(${this._indicatorTranslateY}px)`}
></div>`
)}
${this._renderNoteCards(this._pageVisibleNoteItems$.value)}
</div>`;
}
private _renderEdgelessOnlyCardList() {
const items = this._edgelessOnlyNoteItems$.value;
return html`<div class=${styles.cardList}>
${when(
items.length > 0,
() =>
html`<div class=${styles.edgelessCardListTitle}>Hidden Contents</div>`
)}
${this._renderNoteCards(items)}
</div>`;
}
private async _scrollToBlock(blockId: string) { private async _scrollToBlock(blockId: string) {
this._lockActiveHeadingId = true; this._lockActiveHeadingId = true;
this._activeHeadingId$.value = blockId; this._activeHeadingId$.value = blockId;
@@ -471,69 +416,41 @@ export class OutlinePanelBody extends SignalWatcher(
this._docDisposables = new DisposableGroup(); this._docDisposables = new DisposableGroup();
this._docDisposables.add( this._docDisposables.add(
effect(() => { effect(() => {
this._updateNotes();
this._updateNoticeVisibility(); this._updateNoticeVisibility();
}) })
); );
this._docDisposables.add( this._docDisposables.add(
this.doc.slots.blockUpdated.on(payload => { effect(() => {
if ( this._updateNotes();
payload.type === 'update' &&
payload.flavour === 'affine:note' &&
payload.props.key === 'displayMode'
) {
this._updateNotes();
}
}) })
); );
} }
/** private _updateNotes() {
* There are two cases that we should render note list: if (this._dragging$.value) return;
* 1. There are headings in the notes
* 2. No headings, but there are blocks in the notes and note sorting option is enabled
*/
private _shouldRenderNoteList(noteItems: OutlineNoteItem[]) {
if (!noteItems.length) return false;
let hasHeadings = false; const isRenderableNote = (item: OutlineNoteItem) => {
let hasChildrenBlocks = false; let hasHeadings = false;
for (const noteItem of noteItems) {
for (const block of noteItem.note.children) {
hasChildrenBlocks = true;
for (const block of item.note.children) {
if (isHeadingBlock(block)) { if (isHeadingBlock(block)) {
hasHeadings = true; hasHeadings = true;
break; break;
} }
} }
if (hasHeadings) { return hasHeadings || this.enableNotesSorting;
break; };
}
}
return hasHeadings || (this.enableNotesSorting && hasChildrenBlocks); this._pageVisibleNoteItems$.value = getNotesFromDoc(this.doc, [
}
private _updateNotes() {
const rootModel = this.doc.root;
if (this._dragging) return;
if (!rootModel) {
this._pageVisibleNotes = [];
return;
}
this._pageVisibleNotes = getNotesFromDoc(this.doc, [
NoteDisplayMode.DocAndEdgeless, NoteDisplayMode.DocAndEdgeless,
NoteDisplayMode.DocOnly, NoteDisplayMode.DocOnly,
]); ]).filter(isRenderableNote);
this._edgelessOnlyNotes = getNotesFromDoc(this.doc, [
this._edgelessOnlyNoteItems$.value = getNotesFromDoc(this.doc, [
NoteDisplayMode.EdgelessOnly, NoteDisplayMode.EdgelessOnly,
]); ]).filter(isRenderableNote);
} }
private _updateNoticeVisibility() { private _updateNoticeVisibility() {
@@ -544,9 +461,8 @@ export class OutlinePanelBody extends SignalWatcher(
return; return;
} }
const shouldShowNotice = this._pageVisibleNotes.some( const shouldShowNotice =
note => note.note.displayMode === NoteDisplayMode.DocOnly getNotesFromDoc(this.doc, [NoteDisplayMode.DocOnly]).length > 0;
);
if (shouldShowNotice && !this.noticeVisible) { if (shouldShowNotice && !this.noticeVisible) {
this.setNoticeVisibility(true); this.setNoticeVisibility(true);
@@ -580,6 +496,8 @@ export class OutlinePanelBody extends SignalWatcher(
override connectedCallback(): void { override connectedCallback(): void {
super.connectedCallback(); super.connectedCallback();
this.classList.add(styles.outlinePanelBody);
this.disposables.add( this.disposables.add(
observeActiveHeadingDuringScroll( observeActiveHeadingDuringScroll(
() => this.editor, () => this.editor,
@@ -603,54 +521,33 @@ export class OutlinePanelBody extends SignalWatcher(
} }
override render() { override render() {
const shouldRenderPageVisibleNotes = this._shouldRenderNoteList(
this._pageVisibleNotes
);
const shouldRenderEdgelessOnlyNotes =
this.renderEdgelessOnlyNotes &&
this._shouldRenderNoteList(this._edgelessOnlyNotes);
const shouldRenderEmptyPanel =
!shouldRenderPageVisibleNotes && !shouldRenderEdgelessOnlyNotes;
return html` return html`
<div class="outline-panel-body-container"> ${this._renderDocTitle()}
${this._renderDocTitle()} ${when(
${shouldRenderEmptyPanel this._shouldRenderEmptyPanel,
? this._EmptyPanel() () => this._EmptyPanel(),
: this._PanelList(shouldRenderEdgelessOnlyNotes)} () => html`
</div> ${this._renderPageVisibleCardList()}
${this._renderEdgelessOnlyCardList()}
`
)}
`; `;
} }
override willUpdate(_changedProperties: PropertyValues) { override willUpdate(_changedProperties: PropertyValues) {
if (_changedProperties.has('doc') || _changedProperties.has('edgeless')) { if (_changedProperties.has('editor')) {
this._setDocDisposables(); this._setDocDisposables();
} }
if (_changedProperties.has('edgeless')) { if (_changedProperties.has('enableNotesSorting')) {
this._clearHighlightMask(); this._updateNoticeVisibility();
} }
} }
@state() @query('.page-visible-card-list')
private accessor _dragging = false; private accessor _pageVisibleList!: HTMLElement;
@state()
private accessor _edgelessOnlyNotes: OutlineNoteItem[] = [];
@state()
private accessor _pageVisibleNotes: OutlineNoteItem[] = [];
@property({ attribute: false })
accessor doc!: Store;
@property({ attribute: false })
accessor domHost!: Document | HTMLElement;
@property({ attribute: false })
accessor edgeless!: EdgelessRootBlockComponent | null;
@consume({ context: editorContext })
@property({ attribute: false }) @property({ attribute: false })
accessor editor!: AffineEditorContainer; accessor editor!: AffineEditorContainer;
@@ -666,12 +563,6 @@ export class OutlinePanelBody extends SignalWatcher(
@property({ attribute: false }) @property({ attribute: false })
accessor noticeVisible!: boolean; accessor noticeVisible!: boolean;
@query('.outline-panel-body-container')
accessor OutlinePanelContainer!: HTMLElement;
@query('.panel-list')
accessor panelListElement!: HTMLElement;
@property({ attribute: false }) @property({ attribute: false })
accessor renderEdgelessOnlyNotes: boolean = true; accessor renderEdgelessOnlyNotes: boolean = true;

View File

@@ -0,0 +1,162 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const outlineCard = style({
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxSizing: 'border-box',
selectors: {
'&[data-status="placeholder"]': {
pointerEvents: 'none',
opacity: 0.5,
},
'&[data-sortable="true"]': {
padding: '2px 0px',
},
},
});
export const cardPreview = style({
position: 'relative',
width: '100%',
borderRadius: '4px',
cursor: 'default',
userSelect: 'none',
':hover': {
background: cssVarV2('layer/background/hoverOverlay'),
},
selectors: {
[`${outlineCard}[data-status="selected"] &`]: {
background: cssVarV2('layer/background/hoverOverlay'),
},
[`${outlineCard}[data-status="placeholder"] &`]: {
background: cssVarV2('layer/background/hoverOverlay'),
opacity: 0.9,
},
},
});
export const cardHeader = style({
padding: '0 8px',
width: '100%',
minHeight: '28px',
display: 'none',
alignItems: 'center',
gap: '8px',
boxSizing: 'border-box',
selectors: {
[`${outlineCard}[data-sortable="true"] &`]: {
display: 'flex',
},
[`${outlineCard}[data-invisible="false"] &:hover`]: {
cursor: 'grab',
},
},
});
const invisibleCard = style({
selectors: {
[`${outlineCard}[data-invisible="true"] &`]: {
color: cssVarV2('text/disable'),
pointerEvents: 'none',
},
},
});
export const headerIcon = style([
{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
invisibleCard,
]);
export const headerNumber = style([
{
textAlign: 'center',
fontSize: cssVar('fontSm'),
color: cssVar('brandColor'),
fontWeight: 500,
lineHeight: '14px',
},
invisibleCard,
]);
export const divider = style({
height: '1px',
flex: 1,
borderTop: `1px dashed ${cssVar('borderColor')}`,
transform: 'translateY(50%)',
});
export const displayModeButtonGroup = style({
display: 'none',
position: 'absolute',
right: '8px',
top: '-6px',
paddingTop: '8px',
paddingBottom: '8px',
alignItems: 'center',
gap: '4px',
fontSize: '12px',
fontWeight: 500,
lineHeight: '20px',
selectors: {
[`${cardPreview}:hover &`]: {
display: 'flex',
},
},
});
export const displayModeButton = style({
display: 'flex',
borderRadius: '4px',
backgroundColor: cssVar('hoverColor'),
alignItems: 'center',
});
export const currentModeLabel = style({
display: 'flex',
padding: '2px 0px 2px 4px',
alignItems: 'center',
});
export const cardContent = style([
{
fontFamily: cssVar('fontSansFamily'),
userSelect: 'none',
color: cssVarV2('text/primary'),
':hover': {
cursor: 'pointer',
},
},
invisibleCard,
]);
export const modeChangePanel = style({
position: 'absolute',
display: 'none',
background: cssVarV2('layer/background/overlayPanel'),
borderRadius: '8px',
boxShadow: cssVar('shadow2'),
boxSizing: 'border-box',
padding: '8px',
fontSize: cssVar('fontSm'),
color: cssVarV2('text/primary'),
lineHeight: '22px',
fontWeight: 400,
fontFamily: cssVar('fontSansFamily'),
selectors: {
'&[data-show]': {
display: 'flex',
},
},
});

View File

@@ -1,5 +1,5 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import { import {
type ColorScheme,
createButtonPopper, createButtonPopper,
type NoteBlockModel, type NoteBlockModel,
NoteDisplayMode, NoteDisplayMode,
@@ -7,189 +7,19 @@ import {
once, once,
} from '@blocksuite/blocks'; } from '@blocksuite/blocks';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import type { BlockModel, Store } from '@blocksuite/store'; import type { BlockModel } from '@blocksuite/store';
import { baseTheme } from '@toeverything/theme'; import { html } from 'lit';
import { css, html, LitElement, unsafeCSS } from 'lit';
import { property, query, state } from 'lit/decorators.js'; import { property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { HiddenIcon, SmallArrowDownIcon } from '../../_common/icons.js'; import { HiddenIcon, SmallArrowDownIcon } from '../../_common/icons';
import type { SelectEvent } from '../utils/custom-events.js'; import type { SelectEvent } from '../utils/custom-events';
import * as styles from './outline-card.css';
const styles = css`
:host {
display: block;
position: relative;
}
.card-container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.card-preview {
position: relative;
width: 100%;
border-radius: 4px;
cursor: default;
user-select: none;
}
.card-preview:hover {
background: var(--affine-hover-color);
}
.card-header-container {
padding: 0 8px;
width: 100%;
min-height: 28px;
display: none;
align-items: center;
gap: 8px;
box-sizing: border-box;
}
.card-header-container.enable-sorting {
display: flex;
}
.card-header-container .card-number {
text-align: center;
font-size: var(--affine-font-sm);
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
color: var(--affine-brand-color, #1e96eb);
font-weight: 500;
line-height: 14px;
line-height: 20px;
}
.card-header-container .card-header-icon {
display: flex;
align-items: center;
justify-content: center;
}
.card-header-container .card-divider {
height: 1px;
flex: 1;
border-top: 1px dashed var(--affine-border-color);
transform: translateY(50%);
}
.display-mode-button-group {
display: none;
position: absolute;
right: 8px;
top: -6px;
padding-top: 8px;
padding-bottom: 8px;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
line-height: 20px;
}
.card-preview:hover .display-mode-button-group {
display: flex;
}
.display-mode-button-label {
color: var(--affine-text-primary-color);
}
.display-mode-button {
display: flex;
border-radius: 4px;
background-color: var(--affine-hover-color);
align-items: center;
}
.current-mode-label {
display: flex;
padding: 2px 0px 2px 4px;
align-items: center;
}
note-display-mode-panel {
position: absolute;
display: none;
background: var(--affine-background-overlay-panel-color);
border-radius: 8px;
box-shadow: var(--affine-shadow-2);
box-sizing: border-box;
padding: 8px;
font-size: var(--affine-font-sm);
color: var(--affine-text-primary-color);
line-height: 22px;
font-weight: 400;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
}
note-display-mode-panel[data-show] {
display: flex;
}
.card-content {
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
user-select: none;
color: var(--affine-text-primary-color);
}
.card-preview .card-content:hover {
cursor: pointer;
}
.card-container[data-invisible='false']
.card-preview
.card-header-container:hover {
cursor: grab;
}
.card-container.placeholder {
pointer-events: none;
opacity: 0.5;
}
.card-container.selected .card-preview {
background: var(--affine-hover-color);
}
.card-container.placeholder .card-preview {
background: var(--affine-hover-color);
opacity: 0.9;
}
.card-container[data-sortable='true'] {
padding: 2px 0;
}
.card-container[data-invisible='true'] .card-header-container .card-number,
.card-container[data-invisible='true']
.card-header-container
.card-header-icon,
.card-container[data-invisible='true'] .card-preview .card-content {
color: var(--affine-text-disable-color);
pointer-events: none;
}
.card-preview outline-block-preview:hover {
color: var(--affine-brand-color);
}
`;
export const AFFINE_OUTLINE_NOTE_CARD = 'affine-outline-note-card'; export const AFFINE_OUTLINE_NOTE_CARD = 'affine-outline-note-card';
export class OutlineNoteCard extends SignalWatcher(WithDisposable(LitElement)) { export class OutlineNoteCard extends SignalWatcher(
static override styles = styles; WithDisposable(ShadowlessElement)
) {
private _displayModePopper: ReturnType<typeof createButtonPopper> | null = private _displayModePopper: ReturnType<typeof createButtonPopper> | null =
null; null;
@@ -283,6 +113,10 @@ export class OutlineNoteCard extends SignalWatcher(WithDisposable(LitElement)) {
} }
} }
get invisible() {
return this.note.displayMode === NoteDisplayMode.EdgelessOnly;
}
override updated() { override updated() {
this._displayModePopper = createButtonPopper( this._displayModePopper = createButtonPopper(
this._displayModeButtonGroup, this._displayModeButtonGroup,
@@ -302,50 +136,49 @@ export class OutlineNoteCard extends SignalWatcher(WithDisposable(LitElement)) {
override render() { override render() {
const { children, displayMode } = this.note; const { children, displayMode } = this.note;
const currentMode = this._getCurrentModeLabel(displayMode); const currentMode = this._getCurrentModeLabel(displayMode);
const cardHeaderClasses = classMap({
'card-header-container': true,
'enable-sorting': this.enableNotesSorting,
});
return html` return html`
<div <div
data-invisible="${this.invisible ? 'true' : 'false'}" data-invisible=${this.invisible}
data-sortable="${this.enableNotesSorting ? 'true' : 'false'}" data-sortable=${this.enableNotesSorting}
class="card-container ${this.status ?? ''} ${this.theme}" data-status=${this.status}
class=${styles.outlineCard}
> >
<div <div
class="card-preview" class=${styles.cardPreview}
@mousedown=${this._dispatchDragEvent} @mousedown=${this._dispatchDragEvent}
@click=${this._dispatchSelectEvent} @click=${this._dispatchSelectEvent}
@dblclick=${this._dispatchFitViewEvent} @dblclick=${this._dispatchFitViewEvent}
> >
${html`<div class=${cardHeaderClasses}> ${html`<div class=${styles.cardHeader}>
${ ${
this.invisible this.invisible
? html`<span class="card-header-icon">${HiddenIcon}</span>` ? html`<span class=${styles.headerIcon}>${HiddenIcon}</span>`
: html`<span class="card-number">${this.number}</span>` : html`<span class=${styles.headerNumber}>${this.number}</span>`
} }
<span class="card-divider"></span> <span class=${styles.divider}></span>
<div class="display-mode-button-group"> <div class=${styles.displayModeButtonGroup}>
<span class="display-mode-button-label">Show in</span> <span>Show in</span>
<edgeless-tool-icon-button <edgeless-tool-icon-button
.tooltip=${this._showPopper ? '' : 'Display Mode'} .tooltip=${this._showPopper ? '' : 'Display Mode'}
.tipPosition=${'left-start'} .tipPosition=${'left-start'}
.iconContainerPadding=${0} .iconContainerPadding=${0}
data-testid="display-mode-button"
@click=${(e: MouseEvent) => { @click=${(e: MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
this._displayModePopper?.toggle(); this._displayModePopper?.toggle();
}} }}
@dblclick=${(e: MouseEvent) => e.stopPropagation()} @dblclick=${(e: MouseEvent) => e.stopPropagation()}
> >
<div class="display-mode-button"> <div class=${styles.displayModeButton}>
<span class="current-mode-label">${currentMode}</span> <span class=${styles.currentModeLabel}>${currentMode}</span>
${SmallArrowDownIcon} ${SmallArrowDownIcon}
</div> </div>
</edgeless-tool-icon-button> </edgeless-tool-icon-button>
</div> </div>
</div> </div>
<note-display-mode-panel <note-display-mode-panel
class=${styles.modeChangePanel}
.displayMode=${displayMode} .displayMode=${displayMode}
.panelWidth=${220} .panelWidth=${220}
.onSelect=${(newMode: NoteDisplayMode) => { .onSelect=${(newMode: NoteDisplayMode) => {
@@ -355,7 +188,7 @@ export class OutlineNoteCard extends SignalWatcher(WithDisposable(LitElement)) {
> >
</note-display-mode-panel> </note-display-mode-panel>
</div>`} </div>`}
<div class="card-content"> <div class=${styles.cardContent}>
${children.map(block => { ${children.map(block => {
return html`<affine-outline-block-preview return html`<affine-outline-block-preview
.block=${block} .block=${block}
@@ -377,7 +210,7 @@ export class OutlineNoteCard extends SignalWatcher(WithDisposable(LitElement)) {
`; `;
} }
@query('.display-mode-button-group') @query(`.${styles.displayModeButtonGroup}`)
private accessor _displayModeButtonGroup!: HTMLDivElement; private accessor _displayModeButtonGroup!: HTMLDivElement;
@query('note-display-mode-panel') @query('note-display-mode-panel')
@@ -389,18 +222,12 @@ export class OutlineNoteCard extends SignalWatcher(WithDisposable(LitElement)) {
@property({ attribute: false }) @property({ attribute: false })
accessor activeHeadingId: string | null = null; accessor activeHeadingId: string | null = null;
@property({ attribute: false })
accessor doc!: Store;
@property({ attribute: false }) @property({ attribute: false })
accessor enableNotesSorting!: boolean; accessor enableNotesSorting!: boolean;
@property({ attribute: false }) @property({ attribute: false })
accessor index!: number; accessor index!: number;
@property({ attribute: false })
accessor invisible = false;
@property({ attribute: false }) @property({ attribute: false })
accessor note!: NoteBlockModel; accessor note!: NoteBlockModel;
@@ -411,10 +238,7 @@ export class OutlineNoteCard extends SignalWatcher(WithDisposable(LitElement)) {
accessor showPreviewIcon!: boolean; accessor showPreviewIcon!: boolean;
@property({ attribute: false }) @property({ attribute: false })
accessor status: 'selected' | 'placeholder' | undefined = undefined; accessor status: 'selected' | 'placeholder' | 'normal' = 'normal';
@property({ attribute: false })
accessor theme!: ColorScheme;
} }
declare global { declare global {

View File

@@ -0,0 +1,114 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const outlineBlockPreview = style({
fontFamily: cssVar('fontFamily'),
width: '100%',
boxSizing: 'border-box',
padding: '6px 8px',
whiteSpace: 'nowrap',
display: 'flex',
justifyContent: 'start',
alignItems: 'center',
gap: '8px',
':hover': {
cursor: 'pointer',
background: cssVarV2('layer/background/hoverOverlay'),
},
selectors: {
'.active > &': {
color: cssVarV2('text/emphasis'),
},
'&:not(:has(span))': {
display: 'none',
},
},
});
export const icon = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '22px',
height: '22px',
boxSizing: 'border-box',
padding: '4px',
background: cssVarV2('layer/background/secondary'),
borderRadius: '4px',
color: cssVarV2('icon/primary'),
});
export const iconDisabled = style({
color: cssVarV2('icon/disable'),
});
export const text = style({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
flex: 1,
fontSize: cssVar('fontSm'),
lineHeight: '22px',
height: '22px',
});
export const textGeneral = style({
fontWeight: 400,
paddingLeft: '28px',
});
export const subtypeStyles = {
title: style({
fontWeight: 600,
paddingLeft: '0',
}),
h1: style({
fontWeight: 600,
paddingLeft: '0',
}),
h2: style({
fontWeight: 600,
paddingLeft: '4px',
}),
h3: style({
fontWeight: 600,
paddingLeft: '12px',
}),
h4: style({
fontWeight: 600,
paddingLeft: '16px',
}),
h5: style({
fontWeight: 600,
paddingLeft: '20px',
}),
h6: style({
fontWeight: 600,
paddingLeft: '24px',
}),
};
export const textSpan = style({
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
});
export const linkedDocText = style({
fontSize: 'inherit',
borderBottom: `0.5px solid ${cssVar('dividerColor')}`,
whiteSpace: 'break-spaces',
marginRight: '2px',
});
export const linkedDocPreviewUnavailable = style({
color: cssVarV2('text/disable'),
});
export const linkedDocTextUnavailable = style({
color: cssVarV2('text/disable'),
textDecoration: 'line-through',
});

View File

@@ -1,22 +1,28 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types'; import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import type { import { ShadowlessElement } from '@blocksuite/block-std';
AttachmentBlockModel, import {
BookmarkBlockModel, type AttachmentBlockModel,
CodeBlockModel, type BookmarkBlockModel,
DatabaseBlockModel, type CodeBlockModel,
ImageBlockModel, type DatabaseBlockModel,
ListBlockModel, DocDisplayMetaProvider,
ParagraphBlockModel, type ImageBlockModel,
RootBlockModel, type ListBlockModel,
type ParagraphBlockModel,
type RootBlockModel,
} from '@blocksuite/blocks'; } from '@blocksuite/blocks';
import { noop, SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; import { noop, SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { LinkedPageIcon } from '@blocksuite/icons/lit';
import type { DeltaInsert } from '@blocksuite/inline'; import type { DeltaInsert } from '@blocksuite/inline';
import { css, html, LitElement, nothing } from 'lit'; import { consume } from '@lit/context';
import { html, nothing } from 'lit';
import { property } from 'lit/decorators.js'; import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { SmallLinkedDocIcon } from '../../_common/icons.js'; import type { AffineEditorContainer } from '../../../editors/editor-container.js';
import { placeholderMap, previewIconMap } from '../config.js'; import { editorContext, placeholderMap, previewIconMap } from '../config.js';
import { isHeadingBlock, isRootBlock } from '../utils/query.js'; import { isHeadingBlock, isRootBlock } from '../utils/query.js';
import * as styles from './outline-preview.css';
type ValuesOf<T, K extends keyof T = keyof T> = T[K]; type ValuesOf<T, K extends keyof T = keyof T> = T[K];
@@ -24,147 +30,16 @@ function assertType<T>(value: unknown): asserts value is T {
noop(value); noop(value);
} }
const styles = css`
:host {
display: block;
width: 100%;
font-family: var(--affine-font-family);
}
:host(:hover) {
cursor: pointer;
background: var(--affine-hover-color);
}
:host(.active) {
color: var(--affine-text-emphasis-color);
}
.outline-block-preview {
width: 100%;
box-sizing: border-box;
padding: 6px 8px;
white-space: nowrap;
display: flex;
justify-content: start;
align-items: center;
gap: 8px;
}
.icon {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
box-sizing: border-box;
padding: 4px;
background: var(--affine-background-secondary-color);
border-radius: 4px;
color: var(--affine-icon-color);
}
.icon.disabled {
color: var(--affine-disabled-icon-color);
}
.text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
font-size: var(--affine-font-sm);
line-height: 22px;
height: 22px;
}
.text.general,
.subtype.text,
.subtype.quote {
font-weight: 400;
padding-left: 28px;
}
.subtype.title,
.subtype.h1,
.subtype.h2,
.subtype.h3,
.subtype.h4,
.subtype.h5,
.subtype.h6 {
font-weight: 600;
}
.subtype.title {
padding-left: 0;
}
.subtype.h1 {
padding-left: 0;
}
.subtype.h2 {
padding-left: 4px;
}
.subtype.h3 {
padding-left: 12px;
}
.subtype.h4 {
padding-left: 16px;
}
.subtype.h5 {
padding-left: 20px;
}
.subtype.h6 {
padding-left: 24px;
}
.outline-block-preview:not(:has(span)) {
display: none;
}
.text span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.linked-doc-preview svg {
width: 1.1em;
height: 1.1em;
vertical-align: middle;
font-size: inherit;
margin-bottom: 0.1em;
}
.linked-doc-text {
font-size: inherit;
border-bottom: 0.5px solid var(--affine-divider-color);
white-space: break-spaces;
margin-right: 2px;
}
.linked-doc-preview.unavailable svg {
color: var(--affine-text-disable-color);
}
.linked-doc-preview.unavailable .linked-doc-text {
color: var(--affine-text-disable-color);
text-decoration: line-through;
}
`;
export const AFFINE_OUTLINE_BLOCK_PREVIEW = 'affine-outline-block-preview'; export const AFFINE_OUTLINE_BLOCK_PREVIEW = 'affine-outline-block-preview';
export class OutlineBlockPreview extends SignalWatcher( export class OutlineBlockPreview extends SignalWatcher(
WithDisposable(LitElement) WithDisposable(ShadowlessElement)
) { ) {
static override styles = styles;
private _TextBlockPreview(block: ParagraphBlockModel | ListBlockModel) { private _TextBlockPreview(block: ParagraphBlockModel | ListBlockModel) {
const deltas: DeltaInsert<AffineTextAttributes>[] = const deltas: DeltaInsert<AffineTextAttributes>[] =
block.text.yText.toDelta(); block.text.yText.toDelta();
if (!block.text.length) return nothing; if (!block.text.length) return nothing;
const iconClass = this.disabledIcon ? 'icon disabled' : 'icon'; const iconClass = this.disabledIcon ? styles.iconDisabled : styles.icon;
const previewText = deltas.map(delta => { const previewText = deltas.map(delta => {
if (delta.attributes?.reference) { if (delta.attributes?.reference) {
@@ -174,37 +49,65 @@ export class OutlineBlockPreview extends SignalWatcher(
doc => doc.id === refAttribute.pageId doc => doc.id === refAttribute.pageId
); );
const unavailable = !refMeta; const unavailable = !refMeta;
const title = unavailable ? 'Deleted doc' : refMeta.title; const docDisplayMetaService = this.editor.std.get(
DocDisplayMetaProvider
);
const icon = unavailable
? LinkedPageIcon({ width: '1.1em', height: '1.1em' })
: docDisplayMetaService.icon(refMeta.id).value;
const title = unavailable
? 'Deleted doc'
: docDisplayMetaService.title(refMeta.id).value;
return html`<span return html`<span
class="linked-doc-preview ${unavailable ? 'unavailable' : ''}" class=${classMap({
>${SmallLinkedDocIcon} [styles.linkedDocPreviewUnavailable]: unavailable,
<span class="linked-doc-text" })}
>
${icon}
<span
class=${classMap({
[styles.linkedDocText]: true,
[styles.linkedDocTextUnavailable]: unavailable,
})}
>${title.length ? title : 'Untitled'}</span >${title.length ? title : 'Untitled'}</span
></span ></span
>`; >`;
} else { } else {
// If not linked doc, render the text. // If not linked doc, render the text.
return delta.insert.toString().trim().length > 0 return delta.insert.toString().trim().length > 0
? html`<span>${delta.insert.toString()}</span>` ? html`<span class=${styles.textSpan}
>${delta.insert.toString()}</span
>`
: nothing; : nothing;
} }
}); });
return html`<span class="text subtype ${block.type}">${previewText}</span> const headingClass =
block.type in styles.subtypeStyles
? styles.subtypeStyles[block.type as keyof typeof styles.subtypeStyles]
: '';
return html`<span
data-testid="outline-block-preview-${block.type}"
class="${styles.text} ${styles.textGeneral} ${headingClass}"
>${previewText}</span
>
${this.showPreviewIcon ${this.showPreviewIcon
? html`<span class=${iconClass}>${previewIconMap[block.type]}</span>` ? html`<span class=${iconClass}>${previewIconMap[block.type]}</span>`
: nothing}`; : nothing}`;
} }
override render() { override render() {
return html`<div class="outline-block-preview"> return html`<div class=${styles.outlineBlockPreview}>
${this.renderBlockByFlavour()} ${this.renderBlockByFlavour()}
</div>`; </div>`;
} }
renderBlockByFlavour() { renderBlockByFlavour() {
const { block } = this; const { block } = this;
const iconClass = this.disabledIcon ? 'icon disabled' : 'icon'; const iconClass = this.disabledIcon ? styles.iconDisabled : styles.icon;
if ( if (
!this.enableNotesSorting && !this.enableNotesSorting &&
@@ -217,7 +120,10 @@ export class OutlineBlockPreview extends SignalWatcher(
case 'affine:page': case 'affine:page':
assertType<RootBlockModel>(block); assertType<RootBlockModel>(block);
return block.title.length > 0 return block.title.length > 0
? html`<span class="text subtype title"> ? html`<span
data-testid="outline-block-preview-title"
class="${styles.text} ${styles.subtypeStyles.title}"
>
${block.title$.value} ${block.title$.value}
</span>` </span>`
: nothing; : nothing;
@@ -230,7 +136,7 @@ export class OutlineBlockPreview extends SignalWatcher(
case 'affine:bookmark': case 'affine:bookmark':
assertType<BookmarkBlockModel>(block); assertType<BookmarkBlockModel>(block);
return html` return html`
<span class="text general" <span class="${styles.text} ${styles.textGeneral}"
>${block.title || block.url || placeholderMap['bookmark']}</span >${block.title || block.url || placeholderMap['bookmark']}</span
> >
${this.showPreviewIcon ${this.showPreviewIcon
@@ -242,7 +148,7 @@ export class OutlineBlockPreview extends SignalWatcher(
case 'affine:code': case 'affine:code':
assertType<CodeBlockModel>(block); assertType<CodeBlockModel>(block);
return html` return html`
<span class="text general" <span class="${styles.text} ${styles.textGeneral}"
>${block.language ?? placeholderMap['code']}</span >${block.language ?? placeholderMap['code']}</span
> >
${this.showPreviewIcon ${this.showPreviewIcon
@@ -252,7 +158,7 @@ export class OutlineBlockPreview extends SignalWatcher(
case 'affine:database': case 'affine:database':
assertType<DatabaseBlockModel>(block); assertType<DatabaseBlockModel>(block);
return html` return html`
<span class="text general" <span class="${styles.text} ${styles.textGeneral}"
>${block.title.toString().length >${block.title.toString().length
? block.title.toString() ? block.title.toString()
: placeholderMap['database']}</span : placeholderMap['database']}</span
@@ -264,7 +170,7 @@ export class OutlineBlockPreview extends SignalWatcher(
case 'affine:image': case 'affine:image':
assertType<ImageBlockModel>(block); assertType<ImageBlockModel>(block);
return html` return html`
<span class="text general" <span class="${styles.text} ${styles.textGeneral}"
>${block.caption?.length >${block.caption?.length
? block.caption ? block.caption
: placeholderMap['image']}</span : placeholderMap['image']}</span
@@ -276,7 +182,7 @@ export class OutlineBlockPreview extends SignalWatcher(
case 'affine:attachment': case 'affine:attachment':
assertType<AttachmentBlockModel>(block); assertType<AttachmentBlockModel>(block);
return html` return html`
<span class="text general" <span class="${styles.text} ${styles.textGeneral}"
>${block.name?.length >${block.name?.length
? block.name ? block.name
: placeholderMap['attachment']}</span : placeholderMap['attachment']}</span
@@ -292,6 +198,10 @@ export class OutlineBlockPreview extends SignalWatcher(
} }
} }
@consume({ context: editorContext })
@property({ attribute: false })
accessor editor!: AffineEditorContainer;
@property({ attribute: false }) @property({ attribute: false })
accessor block!: ValuesOf<BlockSuite.BlockModels>; accessor block!: ValuesOf<BlockSuite.BlockModels>;

View File

@@ -1,6 +1,8 @@
import type { ParagraphBlockModel } from '@blocksuite/blocks'; import type { ParagraphBlockModel } from '@blocksuite/blocks';
import { createContext } from '@lit/context';
import type { TemplateResult } from 'lit'; import type { TemplateResult } from 'lit';
import type { AffineEditorContainer } from '../../editors/editor-container.js';
import { import {
BlockPreviewIcon, BlockPreviewIcon,
SmallAttachmentIcon, SmallAttachmentIcon,
@@ -84,3 +86,6 @@ export type OutlineSettingsDataType = {
showIcons: boolean; showIcons: boolean;
enableSorting: boolean; enableSorting: boolean;
}; };
export const editorContext =
createContext<AffineEditorContainer>('editorContext');

View File

@@ -0,0 +1,44 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const host = style({});
export const container = style({
display: 'flex',
width: '100%',
height: '40px',
alignItems: 'center',
justifyContent: 'space-between',
boxSizing: 'border-box',
padding: '8px 16px',
});
export const noteSettingContainer = style({
display: 'flex',
alignItems: 'center',
gap: '8px',
});
export const label = style({
width: '119px',
height: '22px',
fontSize: '14px',
fontWeight: 500,
lineHeight: '22px',
color: cssVarV2('text/secondary'),
});
export const notePreviewSettingContainer = style({
display: 'none',
justifyContent: 'center',
alignItems: 'center',
background: cssVarV2('layer/background/overlayPanel'),
boxShadow: cssVar('shadow2'),
borderRadius: '8px',
selectors: {
'&[data-show]': {
display: 'flex',
},
},
});

View File

@@ -1,84 +1,15 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import { createButtonPopper } from '@blocksuite/blocks'; import { createButtonPopper } from '@blocksuite/blocks';
import { WithDisposable } from '@blocksuite/global/utils'; import { WithDisposable } from '@blocksuite/global/utils';
import { css, html, LitElement } from 'lit'; import { html } from 'lit';
import { property, query, state } from 'lit/decorators.js'; import { property, query, state } from 'lit/decorators.js';
import { SettingsIcon, SortingIcon } from '../../_common/icons.js'; import { SettingsIcon, SortingIcon } from '../../_common/icons.js';
import * as styles from './outline-panel-header.css';
const styles = css`
:host {
display: flex;
width: 100%;
height: 40px;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
padding: 8px 16px;
}
.outline-panel-header-container {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 100%;
box-sizing: border-box;
padding-right: 6px;
}
.note-setting-container {
display: flex;
align-items: center;
gap: 8px;
}
.outline-panel-header-label {
width: 119px;
height: 22px;
font-size: 14px;
font-weight: 500;
line-height: 22px;
color: var(--affine-text-secondary-color, #8e8d91);
}
.note-sorting-button {
justify-self: end;
}
.note-setting-button svg,
.note-sorting-button svg {
color: var(--affine-icon-secondary);
}
.note-setting-button:hover svg,
.note-setting-button.active svg,
.note-sorting-button:hover svg {
color: var(--affine-icon-color);
}
.note-sorting-button.active svg {
color: var(--affine-primary-color);
}
.note-preview-setting-container {
display: none;
justify-content: center;
align-items: center;
background: var(--affine-background-overlay-panel-color);
box-shadow: var(--affine-shadow-2);
border-radius: 8px;
}
.note-preview-setting-container[data-show] {
display: flex;
}
`;
export const AFFINE_OUTLINE_PANEL_HEADER = 'affine-outline-panel-header'; export const AFFINE_OUTLINE_PANEL_HEADER = 'affine-outline-panel-header';
export class OutlinePanelHeader extends WithDisposable(LitElement) { export class OutlinePanelHeader extends WithDisposable(ShadowlessElement) {
static override styles = styles;
private _notePreviewSettingMenuPopper: ReturnType< private _notePreviewSettingMenuPopper: ReturnType<
typeof createButtonPopper typeof createButtonPopper
> | null = null; > | null = null;
@@ -101,13 +32,11 @@ export class OutlinePanelHeader extends WithDisposable(LitElement) {
} }
override render() { override render() {
return html`<div class="outline-panel-header-container"> return html`<div class=${styles.container}>
<div class="note-setting-container"> <div class=${styles.noteSettingContainer}>
<span class="outline-panel-header-label">Table of Contents</span> <span class=${styles.label}>Table of Contents</span>
<edgeless-tool-icon-button <edgeless-tool-icon-button
class="note-setting-button ${this._settingPopperShow class="${this._settingPopperShow ? 'active' : ''}"
? 'active'
: ''}"
.tooltip=${this._settingPopperShow ? '' : 'Preview Settings'} .tooltip=${this._settingPopperShow ? '' : 'Preview Settings'}
.tipPosition=${'bottom'} .tipPosition=${'bottom'}
.active=${this._settingPopperShow} .active=${this._settingPopperShow}
@@ -118,7 +47,8 @@ export class OutlinePanelHeader extends WithDisposable(LitElement) {
</edgeless-tool-icon-button> </edgeless-tool-icon-button>
</div> </div>
<edgeless-tool-icon-button <edgeless-tool-icon-button
class="note-sorting-button ${this.enableNotesSorting ? 'active' : ''}" data-testid="toggle-notes-sorting-button"
class="${this.enableNotesSorting ? 'active' : ''}"
.tooltip=${'Visibility and sort'} .tooltip=${'Visibility and sort'}
.tipPosition=${'left'} .tipPosition=${'left'}
.iconContainerPadding=${0} .iconContainerPadding=${0}
@@ -129,7 +59,7 @@ export class OutlinePanelHeader extends WithDisposable(LitElement) {
${SortingIcon} ${SortingIcon}
</edgeless-tool-icon-button> </edgeless-tool-icon-button>
</div> </div>
<div class="note-preview-setting-container"> <div class=${styles.notePreviewSettingContainer}>
<affine-outline-note-preview-setting-menu <affine-outline-note-preview-setting-menu
.showPreviewIcon=${this.showPreviewIcon} .showPreviewIcon=${this.showPreviewIcon}
.toggleShowPreviewIcon=${this.toggleShowPreviewIcon} .toggleShowPreviewIcon=${this.toggleShowPreviewIcon}
@@ -137,10 +67,10 @@ export class OutlinePanelHeader extends WithDisposable(LitElement) {
</div>`; </div>`;
} }
@query('.note-preview-setting-container') @query(`.${styles.notePreviewSettingContainer}`)
private accessor _notePreviewSettingMenu!: HTMLDivElement; private accessor _notePreviewSettingMenu!: HTMLDivElement;
@query('.note-setting-button') @query(`.${styles.noteSettingContainer}`)
private accessor _noteSettingButton!: HTMLDivElement; private accessor _noteSettingButton!: HTMLDivElement;
@state() @state()

View File

@@ -0,0 +1,47 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const host = style({});
export const notePreviewSettingMenuContainer = style({
padding: '8px',
width: '220px',
display: 'flex',
flexDirection: 'column',
});
export const notePreviewSettingMenuItem = style({
display: 'flex',
boxSizing: 'border-box',
width: '100%',
height: '28px',
padding: '4px 12px',
alignItems: 'center',
});
export const settingLabel = style({
fontFamily: 'sans-serif',
fontSize: '12px',
fontWeight: 500,
lineHeight: '20px',
color: cssVarV2('text/secondary'),
padding: '0 4px',
});
export const action = style({
gap: '4px',
});
export const actionLabel = style({
width: '138px',
height: '20px',
padding: '0 4px',
fontSize: '12px',
fontWeight: 500,
lineHeight: '20px',
color: cssVarV2('text/primary'),
});
export const toggleButton = style({
display: 'flex',
});

View File

@@ -1,76 +1,27 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils'; import { WithDisposable } from '@blocksuite/global/utils';
import { css, html, LitElement } from 'lit'; import { html } from 'lit';
import { property } from 'lit/decorators.js'; import { property } from 'lit/decorators.js';
const styles = css` import * as styles from './outline-setting-menu.css';
:host {
display: block;
box-sizing: border-box;
padding: 8px;
width: 220px;
}
.note-preview-setting-menu-container {
display: flex;
flex-direction: column;
box-sizing: border-box;
width: 100%;
}
.note-preview-setting-menu-item {
display: flex;
box-sizing: border-box;
width: 100%;
height: 28px;
padding: 4px 12px;
align-items: center;
}
.note-preview-setting-menu-item .setting-label {
font-family: sans-serif;
font-size: 12px;
font-weight: 500;
line-height: 20px;
color: var(--affine-text-secondary-color);
padding: 0 4px;
}
.note-preview-setting-menu-item.action {
gap: 4px;
}
.note-preview-setting-menu-item .action-label {
width: 138px;
height: 20px;
padding: 0 4px;
font-size: 12px;
font-weight: 500;
line-height: 20px;
color: var(--affine-text-primary-color);
}
.note-preview-setting-menu-item .toggle-button {
display: flex;
}
`;
export const AFFINE_OUTLINE_NOTE_PREVIEW_SETTING_MENU = export const AFFINE_OUTLINE_NOTE_PREVIEW_SETTING_MENU =
'affine-outline-note-preview-setting-menu'; 'affine-outline-note-preview-setting-menu';
export class OutlineNotePreviewSettingMenu extends WithDisposable(LitElement) { export class OutlineNotePreviewSettingMenu extends WithDisposable(
static override styles = styles; ShadowlessElement
) {
override render() { override render() {
return html`<div return html`<div
class="note-preview-setting-menu-container" class=${styles.notePreviewSettingMenuContainer}
@click=${(e: MouseEvent) => e.stopPropagation()} @click=${(e: MouseEvent) => e.stopPropagation()}
> >
<div class="note-preview-setting-menu-item"> <div class=${styles.notePreviewSettingMenuItem}>
<div class="setting-label">Settings</div> <div class=${styles.settingLabel}>Settings</div>
</div> </div>
<div class="note-preview-setting-menu-item action"> <div class="${styles.notePreviewSettingMenuItem} ${styles.action}">
<div class="action-label">Show type icon</div> <div class=${styles.actionLabel}>Show type icon</div>
<div class="toggle-button"> <div class=${styles.toggleButton}>
<toggle-switch <toggle-switch
.on=${this.showPreviewIcon} .on=${this.showPreviewIcon}
.onChange=${this.toggleShowPreviewIcon} .onChange=${this.toggleShowPreviewIcon}

View File

@@ -173,10 +173,7 @@ export class MobileOutlineMenu extends SignalWatcher(
...headingBlocks, ...headingBlocks,
]; ];
return html` return repeat(items, block => block.id, this.renderItem);
${repeat(items, block => block.id, this.renderItem)}
</div>
`;
} }
@property({ attribute: false }) @property({ attribute: false })

View File

@@ -0,0 +1,15 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const outlinePanel = style({
display: 'flex',
flexDirection: 'column',
backgroundColor: cssVarV2('layer/background/primary'),
boxSizing: 'border-box',
width: '100%',
height: '100%',
fontFamily: cssVar('fontSansFamily'),
paddingTop: '8px',
position: 'relative',
});

View File

@@ -1,61 +1,30 @@
import {
PropTypes,
requiredProperties,
ShadowlessElement,
} from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { provide } from '@lit/context';
import { effect } from '@preact/signals-core'; import { effect } from '@preact/signals-core';
import { baseTheme } from '@toeverything/theme'; import { html, type PropertyValues } from 'lit';
import { css, html, LitElement, type PropertyValues, unsafeCSS } from 'lit';
import { property, state } from 'lit/decorators.js'; import { property, state } from 'lit/decorators.js';
import type { AffineEditorContainer } from '../../editors/editor-container.js'; import type { AffineEditorContainer } from '../../editors/editor-container.js';
import { type OutlineSettingsDataType, outlineSettingsKey } from './config.js'; import {
editorContext,
const styles = css` type OutlineSettingsDataType,
:host { outlineSettingsKey,
display: block; } from './config.js';
width: 100%; import * as styles from './outline-panel.css';
height: 100%;
}
.outline-panel-container {
background-color: var(--affine-background-primary-color);
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: stretch;
height: 100%;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
padding-top: 8px;
position: relative;
}
.outline-panel-body {
flex-grow: 1;
width: 100%;
overflow-y: scroll;
}
.outline-panel-body::-webkit-scrollbar {
width: 4px;
}
.outline-panel-body::-webkit-scrollbar-thumb {
border-radius: 2px;
}
.outline-panel-body:hover::-webkit-scrollbar-thumb {
background-color: var(--affine-black-30);
}
.outline-panel-body::-webkit-scrollbar-track {
background-color: transparent;
}
.outline-panel-body::-webkit-scrollbar-corner {
display: none;
}
`;
export const AFFINE_OUTLINE_PANEL = 'affine-outline-panel'; export const AFFINE_OUTLINE_PANEL = 'affine-outline-panel';
export class OutlinePanel extends SignalWatcher(WithDisposable(LitElement)) { @requiredProperties({
static override styles = styles; editor: PropTypes.object,
})
export class OutlinePanel extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
private readonly _setNoticeVisibility = (visibility: boolean) => { private readonly _setNoticeVisibility = (visibility: boolean) => {
this._noticeVisible = visibility; this._noticeVisible = visibility;
}; };
@@ -79,10 +48,6 @@ export class OutlinePanel extends SignalWatcher(WithDisposable(LitElement)) {
return this.editor.doc; return this.editor.doc;
} }
get edgeless() {
return this.editor.querySelector('affine-edgeless-root');
}
get host() { get host() {
return this.editor.host; return this.editor.host;
} }
@@ -113,6 +78,8 @@ export class OutlinePanel extends SignalWatcher(WithDisposable(LitElement)) {
override connectedCallback() { override connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.classList.add(styles.outlinePanel);
this.disposables.add( this.disposables.add(
effect(() => { effect(() => {
if (this.editor.mode === 'edgeless') { if (this.editor.mode === 'edgeless') {
@@ -138,33 +105,27 @@ export class OutlinePanel extends SignalWatcher(WithDisposable(LitElement)) {
if (!this.host) return; if (!this.host) return;
return html` return html`
<div class="outline-panel-container"> <affine-outline-panel-header
<affine-outline-panel-header .showPreviewIcon=${this._showPreviewIcon}
.showPreviewIcon=${this._showPreviewIcon} .enableNotesSorting=${this._enableNotesSorting}
.enableNotesSorting=${this._enableNotesSorting} .toggleShowPreviewIcon=${this._toggleShowPreviewIcon}
.toggleShowPreviewIcon=${this._toggleShowPreviewIcon} .toggleNotesSorting=${this._toggleNotesSorting}
.toggleNotesSorting=${this._toggleNotesSorting} ></affine-outline-panel-header>
></affine-outline-panel-header> <affine-outline-panel-body
<affine-outline-panel-body .fitPadding=${this.fitPadding}
class="outline-panel-body" .mode=${this.mode}
.doc=${this.doc} .showPreviewIcon=${this._showPreviewIcon}
.fitPadding=${this.fitPadding} .enableNotesSorting=${this._enableNotesSorting}
.edgeless=${this.edgeless} .toggleNotesSorting=${this._toggleNotesSorting}
.editor=${this.editor} .noticeVisible=${this._noticeVisible}
.mode=${this.mode} .setNoticeVisibility=${this._setNoticeVisibility}
.showPreviewIcon=${this._showPreviewIcon} >
.enableNotesSorting=${this._enableNotesSorting} </affine-outline-panel-body>
.toggleNotesSorting=${this._toggleNotesSorting} <affine-outline-notice
.noticeVisible=${this._noticeVisible} .noticeVisible=${this._noticeVisible}
.setNoticeVisibility=${this._setNoticeVisibility} .toggleNotesSorting=${this._toggleNotesSorting}
> .setNoticeVisibility=${this._setNoticeVisibility}
</affine-outline-panel-body> ></affine-outline-notice>
<affine-outline-notice
.noticeVisible=${this._noticeVisible}
.toggleNotesSorting=${this._toggleNotesSorting}
.setNoticeVisibility=${this._setNoticeVisibility}
></affine-outline-notice>
</div>
`; `;
} }
@@ -177,6 +138,7 @@ export class OutlinePanel extends SignalWatcher(WithDisposable(LitElement)) {
@state() @state()
private accessor _showPreviewIcon = false; private accessor _showPreviewIcon = false;
@provide({ context: editorContext })
@property({ attribute: false }) @property({ attribute: false })
accessor editor!: AffineEditorContainer; accessor editor!: AffineEditorContainer;

View File

@@ -1,14 +1,20 @@
import { PropTypes, requiredProperties } from '@blocksuite/block-std'; import {
PropTypes,
requiredProperties,
ShadowlessElement,
} from '@blocksuite/block-std';
import { NoteDisplayMode, scrollbarStyle } from '@blocksuite/blocks'; import { NoteDisplayMode, scrollbarStyle } from '@blocksuite/blocks';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils'; import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { provide } from '@lit/context';
import { signal } from '@preact/signals-core'; import { signal } from '@preact/signals-core';
import { css, html, LitElement, nothing } from 'lit'; import { css, html, nothing } from 'lit';
import { property, query, state } from 'lit/decorators.js'; import { property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js'; import { repeat } from 'lit/directives/repeat.js';
import type { AffineEditorContainer } from '../../editors/editor-container.js'; import type { AffineEditorContainer } from '../../editors/editor-container.js';
import { TocIcon } from '../_common/icons.js'; import { TocIcon } from '../_common/icons.js';
import { editorContext } from './config.js';
import { getHeadingBlocksFromDoc } from './utils/query.js'; import { getHeadingBlocksFromDoc } from './utils/query.js';
import { import {
observeActiveHeadingDuringScroll, observeActiveHeadingDuringScroll,
@@ -20,9 +26,11 @@ export const AFFINE_OUTLINE_VIEWER = 'affine-outline-viewer';
@requiredProperties({ @requiredProperties({
editor: PropTypes.object, editor: PropTypes.object,
}) })
export class OutlineViewer extends SignalWatcher(WithDisposable(LitElement)) { export class OutlineViewer extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css` static override styles = css`
:host { affine-outline-viewer {
display: flex; display: flex;
} }
.outline-viewer-root { .outline-viewer-root {
@@ -117,9 +125,7 @@ export class OutlineViewer extends SignalWatcher(WithDisposable(LitElement)) {
} }
.outline-viewer-item { .outline-viewer-item {
display: flex; width: 100%;
align-items: center;
align-self: stretch;
} }
.outline-viewer-root:hover { .outline-viewer-root:hover {
@@ -281,6 +287,7 @@ export class OutlineViewer extends SignalWatcher(WithDisposable(LitElement)) {
@state() @state()
private accessor _showViewer: boolean = false; private accessor _showViewer: boolean = false;
@provide({ context: editorContext })
@property({ attribute: false }) @property({ attribute: false })
accessor editor!: AffineEditorContainer; accessor editor!: AffineEditorContainer;

View File

@@ -40,7 +40,7 @@ export function getNotesFromDoc(
number: index + 1, number: index + 1,
}; };
if (modes.includes(blockModel.displayMode)) { if (modes.includes(blockModel.displayMode$.value)) {
notes.push(OutlineNoteItem); notes.push(OutlineNoteItem);
} }
}); });

View File

@@ -232,7 +232,7 @@ test.describe('edgeless note element toolbar', () => {
const toc = page.locator('affine-outline-panel'); const toc = page.locator('affine-outline-panel');
await toc.waitFor({ state: 'visible' }); await toc.waitFor({ state: 'visible' });
const highlightNoteCards = toc.locator( const highlightNoteCards = toc.locator(
'affine-outline-note-card > .selected' 'affine-outline-note-card > [data-status="selected"]'
); );
expect(highlightNoteCards).toHaveCount(1); expect(highlightNoteCards).toHaveCount(1);
}); });

View File

@@ -40,7 +40,7 @@ async function openTocPanel(page: Page) {
} }
function getTocHeading(panel: Locator, level: number) { function getTocHeading(panel: Locator, level: number) {
return panel.locator(`.h${level} span`); return panel.getByTestId(`outline-block-preview-h${level}`).locator('span');
} }
async function dragNoteCard(page: Page, fromCard: Locator, toCard: Locator) { async function dragNoteCard(page: Page, fromCard: Locator, toCard: Locator) {
@@ -67,7 +67,7 @@ test('should display title and headings when there are non-empty headings in edi
const toc = await openTocPanel(page); const toc = await openTocPanel(page);
await expect(toc.locator('.title')).toBeVisible(); await expect(toc.getByTestId('outline-block-preview-title')).toBeVisible();
for (let i = 1; i <= 6; i++) { for (let i = 1; i <= 6; i++) {
await expect(getTocHeading(toc, i)).toBeVisible(); await expect(getTocHeading(toc, i)).toBeVisible();
await expect(getTocHeading(toc, i)).toContainText(`Heading ${i}`); await expect(getTocHeading(toc, i)).toContainText(`Heading ${i}`);
@@ -76,7 +76,7 @@ test('should display title and headings when there are non-empty headings in edi
test('should display placeholder when no headings', async ({ page }) => { test('should display placeholder when no headings', async ({ page }) => {
const toc = await openTocPanel(page); const toc = await openTocPanel(page);
const noHeadingPlaceholder = toc.locator('.note-placeholder'); const noHeadingPlaceholder = toc.getByTestId('empty-panel-placeholder');
await createTitle(page); await createTitle(page);
await pressEnter(page); await pressEnter(page);
@@ -98,7 +98,7 @@ test('should not display headings when there are only empty headings', async ({
const toc = await openTocPanel(page); const toc = await openTocPanel(page);
await expect(toc.locator('.title')).toBeHidden(); await expect(toc.getByTestId('outline-block-preview-title')).toBeHidden();
for (let i = 1; i <= 6; i++) { for (let i = 1; i <= 6; i++) {
await expect(getTocHeading(toc, i)).toBeHidden(); await expect(getTocHeading(toc, i)).toBeHidden();
} }
@@ -115,10 +115,12 @@ test('should update panel when modify or clear title or headings', async ({
await title.scrollIntoViewIfNeeded(); await title.scrollIntoViewIfNeeded();
await title.click(); await title.click();
await type(page, 'xxx'); await type(page, 'xxx');
await expect(toc.locator('.title')).toContainText(['Titlexxx']); await expect(toc.getByTestId('outline-block-preview-title')).toContainText([
'Titlexxx',
]);
await selectAllByKeyboard(page); await selectAllByKeyboard(page);
await pressBackspace(page); await pressBackspace(page);
await expect(toc.locator('.title')).toBeHidden(); await expect(toc.getByTestId('outline-block-preview-title')).toBeHidden();
for (let i = 1; i <= 6; i++) { for (let i = 1; i <= 6; i++) {
await headings[i - 1].click(); await headings[i - 1].click();
@@ -210,7 +212,7 @@ test('should scroll to title when click title in outline panel', async ({
const toc = await openTocPanel(page); const toc = await openTocPanel(page);
const titleInPanel = toc.locator('.title'); const titleInPanel = toc.getByTestId('outline-block-preview-title');
await expect(title).not.toBeInViewport(); await expect(title).not.toBeInViewport();
await titleInPanel.click(); await titleInPanel.click();
@@ -225,7 +227,7 @@ test('visibility sorting should be enabled in edgeless mode and disabled in page
const toc = await openTocPanel(page); const toc = await openTocPanel(page);
const sortingButton = toc.locator('.note-sorting-button'); const sortingButton = toc.getByTestId('toggle-notes-sorting-button');
await expect(sortingButton).not.toHaveClass(/active/); await expect(sortingButton).not.toHaveClass(/active/);
expect(toc.locator('[data-sortable="false"]')).toHaveCount(1); expect(toc.locator('[data-sortable="false"]')).toHaveCount(1);
@@ -250,10 +252,10 @@ test('should reorder notes when drag and drop note in outline panel', async ({
const toc = await openTocPanel(page); const toc = await openTocPanel(page);
const docVisibleCards = toc.locator( const docVisibleCards = toc.locator(
'.card-container[data-invisible="false"]' 'affine-outline-note-card [data-invisible="false"]'
); );
const docInvisibleCards = toc.locator( const docInvisibleCards = toc.locator(
'.card-container[data-invisible="true"]' 'affine-outline-note-card [data-invisible="true"]'
); );
await expect(docVisibleCards).toHaveCount(1); await expect(docVisibleCards).toHaveCount(1);
@@ -262,7 +264,7 @@ test('should reorder notes when drag and drop note in outline panel', async ({
while ((await docInvisibleCards.count()) > 0) { while ((await docInvisibleCards.count()) > 0) {
const card = docInvisibleCards.first(); const card = docInvisibleCards.first();
await card.hover(); await card.hover();
await card.locator('.display-mode-button').click(); await card.getByTestId('display-mode-button').click();
await card.locator('note-display-mode-panel').locator('.item.both').click(); await card.locator('note-display-mode-panel').locator('.item.both').click();
} }
@@ -310,10 +312,10 @@ test.describe('advanced visibility control', () => {
const toc = await openTocPanel(page); const toc = await openTocPanel(page);
const docVisibleCard = toc.locator( const docVisibleCard = toc.locator(
'.card-container[data-invisible="false"]' 'affine-outline-note-card [data-invisible="false"]'
); );
const docInvisibleCard = toc.locator( const docInvisibleCard = toc.locator(
'.card-container[data-invisible="true"]' 'affine-outline-note-card [data-invisible="true"]'
); );
await expect(docVisibleCard).toHaveCount(1); await expect(docVisibleCard).toHaveCount(1);
@@ -341,17 +343,17 @@ test.describe('advanced visibility control', () => {
const toc = await openTocPanel(page); const toc = await openTocPanel(page);
const docVisibleCard = toc.locator( const docVisibleCard = toc.locator(
'.card-container[data-invisible="false"]' 'affine-outline-note-card [data-invisible="false"]'
); );
const docInvisibleCard = toc.locator( const docInvisibleCard = toc.locator(
'.card-container[data-invisible="true"]' 'affine-outline-note-card [data-invisible="true"]'
); );
await expect(docVisibleCard).toHaveCount(1); await expect(docVisibleCard).toHaveCount(1);
await expect(docInvisibleCard).toHaveCount(1); await expect(docInvisibleCard).toHaveCount(1);
await docInvisibleCard.hover(); await docInvisibleCard.hover();
await docInvisibleCard.locator('.display-mode-button').click(); await docInvisibleCard.getByTestId('display-mode-button').click();
await docInvisibleCard await docInvisibleCard
.locator('note-display-mode-panel .item.both') .locator('note-display-mode-panel .item.both')
.click(); .click();

View File

@@ -129,7 +129,7 @@ test('should highlight indicator when click item in outline panel', async ({
await indicators.first().hover({ force: true }); await indicators.first().hover({ force: true });
const headingsInPanel = Array.from({ length: 6 }, (_, i) => const headingsInPanel = Array.from({ length: 6 }, (_, i) =>
viewer.locator(`.h${i + 1} > span`) viewer.getByTestId(`outline-block-preview-h${i + 1}`)
); );
await headingsInPanel[2].click(); await headingsInPanel[2].click();
await expect(headings[2]).toBeVisible(); await expect(headings[2]).toBeVisible();
@@ -172,7 +172,9 @@ test('should hide edgeless-only note headings', async ({ page }) => {
const viewer = page.locator('affine-outline-viewer'); const viewer = page.locator('affine-outline-viewer');
await expect(viewer).toBeVisible(); await expect(viewer).toBeVisible();
const h1InPanel = viewer.locator('.h1 > span'); const h1InPanel = viewer
.getByTestId('outline-block-preview-h1')
.locator('span');
await h1InPanel.waitFor({ state: 'visible' }); await h1InPanel.waitFor({ state: 'visible' });
expect(h1InPanel).toContainText(['Heading 1']); expect(h1InPanel).toContainText(['Heading 1']);
}); });

View File

@@ -3947,7 +3947,7 @@ __metadata:
languageName: unknown languageName: unknown
linkType: soft linkType: soft
"@blocksuite/icons@npm:2.2.2, @blocksuite/icons@npm:^2.2.1": "@blocksuite/icons@npm:2.2.2, @blocksuite/icons@npm:^2.2.1, @blocksuite/icons@npm:^2.2.2":
version: 2.2.2 version: 2.2.2
resolution: "@blocksuite/icons@npm:2.2.2" resolution: "@blocksuite/icons@npm:2.2.2"
peerDependencies: peerDependencies:
@@ -4045,12 +4045,15 @@ __metadata:
"@blocksuite/block-std": "workspace:*" "@blocksuite/block-std": "workspace:*"
"@blocksuite/blocks": "workspace:*" "@blocksuite/blocks": "workspace:*"
"@blocksuite/global": "workspace:*" "@blocksuite/global": "workspace:*"
"@blocksuite/icons": "npm:^2.2.2"
"@blocksuite/inline": "workspace:*" "@blocksuite/inline": "workspace:*"
"@blocksuite/store": "workspace:*" "@blocksuite/store": "workspace:*"
"@floating-ui/dom": "npm:^1.6.10" "@floating-ui/dom": "npm:^1.6.10"
"@lit/context": "npm:^1.1.3"
"@lottiefiles/dotlottie-wc": "npm:^0.4.0" "@lottiefiles/dotlottie-wc": "npm:^0.4.0"
"@preact/signals-core": "npm:^1.8.0" "@preact/signals-core": "npm:^1.8.0"
"@toeverything/theme": "npm:^1.1.7" "@toeverything/theme": "npm:^1.1.7"
"@vanilla-extract/css": "npm:^1.17.0"
"@vanilla-extract/vite-plugin": "npm:^4.0.19" "@vanilla-extract/vite-plugin": "npm:^4.0.19"
lit: "npm:^3.2.0" lit: "npm:^3.2.0"
vitest: "npm:^3.0.0" vitest: "npm:^3.0.0"
@@ -7776,7 +7779,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@lit/context@npm:^1.1.2": "@lit/context@npm:^1.1.2, @lit/context@npm:^1.1.3":
version: 1.1.3 version: 1.1.3
resolution: "@lit/context@npm:1.1.3" resolution: "@lit/context@npm:1.1.3"
dependencies: dependencies: