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
+3
View File
@@ -18,12 +18,15 @@
"@blocksuite/block-std": "workspace:*",
"@blocksuite/blocks": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.2",
"@blocksuite/inline": "workspace:*",
"@blocksuite/store": "workspace:*",
"@floating-ui/dom": "^1.6.10",
"@lit/context": "^1.1.3",
"@lottiefiles/dotlottie-wc": "^0.4.0",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.7",
"@vanilla-extract/css": "^1.17.0",
"lit": "^3.2.0",
"yjs": "^13.6.21",
"zod": "^3.23.8"
@@ -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,
});
@@ -1,89 +1,14 @@
import { ShadowlessElement } from '@blocksuite/block-std';
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 { SmallCloseIcon, SortingIcon } from '../../_common/icons.js';
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;
}
`;
import * as styles from './outline-notice.css';
export const AFFINE_OUTLINE_NOTICE = 'affine-outline-notice';
export class OutlineNotice extends WithDisposable(LitElement) {
static override styles = styles;
export class OutlineNotice extends WithDisposable(ShadowlessElement) {
private _handleNoticeButtonClick() {
this.toggleNotesSorting();
this.setNoticeVisibility(false);
@@ -94,29 +19,28 @@ export class OutlineNotice extends WithDisposable(LitElement) {
return nothing;
}
return html`<div class="outline-notice-container">
<div class="outline-notice-header">
<span class="outline-notice-label">SOME CONTENTS HIDDEN</span>
<span
class="outline-notice-close-button"
@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.
return html`
<div class=${styles.outlineNotice}>
<div class=${styles.outlineNoticeHeader}>
<span class=${styles.outlineNoticeLabel}>SOME CONTENTS HIDDEN</span>
<span
class=${styles.outlineNoticeCloseButton}
@click=${() => this.setNoticeVisibility(false)}
>${SmallCloseIcon}</span
>
</div>
<div
class="outline-notice-item button"
@click=${this._handleNoticeButtonClick}
>
<span>Click here or</span>
<span>${SortingIcon}</span>
<span>to organize content.</span>
<div class=${styles.outlineNoticeBody}>
<div class="${styles.notice}">
Some contents are not visible on edgeless.
</div>
<div class="${styles.button}" @click=${this._handleNoticeButtonClick}>
<span class=${styles.buttonSpan}>Click here or</span>
<span class=${styles.buttonSpan}>${SortingIcon}</span>
<span class=${styles.buttonSpan}>to organize content.</span>
</div>
</div>
</div>
</div>`;
`;
}
@property({ attribute: false })
@@ -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',
});
@@ -1,13 +1,9 @@
import { SurfaceSelection } from '@blocksuite/block-std';
import type {
EdgelessRootBlockComponent,
NoteBlockModel,
} from '@blocksuite/blocks';
import { ShadowlessElement, SurfaceSelection } from '@blocksuite/block-std';
import type { NoteBlockModel } from '@blocksuite/blocks';
import {
BlocksUtils,
matchFlavours,
NoteDisplayMode,
ThemeProvider,
} from '@blocksuite/blocks';
import {
Bound,
@@ -15,30 +11,33 @@ import {
SignalWatcher,
WithDisposable,
} from '@blocksuite/global/utils';
import type { Store } from '@blocksuite/store';
import { consume } from '@lit/context';
import { effect, signal } from '@preact/signals-core';
import { css, html, LitElement, nothing, type PropertyValues } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { html, nothing, type PropertyValues } from 'lit';
import { property, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.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 {
ClickBlockEvent,
DisplayModeChangeEvent,
FitViewEvent,
SelectEvent,
} from '../utils/custom-events.js';
import { startDragging } from '../utils/drag.js';
} from '../utils/custom-events';
import { startDragging } from '../utils/drag';
import {
getHeadingBlocksFromDoc,
getNotesFromDoc,
isHeadingBlock,
} from '../utils/query.js';
} from '../utils/query';
import {
observeActiveHeadingDuringScroll,
scrollToBlockWithHighlight,
} from '../utils/scroll.js';
} from '../utils/scroll';
import * as styles from './outline-panel-body.css';
type OutlineNoteItem = {
note: NoteBlockModel;
@@ -54,76 +53,19 @@ type OutlineNoteItem = {
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 class OutlinePanelBody extends SignalWatcher(
WithDisposable(LitElement)
WithDisposable(ShadowlessElement)
) {
static override styles = styles;
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 _docDisposables: DisposableGroup | null = null;
@@ -132,6 +74,21 @@ export class OutlinePanelBody extends SignalWatcher(
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] {
return this.fitPadding
? ([0, 0, 0, 0].map((val, idx) =>
@@ -174,6 +131,8 @@ export class OutlinePanelBody extends SignalWatcher(
};
private _drag() {
const pageVisibleNotes = this._pageVisibleNoteItems$.peek();
const selectedVisibleNotes = this._selectedNotes$.peek().filter(id => {
const model = this.doc.getBlock(id)?.model;
return (
@@ -185,7 +144,7 @@ export class OutlinePanelBody extends SignalWatcher(
if (
selectedVisibleNotes.length === 0 ||
!this._pageVisibleNotes.length ||
!pageVisibleNotes.length ||
!this.doc.root
)
return;
@@ -199,12 +158,11 @@ export class OutlinePanelBody extends SignalWatcher(
this._selectedNotes$.value = selectedVisibleNotes;
}
this._dragging = true;
this._dragging$.value = true;
// cache the notes in case it is changed by other peers
const children = this.doc.root.children.slice() as NoteBlockModel[];
const notes = this._pageVisibleNotes;
const notesMap = this._pageVisibleNotes.reduce((map, note, index) => {
const notesMap = pageVisibleNotes.reduce((map, note, index) => {
map.set(note.note.id, {
...note,
number: index + 1,
@@ -215,11 +173,11 @@ export class OutlinePanelBody extends SignalWatcher(
startDragging({
container: this,
document: this.ownerDocument,
host: this.domHost ?? this.ownerDocument,
host: this.ownerDocument,
doc: this.doc,
outlineListContainer: this.panelListElement,
outlineListContainer: this._pageVisibleList,
onDragEnd: insertIdx => {
this._dragging = false;
this._dragging$.value = false;
this.insertIndex = undefined;
if (insertIdx === undefined) return;
@@ -228,7 +186,7 @@ export class OutlinePanelBody extends SignalWatcher(
insertIdx,
selectedVisibleNotes,
notesMap,
notes,
pageVisibleNotes,
children
);
},
@@ -240,8 +198,11 @@ export class OutlinePanelBody extends SignalWatcher(
}
private _EmptyPanel() {
return html`<div class="no-note-container">
<div class="note-placeholder">
return html`<div class=${styles.emptyPanel}>
<div
data-testid="empty-panel-placeholder"
class=${styles.emptyPanelPlaceholder}
>
Use headings to create a table of contents.
</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() {
if (!this.doc.root) return nothing;
@@ -431,6 +321,61 @@ export class OutlinePanelBody extends SignalWatcher(
></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) {
this._lockActiveHeadingId = true;
this._activeHeadingId$.value = blockId;
@@ -471,69 +416,41 @@ export class OutlinePanelBody extends SignalWatcher(
this._docDisposables = new DisposableGroup();
this._docDisposables.add(
effect(() => {
this._updateNotes();
this._updateNoticeVisibility();
})
);
this._docDisposables.add(
this.doc.slots.blockUpdated.on(payload => {
if (
payload.type === 'update' &&
payload.flavour === 'affine:note' &&
payload.props.key === 'displayMode'
) {
this._updateNotes();
}
effect(() => {
this._updateNotes();
})
);
}
/**
* There are two cases that we should render note list:
* 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;
private _updateNotes() {
if (this._dragging$.value) return;
let hasHeadings = false;
let hasChildrenBlocks = false;
for (const noteItem of noteItems) {
for (const block of noteItem.note.children) {
hasChildrenBlocks = true;
const isRenderableNote = (item: OutlineNoteItem) => {
let hasHeadings = false;
for (const block of item.note.children) {
if (isHeadingBlock(block)) {
hasHeadings = true;
break;
}
}
if (hasHeadings) {
break;
}
}
return hasHeadings || this.enableNotesSorting;
};
return hasHeadings || (this.enableNotesSorting && hasChildrenBlocks);
}
private _updateNotes() {
const rootModel = this.doc.root;
if (this._dragging) return;
if (!rootModel) {
this._pageVisibleNotes = [];
return;
}
this._pageVisibleNotes = getNotesFromDoc(this.doc, [
this._pageVisibleNoteItems$.value = getNotesFromDoc(this.doc, [
NoteDisplayMode.DocAndEdgeless,
NoteDisplayMode.DocOnly,
]);
this._edgelessOnlyNotes = getNotesFromDoc(this.doc, [
]).filter(isRenderableNote);
this._edgelessOnlyNoteItems$.value = getNotesFromDoc(this.doc, [
NoteDisplayMode.EdgelessOnly,
]);
]).filter(isRenderableNote);
}
private _updateNoticeVisibility() {
@@ -544,9 +461,8 @@ export class OutlinePanelBody extends SignalWatcher(
return;
}
const shouldShowNotice = this._pageVisibleNotes.some(
note => note.note.displayMode === NoteDisplayMode.DocOnly
);
const shouldShowNotice =
getNotesFromDoc(this.doc, [NoteDisplayMode.DocOnly]).length > 0;
if (shouldShowNotice && !this.noticeVisible) {
this.setNoticeVisibility(true);
@@ -580,6 +496,8 @@ export class OutlinePanelBody extends SignalWatcher(
override connectedCallback(): void {
super.connectedCallback();
this.classList.add(styles.outlinePanelBody);
this.disposables.add(
observeActiveHeadingDuringScroll(
() => this.editor,
@@ -603,54 +521,33 @@ export class OutlinePanelBody extends SignalWatcher(
}
override render() {
const shouldRenderPageVisibleNotes = this._shouldRenderNoteList(
this._pageVisibleNotes
);
const shouldRenderEdgelessOnlyNotes =
this.renderEdgelessOnlyNotes &&
this._shouldRenderNoteList(this._edgelessOnlyNotes);
const shouldRenderEmptyPanel =
!shouldRenderPageVisibleNotes && !shouldRenderEdgelessOnlyNotes;
return html`
<div class="outline-panel-body-container">
${this._renderDocTitle()}
${shouldRenderEmptyPanel
? this._EmptyPanel()
: this._PanelList(shouldRenderEdgelessOnlyNotes)}
</div>
${this._renderDocTitle()}
${when(
this._shouldRenderEmptyPanel,
() => this._EmptyPanel(),
() => html`
${this._renderPageVisibleCardList()}
${this._renderEdgelessOnlyCardList()}
`
)}
`;
}
override willUpdate(_changedProperties: PropertyValues) {
if (_changedProperties.has('doc') || _changedProperties.has('edgeless')) {
if (_changedProperties.has('editor')) {
this._setDocDisposables();
}
if (_changedProperties.has('edgeless')) {
this._clearHighlightMask();
if (_changedProperties.has('enableNotesSorting')) {
this._updateNoticeVisibility();
}
}
@state()
private accessor _dragging = false;
@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;
@query('.page-visible-card-list')
private accessor _pageVisibleList!: HTMLElement;
@consume({ context: editorContext })
@property({ attribute: false })
accessor editor!: AffineEditorContainer;
@@ -666,12 +563,6 @@ export class OutlinePanelBody extends SignalWatcher(
@property({ attribute: false })
accessor noticeVisible!: boolean;
@query('.outline-panel-body-container')
accessor OutlinePanelContainer!: HTMLElement;
@query('.panel-list')
accessor panelListElement!: HTMLElement;
@property({ attribute: false })
accessor renderEdgelessOnlyNotes: boolean = true;
@@ -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',
},
},
});
@@ -1,5 +1,5 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import {
type ColorScheme,
createButtonPopper,
type NoteBlockModel,
NoteDisplayMode,
@@ -7,189 +7,19 @@ import {
once,
} from '@blocksuite/blocks';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import type { BlockModel, Store } from '@blocksuite/store';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, unsafeCSS } from 'lit';
import type { BlockModel } from '@blocksuite/store';
import { html } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { HiddenIcon, SmallArrowDownIcon } from '../../_common/icons.js';
import type { SelectEvent } from '../utils/custom-events.js';
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);
}
`;
import { HiddenIcon, SmallArrowDownIcon } from '../../_common/icons';
import type { SelectEvent } from '../utils/custom-events';
import * as styles from './outline-card.css';
export const AFFINE_OUTLINE_NOTE_CARD = 'affine-outline-note-card';
export class OutlineNoteCard extends SignalWatcher(WithDisposable(LitElement)) {
static override styles = styles;
export class OutlineNoteCard extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
private _displayModePopper: ReturnType<typeof createButtonPopper> | null =
null;
@@ -283,6 +113,10 @@ export class OutlineNoteCard extends SignalWatcher(WithDisposable(LitElement)) {
}
}
get invisible() {
return this.note.displayMode === NoteDisplayMode.EdgelessOnly;
}
override updated() {
this._displayModePopper = createButtonPopper(
this._displayModeButtonGroup,
@@ -302,50 +136,49 @@ export class OutlineNoteCard extends SignalWatcher(WithDisposable(LitElement)) {
override render() {
const { children, displayMode } = this.note;
const currentMode = this._getCurrentModeLabel(displayMode);
const cardHeaderClasses = classMap({
'card-header-container': true,
'enable-sorting': this.enableNotesSorting,
});
return html`
<div
data-invisible="${this.invisible ? 'true' : 'false'}"
data-sortable="${this.enableNotesSorting ? 'true' : 'false'}"
class="card-container ${this.status ?? ''} ${this.theme}"
data-invisible=${this.invisible}
data-sortable=${this.enableNotesSorting}
data-status=${this.status}
class=${styles.outlineCard}
>
<div
class="card-preview"
class=${styles.cardPreview}
@mousedown=${this._dispatchDragEvent}
@click=${this._dispatchSelectEvent}
@dblclick=${this._dispatchFitViewEvent}
>
${html`<div class=${cardHeaderClasses}>
${html`<div class=${styles.cardHeader}>
${
this.invisible
? html`<span class="card-header-icon">${HiddenIcon}</span>`
: html`<span class="card-number">${this.number}</span>`
? html`<span class=${styles.headerIcon}>${HiddenIcon}</span>`
: html`<span class=${styles.headerNumber}>${this.number}</span>`
}
<span class="card-divider"></span>
<div class="display-mode-button-group">
<span class="display-mode-button-label">Show in</span>
<span class=${styles.divider}></span>
<div class=${styles.displayModeButtonGroup}>
<span>Show in</span>
<edgeless-tool-icon-button
.tooltip=${this._showPopper ? '' : 'Display Mode'}
.tipPosition=${'left-start'}
.iconContainerPadding=${0}
data-testid="display-mode-button"
@click=${(e: MouseEvent) => {
e.stopPropagation();
this._displayModePopper?.toggle();
}}
@dblclick=${(e: MouseEvent) => e.stopPropagation()}
>
<div class="display-mode-button">
<span class="current-mode-label">${currentMode}</span>
<div class=${styles.displayModeButton}>
<span class=${styles.currentModeLabel}>${currentMode}</span>
${SmallArrowDownIcon}
</div>
</edgeless-tool-icon-button>
</div>
</div>
<note-display-mode-panel
class=${styles.modeChangePanel}
.displayMode=${displayMode}
.panelWidth=${220}
.onSelect=${(newMode: NoteDisplayMode) => {
@@ -355,7 +188,7 @@ export class OutlineNoteCard extends SignalWatcher(WithDisposable(LitElement)) {
>
</note-display-mode-panel>
</div>`}
<div class="card-content">
<div class=${styles.cardContent}>
${children.map(block => {
return html`<affine-outline-block-preview
.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;
@query('note-display-mode-panel')
@@ -389,18 +222,12 @@ export class OutlineNoteCard extends SignalWatcher(WithDisposable(LitElement)) {
@property({ attribute: false })
accessor activeHeadingId: string | null = null;
@property({ attribute: false })
accessor doc!: Store;
@property({ attribute: false })
accessor enableNotesSorting!: boolean;
@property({ attribute: false })
accessor index!: number;
@property({ attribute: false })
accessor invisible = false;
@property({ attribute: false })
accessor note!: NoteBlockModel;
@@ -411,10 +238,7 @@ export class OutlineNoteCard extends SignalWatcher(WithDisposable(LitElement)) {
accessor showPreviewIcon!: boolean;
@property({ attribute: false })
accessor status: 'selected' | 'placeholder' | undefined = undefined;
@property({ attribute: false })
accessor theme!: ColorScheme;
accessor status: 'selected' | 'placeholder' | 'normal' = 'normal';
}
declare global {
@@ -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',
});
@@ -1,22 +1,28 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import type {
AttachmentBlockModel,
BookmarkBlockModel,
CodeBlockModel,
DatabaseBlockModel,
ImageBlockModel,
ListBlockModel,
ParagraphBlockModel,
RootBlockModel,
import { ShadowlessElement } from '@blocksuite/block-std';
import {
type AttachmentBlockModel,
type BookmarkBlockModel,
type CodeBlockModel,
type DatabaseBlockModel,
DocDisplayMetaProvider,
type ImageBlockModel,
type ListBlockModel,
type ParagraphBlockModel,
type RootBlockModel,
} from '@blocksuite/blocks';
import { noop, SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { LinkedPageIcon } from '@blocksuite/icons/lit';
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 { classMap } from 'lit/directives/class-map.js';
import { SmallLinkedDocIcon } from '../../_common/icons.js';
import { placeholderMap, previewIconMap } from '../config.js';
import type { AffineEditorContainer } from '../../../editors/editor-container.js';
import { editorContext, placeholderMap, previewIconMap } from '../config.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];
@@ -24,147 +30,16 @@ function assertType<T>(value: unknown): asserts value is T {
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 class OutlineBlockPreview extends SignalWatcher(
WithDisposable(LitElement)
WithDisposable(ShadowlessElement)
) {
static override styles = styles;
private _TextBlockPreview(block: ParagraphBlockModel | ListBlockModel) {
const deltas: DeltaInsert<AffineTextAttributes>[] =
block.text.yText.toDelta();
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 => {
if (delta.attributes?.reference) {
@@ -174,37 +49,65 @@ export class OutlineBlockPreview extends SignalWatcher(
doc => doc.id === refAttribute.pageId
);
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
class="linked-doc-preview ${unavailable ? 'unavailable' : ''}"
>${SmallLinkedDocIcon}
<span class="linked-doc-text"
class=${classMap({
[styles.linkedDocPreviewUnavailable]: unavailable,
})}
>
${icon}
<span
class=${classMap({
[styles.linkedDocText]: true,
[styles.linkedDocTextUnavailable]: unavailable,
})}
>${title.length ? title : 'Untitled'}</span
></span
>`;
} else {
// If not linked doc, render the text.
return delta.insert.toString().trim().length > 0
? html`<span>${delta.insert.toString()}</span>`
? html`<span class=${styles.textSpan}
>${delta.insert.toString()}</span
>`
: 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
? html`<span class=${iconClass}>${previewIconMap[block.type]}</span>`
: nothing}`;
}
override render() {
return html`<div class="outline-block-preview">
return html`<div class=${styles.outlineBlockPreview}>
${this.renderBlockByFlavour()}
</div>`;
}
renderBlockByFlavour() {
const { block } = this;
const iconClass = this.disabledIcon ? 'icon disabled' : 'icon';
const iconClass = this.disabledIcon ? styles.iconDisabled : styles.icon;
if (
!this.enableNotesSorting &&
@@ -217,7 +120,10 @@ export class OutlineBlockPreview extends SignalWatcher(
case 'affine:page':
assertType<RootBlockModel>(block);
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}
</span>`
: nothing;
@@ -230,7 +136,7 @@ export class OutlineBlockPreview extends SignalWatcher(
case 'affine:bookmark':
assertType<BookmarkBlockModel>(block);
return html`
<span class="text general"
<span class="${styles.text} ${styles.textGeneral}"
>${block.title || block.url || placeholderMap['bookmark']}</span
>
${this.showPreviewIcon
@@ -242,7 +148,7 @@ export class OutlineBlockPreview extends SignalWatcher(
case 'affine:code':
assertType<CodeBlockModel>(block);
return html`
<span class="text general"
<span class="${styles.text} ${styles.textGeneral}"
>${block.language ?? placeholderMap['code']}</span
>
${this.showPreviewIcon
@@ -252,7 +158,7 @@ export class OutlineBlockPreview extends SignalWatcher(
case 'affine:database':
assertType<DatabaseBlockModel>(block);
return html`
<span class="text general"
<span class="${styles.text} ${styles.textGeneral}"
>${block.title.toString().length
? block.title.toString()
: placeholderMap['database']}</span
@@ -264,7 +170,7 @@ export class OutlineBlockPreview extends SignalWatcher(
case 'affine:image':
assertType<ImageBlockModel>(block);
return html`
<span class="text general"
<span class="${styles.text} ${styles.textGeneral}"
>${block.caption?.length
? block.caption
: placeholderMap['image']}</span
@@ -276,7 +182,7 @@ export class OutlineBlockPreview extends SignalWatcher(
case 'affine:attachment':
assertType<AttachmentBlockModel>(block);
return html`
<span class="text general"
<span class="${styles.text} ${styles.textGeneral}"
>${block.name?.length
? block.name
: placeholderMap['attachment']}</span
@@ -292,6 +198,10 @@ export class OutlineBlockPreview extends SignalWatcher(
}
}
@consume({ context: editorContext })
@property({ attribute: false })
accessor editor!: AffineEditorContainer;
@property({ attribute: false })
accessor block!: ValuesOf<BlockSuite.BlockModels>;
@@ -1,6 +1,8 @@
import type { ParagraphBlockModel } from '@blocksuite/blocks';
import { createContext } from '@lit/context';
import type { TemplateResult } from 'lit';
import type { AffineEditorContainer } from '../../editors/editor-container.js';
import {
BlockPreviewIcon,
SmallAttachmentIcon,
@@ -84,3 +86,6 @@ export type OutlineSettingsDataType = {
showIcons: boolean;
enableSorting: boolean;
};
export const editorContext =
createContext<AffineEditorContainer>('editorContext');
@@ -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',
},
},
});
@@ -1,84 +1,15 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import { createButtonPopper } from '@blocksuite/blocks';
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 { SettingsIcon, SortingIcon } from '../../_common/icons.js';
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;
}
`;
import * as styles from './outline-panel-header.css';
export const AFFINE_OUTLINE_PANEL_HEADER = 'affine-outline-panel-header';
export class OutlinePanelHeader extends WithDisposable(LitElement) {
static override styles = styles;
export class OutlinePanelHeader extends WithDisposable(ShadowlessElement) {
private _notePreviewSettingMenuPopper: ReturnType<
typeof createButtonPopper
> | null = null;
@@ -101,13 +32,11 @@ export class OutlinePanelHeader extends WithDisposable(LitElement) {
}
override render() {
return html`<div class="outline-panel-header-container">
<div class="note-setting-container">
<span class="outline-panel-header-label">Table of Contents</span>
return html`<div class=${styles.container}>
<div class=${styles.noteSettingContainer}>
<span class=${styles.label}>Table of Contents</span>
<edgeless-tool-icon-button
class="note-setting-button ${this._settingPopperShow
? 'active'
: ''}"
class="${this._settingPopperShow ? 'active' : ''}"
.tooltip=${this._settingPopperShow ? '' : 'Preview Settings'}
.tipPosition=${'bottom'}
.active=${this._settingPopperShow}
@@ -118,7 +47,8 @@ export class OutlinePanelHeader extends WithDisposable(LitElement) {
</edgeless-tool-icon-button>
</div>
<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'}
.tipPosition=${'left'}
.iconContainerPadding=${0}
@@ -129,7 +59,7 @@ export class OutlinePanelHeader extends WithDisposable(LitElement) {
${SortingIcon}
</edgeless-tool-icon-button>
</div>
<div class="note-preview-setting-container">
<div class=${styles.notePreviewSettingContainer}>
<affine-outline-note-preview-setting-menu
.showPreviewIcon=${this.showPreviewIcon}
.toggleShowPreviewIcon=${this.toggleShowPreviewIcon}
@@ -137,10 +67,10 @@ export class OutlinePanelHeader extends WithDisposable(LitElement) {
</div>`;
}
@query('.note-preview-setting-container')
@query(`.${styles.notePreviewSettingContainer}`)
private accessor _notePreviewSettingMenu!: HTMLDivElement;
@query('.note-setting-button')
@query(`.${styles.noteSettingContainer}`)
private accessor _noteSettingButton!: HTMLDivElement;
@state()
@@ -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',
});
@@ -1,76 +1,27 @@
import { ShadowlessElement } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
import { css, html, LitElement } from 'lit';
import { html } from 'lit';
import { property } from 'lit/decorators.js';
const styles = 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;
}
`;
import * as styles from './outline-setting-menu.css';
export const AFFINE_OUTLINE_NOTE_PREVIEW_SETTING_MENU =
'affine-outline-note-preview-setting-menu';
export class OutlineNotePreviewSettingMenu extends WithDisposable(LitElement) {
static override styles = styles;
export class OutlineNotePreviewSettingMenu extends WithDisposable(
ShadowlessElement
) {
override render() {
return html`<div
class="note-preview-setting-menu-container"
class=${styles.notePreviewSettingMenuContainer}
@click=${(e: MouseEvent) => e.stopPropagation()}
>
<div class="note-preview-setting-menu-item">
<div class="setting-label">Settings</div>
<div class=${styles.notePreviewSettingMenuItem}>
<div class=${styles.settingLabel}>Settings</div>
</div>
<div class="note-preview-setting-menu-item action">
<div class="action-label">Show type icon</div>
<div class="toggle-button">
<div class="${styles.notePreviewSettingMenuItem} ${styles.action}">
<div class=${styles.actionLabel}>Show type icon</div>
<div class=${styles.toggleButton}>
<toggle-switch
.on=${this.showPreviewIcon}
.onChange=${this.toggleShowPreviewIcon}
@@ -173,10 +173,7 @@ export class MobileOutlineMenu extends SignalWatcher(
...headingBlocks,
];
return html`
${repeat(items, block => block.id, this.renderItem)}
</div>
`;
return repeat(items, block => block.id, this.renderItem);
}
@property({ attribute: false })
@@ -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',
});
@@ -1,61 +1,30 @@
import {
PropTypes,
requiredProperties,
ShadowlessElement,
} from '@blocksuite/block-std';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { provide } from '@lit/context';
import { effect } from '@preact/signals-core';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, type PropertyValues, unsafeCSS } from 'lit';
import { html, type PropertyValues } from 'lit';
import { property, state } from 'lit/decorators.js';
import type { AffineEditorContainer } from '../../editors/editor-container.js';
import { type OutlineSettingsDataType, outlineSettingsKey } from './config.js';
const styles = css`
:host {
display: block;
width: 100%;
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;
}
`;
import {
editorContext,
type OutlineSettingsDataType,
outlineSettingsKey,
} from './config.js';
import * as styles from './outline-panel.css';
export const AFFINE_OUTLINE_PANEL = 'affine-outline-panel';
export class OutlinePanel extends SignalWatcher(WithDisposable(LitElement)) {
static override styles = styles;
@requiredProperties({
editor: PropTypes.object,
})
export class OutlinePanel extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
private readonly _setNoticeVisibility = (visibility: boolean) => {
this._noticeVisible = visibility;
};
@@ -79,10 +48,6 @@ export class OutlinePanel extends SignalWatcher(WithDisposable(LitElement)) {
return this.editor.doc;
}
get edgeless() {
return this.editor.querySelector('affine-edgeless-root');
}
get host() {
return this.editor.host;
}
@@ -113,6 +78,8 @@ export class OutlinePanel extends SignalWatcher(WithDisposable(LitElement)) {
override connectedCallback() {
super.connectedCallback();
this.classList.add(styles.outlinePanel);
this.disposables.add(
effect(() => {
if (this.editor.mode === 'edgeless') {
@@ -138,33 +105,27 @@ export class OutlinePanel extends SignalWatcher(WithDisposable(LitElement)) {
if (!this.host) return;
return html`
<div class="outline-panel-container">
<affine-outline-panel-header
.showPreviewIcon=${this._showPreviewIcon}
.enableNotesSorting=${this._enableNotesSorting}
.toggleShowPreviewIcon=${this._toggleShowPreviewIcon}
.toggleNotesSorting=${this._toggleNotesSorting}
></affine-outline-panel-header>
<affine-outline-panel-body
class="outline-panel-body"
.doc=${this.doc}
.fitPadding=${this.fitPadding}
.edgeless=${this.edgeless}
.editor=${this.editor}
.mode=${this.mode}
.showPreviewIcon=${this._showPreviewIcon}
.enableNotesSorting=${this._enableNotesSorting}
.toggleNotesSorting=${this._toggleNotesSorting}
.noticeVisible=${this._noticeVisible}
.setNoticeVisibility=${this._setNoticeVisibility}
>
</affine-outline-panel-body>
<affine-outline-notice
.noticeVisible=${this._noticeVisible}
.toggleNotesSorting=${this._toggleNotesSorting}
.setNoticeVisibility=${this._setNoticeVisibility}
></affine-outline-notice>
</div>
<affine-outline-panel-header
.showPreviewIcon=${this._showPreviewIcon}
.enableNotesSorting=${this._enableNotesSorting}
.toggleShowPreviewIcon=${this._toggleShowPreviewIcon}
.toggleNotesSorting=${this._toggleNotesSorting}
></affine-outline-panel-header>
<affine-outline-panel-body
.fitPadding=${this.fitPadding}
.mode=${this.mode}
.showPreviewIcon=${this._showPreviewIcon}
.enableNotesSorting=${this._enableNotesSorting}
.toggleNotesSorting=${this._toggleNotesSorting}
.noticeVisible=${this._noticeVisible}
.setNoticeVisibility=${this._setNoticeVisibility}
>
</affine-outline-panel-body>
<affine-outline-notice
.noticeVisible=${this._noticeVisible}
.toggleNotesSorting=${this._toggleNotesSorting}
.setNoticeVisibility=${this._setNoticeVisibility}
></affine-outline-notice>
`;
}
@@ -177,6 +138,7 @@ export class OutlinePanel extends SignalWatcher(WithDisposable(LitElement)) {
@state()
private accessor _showPreviewIcon = false;
@provide({ context: editorContext })
@property({ attribute: false })
accessor editor!: AffineEditorContainer;
@@ -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 { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { provide } from '@lit/context';
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 { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js';
import type { AffineEditorContainer } from '../../editors/editor-container.js';
import { TocIcon } from '../_common/icons.js';
import { editorContext } from './config.js';
import { getHeadingBlocksFromDoc } from './utils/query.js';
import {
observeActiveHeadingDuringScroll,
@@ -20,9 +26,11 @@ export const AFFINE_OUTLINE_VIEWER = 'affine-outline-viewer';
@requiredProperties({
editor: PropTypes.object,
})
export class OutlineViewer extends SignalWatcher(WithDisposable(LitElement)) {
export class OutlineViewer extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = css`
:host {
affine-outline-viewer {
display: flex;
}
.outline-viewer-root {
@@ -117,9 +125,7 @@ export class OutlineViewer extends SignalWatcher(WithDisposable(LitElement)) {
}
.outline-viewer-item {
display: flex;
align-items: center;
align-self: stretch;
width: 100%;
}
.outline-viewer-root:hover {
@@ -281,6 +287,7 @@ export class OutlineViewer extends SignalWatcher(WithDisposable(LitElement)) {
@state()
private accessor _showViewer: boolean = false;
@provide({ context: editorContext })
@property({ attribute: false })
accessor editor!: AffineEditorContainer;
@@ -40,7 +40,7 @@ export function getNotesFromDoc(
number: index + 1,
};
if (modes.includes(blockModel.displayMode)) {
if (modes.includes(blockModel.displayMode$.value)) {
notes.push(OutlineNoteItem);
}
});
@@ -232,7 +232,7 @@ test.describe('edgeless note element toolbar', () => {
const toc = page.locator('affine-outline-panel');
await toc.waitFor({ state: 'visible' });
const highlightNoteCards = toc.locator(
'affine-outline-note-card > .selected'
'affine-outline-note-card > [data-status="selected"]'
);
expect(highlightNoteCards).toHaveCount(1);
});
@@ -40,7 +40,7 @@ async function openTocPanel(page: Page) {
}
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) {
@@ -67,7 +67,7 @@ test('should display title and headings when there are non-empty headings in edi
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++) {
await expect(getTocHeading(toc, i)).toBeVisible();
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 }) => {
const toc = await openTocPanel(page);
const noHeadingPlaceholder = toc.locator('.note-placeholder');
const noHeadingPlaceholder = toc.getByTestId('empty-panel-placeholder');
await createTitle(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);
await expect(toc.locator('.title')).toBeHidden();
await expect(toc.getByTestId('outline-block-preview-title')).toBeHidden();
for (let i = 1; i <= 6; i++) {
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.click();
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 pressBackspace(page);
await expect(toc.locator('.title')).toBeHidden();
await expect(toc.getByTestId('outline-block-preview-title')).toBeHidden();
for (let i = 1; i <= 6; i++) {
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 titleInPanel = toc.locator('.title');
const titleInPanel = toc.getByTestId('outline-block-preview-title');
await expect(title).not.toBeInViewport();
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 sortingButton = toc.locator('.note-sorting-button');
const sortingButton = toc.getByTestId('toggle-notes-sorting-button');
await expect(sortingButton).not.toHaveClass(/active/);
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 docVisibleCards = toc.locator(
'.card-container[data-invisible="false"]'
'affine-outline-note-card [data-invisible="false"]'
);
const docInvisibleCards = toc.locator(
'.card-container[data-invisible="true"]'
'affine-outline-note-card [data-invisible="true"]'
);
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) {
const card = docInvisibleCards.first();
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();
}
@@ -310,10 +312,10 @@ test.describe('advanced visibility control', () => {
const toc = await openTocPanel(page);
const docVisibleCard = toc.locator(
'.card-container[data-invisible="false"]'
'affine-outline-note-card [data-invisible="false"]'
);
const docInvisibleCard = toc.locator(
'.card-container[data-invisible="true"]'
'affine-outline-note-card [data-invisible="true"]'
);
await expect(docVisibleCard).toHaveCount(1);
@@ -341,17 +343,17 @@ test.describe('advanced visibility control', () => {
const toc = await openTocPanel(page);
const docVisibleCard = toc.locator(
'.card-container[data-invisible="false"]'
'affine-outline-note-card [data-invisible="false"]'
);
const docInvisibleCard = toc.locator(
'.card-container[data-invisible="true"]'
'affine-outline-note-card [data-invisible="true"]'
);
await expect(docVisibleCard).toHaveCount(1);
await expect(docInvisibleCard).toHaveCount(1);
await docInvisibleCard.hover();
await docInvisibleCard.locator('.display-mode-button').click();
await docInvisibleCard.getByTestId('display-mode-button').click();
await docInvisibleCard
.locator('note-display-mode-panel .item.both')
.click();
@@ -129,7 +129,7 @@ test('should highlight indicator when click item in outline panel', async ({
await indicators.first().hover({ force: true });
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 expect(headings[2]).toBeVisible();
@@ -172,7 +172,9 @@ test('should hide edgeless-only note headings', async ({ page }) => {
const viewer = page.locator('affine-outline-viewer');
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' });
expect(h1InPanel).toContainText(['Heading 1']);
});
+5 -2
View File
@@ -3947,7 +3947,7 @@ __metadata:
languageName: unknown
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
resolution: "@blocksuite/icons@npm:2.2.2"
peerDependencies:
@@ -4045,12 +4045,15 @@ __metadata:
"@blocksuite/block-std": "workspace:*"
"@blocksuite/blocks": "workspace:*"
"@blocksuite/global": "workspace:*"
"@blocksuite/icons": "npm:^2.2.2"
"@blocksuite/inline": "workspace:*"
"@blocksuite/store": "workspace:*"
"@floating-ui/dom": "npm:^1.6.10"
"@lit/context": "npm:^1.1.3"
"@lottiefiles/dotlottie-wc": "npm:^0.4.0"
"@preact/signals-core": "npm:^1.8.0"
"@toeverything/theme": "npm:^1.1.7"
"@vanilla-extract/css": "npm:^1.17.0"
"@vanilla-extract/vite-plugin": "npm:^4.0.19"
lit: "npm:^3.2.0"
vitest: "npm:^3.0.0"
@@ -7776,7 +7779,7 @@ __metadata:
languageName: node
linkType: hard
"@lit/context@npm:^1.1.2":
"@lit/context@npm:^1.1.2, @lit/context@npm:^1.1.3":
version: 1.1.3
resolution: "@lit/context@npm:1.1.3"
dependencies: