mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-23 20:18:42 +08:00
refactor: move outline fragment to separate package (#10331)
### TL;DR Moved outline functionality into a dedicated fragment package and updated vanilla-extract CSS dependency. ### What changed? - Created new `@blocksuite/affine-fragment-outline` package - Relocated outline-related code from presets to the new fragment package - Updated imports across affected files to reference the new package location - Upgraded `@vanilla-extract/css` dependency from 1.14.0/1.16.1 to 1.17.0 - Added necessary package configuration and TypeScript setup for the new fragment ### How to test? 1. Verify outline functionality works as expected in both desktop and mobile views 2. Check that outline panel, viewer, and mobile menu components render correctly 3. Ensure outline navigation and interactions continue to work 4. Confirm no regressions in outline-related features ### Why make this change? This change improves code organization by isolating outline functionality into a dedicated package, following the modular architecture pattern. This makes the codebase more maintainable and allows for better separation of concerns. The vanilla-extract CSS upgrade ensures consistency across packages and provides access to the latest features and fixes.
This commit is contained in:
@@ -24,7 +24,7 @@
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.10",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@vanilla-extract/css": "^1.14.0",
|
||||
"@vanilla-extract/css": "^1.17.0",
|
||||
"lit": "^3.2.0",
|
||||
"yjs": "^13.6.21",
|
||||
"zod": "^3.24.1"
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@blocksuite/affine-fragment-outline",
|
||||
"description": "Outline fragment for BlockSuite.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test:unit": "nx vite:test --run --passWithNoTests",
|
||||
"test:unit:coverage": "nx vite:test --run --coverage",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-block-note": "workspace:*",
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/block-std": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.1",
|
||||
"@blocksuite/inline": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.10",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.11",
|
||||
"@vanilla-extract/css": "^1.17.0",
|
||||
"lit": "^3.2.0",
|
||||
"minimatch": "^10.0.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./effects": "./src/effects.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.19.0"
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { NoteDisplayMode } from '@blocksuite/affine-model';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import { CloseIcon, SortIcon } from '@blocksuite/icons/lit';
|
||||
import { consume } from '@lit/context';
|
||||
import { effect, signal } from '@preact/signals-core';
|
||||
import { html, nothing } from 'lit';
|
||||
|
||||
import { type TocContext, tocContext } from '../config';
|
||||
import { getNotesFromDoc } from '../utils/query';
|
||||
import * as styles from './outline-notice.css';
|
||||
|
||||
export const AFFINE_OUTLINE_NOTICE = 'affine-outline-notice';
|
||||
|
||||
export class OutlineNotice extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
private readonly _visible$ = signal(false);
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const enableSorting = this._context.enableSorting$.value;
|
||||
|
||||
if (enableSorting) {
|
||||
if (this._visible$.peek()) {
|
||||
this._visible$.value = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldShowNotice =
|
||||
getNotesFromDoc(this._context.editor$.value.doc, [
|
||||
NoteDisplayMode.DocOnly,
|
||||
]).length > 0;
|
||||
|
||||
if (shouldShowNotice && !this._visible$.peek()) {
|
||||
this._visible$.value = true;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this._visible$.value) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div data-testid=${AFFINE_OUTLINE_NOTICE} class=${styles.outlineNotice}>
|
||||
<div class=${styles.outlineNoticeHeader}>
|
||||
<span class=${styles.outlineNoticeLabel}>SOME CONTENTS HIDDEN</span>
|
||||
<span
|
||||
data-testid="outline-notice-close-button"
|
||||
class=${styles.outlineNoticeCloseButton}
|
||||
@click=${() => {
|
||||
this._visible$.value = false;
|
||||
}}
|
||||
>${CloseIcon({ width: '16px', height: '16px' })}</span
|
||||
>
|
||||
</div>
|
||||
<div class=${styles.outlineNoticeBody}>
|
||||
<div class="${styles.notice}">
|
||||
Some contents are not visible on edgeless.
|
||||
</div>
|
||||
<div
|
||||
data-testid="outline-notice-sort-button"
|
||||
class="${styles.button}"
|
||||
@click=${() => {
|
||||
this._context.enableSorting$.value = true;
|
||||
this._visible$.value = false;
|
||||
}}
|
||||
>
|
||||
<span class=${styles.buttonSpan}>Click here or</span>
|
||||
<span class=${styles.buttonSpan}
|
||||
>${SortIcon({ width: '20px', height: '20px' })}</span
|
||||
>
|
||||
<span class=${styles.buttonSpan}>to organize content.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@consume({ context: tocContext })
|
||||
private accessor _context!: TocContext;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_OUTLINE_NOTICE]: OutlineNotice;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
@@ -0,0 +1,428 @@
|
||||
import { changeNoteDisplayMode } from '@blocksuite/affine-block-note';
|
||||
import { NoteBlockModel, NoteDisplayMode } from '@blocksuite/affine-model';
|
||||
import { DocModeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import { ShadowlessElement, SurfaceSelection } from '@blocksuite/block-std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
|
||||
import { Bound, SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
import { consume } from '@lit/context';
|
||||
import { effect, signal } from '@preact/signals-core';
|
||||
import { html, nothing } from 'lit';
|
||||
import { 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 TocContext, tocContext } from '../config';
|
||||
import type {
|
||||
ClickBlockEvent,
|
||||
DisplayModeChangeEvent,
|
||||
FitViewEvent,
|
||||
SelectEvent,
|
||||
} from '../utils/custom-events';
|
||||
import type { NoteCardEntity, NoteDropPayload } from '../utils/drag';
|
||||
import {
|
||||
getHeadingBlocksFromDoc,
|
||||
getNotesFromDoc,
|
||||
isHeadingBlock,
|
||||
} from '../utils/query';
|
||||
import {
|
||||
observeActiveHeadingDuringScroll,
|
||||
scrollToBlockWithHighlight,
|
||||
} from '../utils/scroll';
|
||||
import * as styles from './outline-panel-body.css';
|
||||
|
||||
export const AFFINE_OUTLINE_PANEL_BODY = 'affine-outline-panel-body';
|
||||
|
||||
export class OutlinePanelBody extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
private readonly _activeHeadingId$ = signal<string | null>(null);
|
||||
|
||||
private readonly _dragging$ = signal(false);
|
||||
|
||||
private readonly _indicatorTranslateY$ = signal(0);
|
||||
|
||||
private readonly _pageVisibleNotes$ = signal<NoteBlockModel[]>([]);
|
||||
|
||||
private readonly _edgelessOnlyNotes$ = signal<NoteBlockModel[]>([]);
|
||||
|
||||
private readonly _selectedNotes$ = signal<NoteBlockModel[]>([]);
|
||||
|
||||
private _clearHighlightMask = () => {};
|
||||
|
||||
private _lockActiveHeadingId = false;
|
||||
|
||||
private get _shouldRenderEmptyPanel() {
|
||||
return (
|
||||
this._pageVisibleNotes$.value.length === 0 &&
|
||||
this._edgelessOnlyNotes$.value.length === 0
|
||||
);
|
||||
}
|
||||
|
||||
private get editor() {
|
||||
return this._context.editor$.value;
|
||||
}
|
||||
|
||||
private get doc() {
|
||||
return this.editor.doc;
|
||||
}
|
||||
|
||||
get viewportPadding(): [number, number, number, number] {
|
||||
const fitPadding = this._context.fitPadding$.value;
|
||||
return fitPadding.length === 4
|
||||
? ([0, 0, 0, 0].map((val, idx) =>
|
||||
Number.isFinite(fitPadding[idx]) ? fitPadding[idx] : val
|
||||
) as [number, number, number, number])
|
||||
: [0, 0, 0, 0];
|
||||
}
|
||||
|
||||
private _deSelectNoteInEdgelessMode(note: NoteBlockModel) {
|
||||
const gfx = this.editor.std.get(GfxControllerIdentifier);
|
||||
const selection = gfx.selection;
|
||||
|
||||
if (!selection.has(note.id)) return;
|
||||
const selectedIds = selection.selectedIds.filter(id => id !== note.id);
|
||||
selection.set({
|
||||
elements: selectedIds,
|
||||
editing: false,
|
||||
});
|
||||
}
|
||||
|
||||
private _renderEmptyPanel() {
|
||||
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>`;
|
||||
}
|
||||
|
||||
private _fitToElement(e: FitViewEvent) {
|
||||
const gfx = this.editor.std.get(GfxControllerIdentifier);
|
||||
|
||||
const { block } = e.detail;
|
||||
const bound = Bound.deserialize(block.xywh);
|
||||
|
||||
gfx.viewport.setViewportByBound(bound, this.viewportPadding, true);
|
||||
}
|
||||
|
||||
// when display mode change to page only, we should de-select the note if it is selected in edgeless mode
|
||||
private _handleDisplayModeChange(e: DisplayModeChangeEvent) {
|
||||
const { note, newMode } = e.detail;
|
||||
this.editor.std.command.exec(changeNoteDisplayMode, {
|
||||
noteId: note.id,
|
||||
mode: newMode,
|
||||
stopCapture: true,
|
||||
});
|
||||
|
||||
// When the display mode of a note changed to page only
|
||||
// We should check if the note is selected in edgeless mode
|
||||
// If so, we should de-select it
|
||||
if (newMode === NoteDisplayMode.DocOnly) {
|
||||
this._deSelectNoteInEdgelessMode(note);
|
||||
}
|
||||
}
|
||||
|
||||
private _moveSelectedNotes(insertIndex: number) {
|
||||
if (!this.doc.root) return;
|
||||
|
||||
const pageVisibleNotes = this._pageVisibleNotes$.peek();
|
||||
const selected = this._selectedNotes$.peek();
|
||||
const children = this.doc.root.children.slice();
|
||||
|
||||
const noteIndex = new Map<NoteBlockModel, number>();
|
||||
children.forEach((block, index) => {
|
||||
if (matchModels(block, [NoteBlockModel])) {
|
||||
noteIndex.set(block, index);
|
||||
}
|
||||
});
|
||||
|
||||
let targetIndex: number | null = null;
|
||||
if (insertIndex === pageVisibleNotes.length) {
|
||||
const temp = noteIndex.get(pageVisibleNotes[insertIndex - 1]);
|
||||
if (temp) targetIndex = temp + 1;
|
||||
} else {
|
||||
targetIndex = noteIndex.get(pageVisibleNotes[insertIndex]) ?? null;
|
||||
}
|
||||
|
||||
if (targetIndex === null) return;
|
||||
|
||||
const removeSelectedNoteFilter = (block: BlockModel) =>
|
||||
!matchModels(block, [NoteBlockModel]) || !selected.includes(block);
|
||||
|
||||
const leftPart = children
|
||||
.slice(0, targetIndex)
|
||||
.filter(removeSelectedNoteFilter);
|
||||
const rightPart = children
|
||||
.slice(targetIndex)
|
||||
.filter(removeSelectedNoteFilter);
|
||||
|
||||
const newChildren = [...leftPart, ...selected, ...rightPart];
|
||||
|
||||
this.doc.updateBlock(this.doc.root, {
|
||||
children: newChildren,
|
||||
});
|
||||
}
|
||||
|
||||
private async _scrollToBlock(blockId: string) {
|
||||
this._lockActiveHeadingId = true;
|
||||
this._activeHeadingId$.value = blockId;
|
||||
this._clearHighlightMask = await scrollToBlockWithHighlight(
|
||||
this.editor,
|
||||
blockId
|
||||
);
|
||||
this._lockActiveHeadingId = false;
|
||||
}
|
||||
|
||||
private _selectNote(e: SelectEvent) {
|
||||
const { selected, id, multiselect } = e.detail;
|
||||
const gfx = this.editor.std.get(GfxControllerIdentifier);
|
||||
const editorMode = this.editor.std.get(DocModeProvider).getEditorMode();
|
||||
const note = this.doc.getBlock(id)?.model;
|
||||
if (!note || !matchModels(note, [NoteBlockModel])) return;
|
||||
|
||||
let selectedNotes = this._selectedNotes$.peek();
|
||||
|
||||
if (!selected) {
|
||||
selectedNotes = selectedNotes.filter(_note => _note !== note);
|
||||
} else if (multiselect) {
|
||||
selectedNotes = [...selectedNotes, note];
|
||||
} else {
|
||||
selectedNotes = [note];
|
||||
}
|
||||
|
||||
if (editorMode === 'edgeless') {
|
||||
gfx.selection.set({
|
||||
elements: selectedNotes.map(({ id }) => id),
|
||||
editing: false,
|
||||
});
|
||||
} else {
|
||||
this._selectedNotes$.value = selectedNotes;
|
||||
}
|
||||
}
|
||||
|
||||
private _watchSelectedNotes() {
|
||||
return effect(() => {
|
||||
const { std, doc } = this.editor;
|
||||
const docModeService = this.editor.std.get(DocModeProvider);
|
||||
const mode = docModeService.getEditorMode();
|
||||
if (mode !== 'edgeless') return;
|
||||
|
||||
const currSelectedNotes = std.selection
|
||||
.filter(SurfaceSelection)
|
||||
.map(({ blockId }) => doc.getBlock(blockId)?.model)
|
||||
.filter(model => {
|
||||
return !!model && matchModels(model, [NoteBlockModel]);
|
||||
});
|
||||
|
||||
const preSelected = this._selectedNotes$.peek();
|
||||
if (
|
||||
preSelected.length !== currSelectedNotes.length ||
|
||||
preSelected.some(note => !currSelectedNotes.includes(note))
|
||||
) {
|
||||
this._selectedNotes$.value = currSelectedNotes;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _watchNotes() {
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const isRenderableNote = (note: NoteBlockModel) => {
|
||||
let hasHeadings = false;
|
||||
|
||||
for (const block of note.children) {
|
||||
if (isHeadingBlock(block)) {
|
||||
hasHeadings = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return hasHeadings || this._context.enableSorting$.value;
|
||||
};
|
||||
|
||||
this._pageVisibleNotes$.value = getNotesFromDoc(this.doc, [
|
||||
NoteDisplayMode.DocAndEdgeless,
|
||||
NoteDisplayMode.DocOnly,
|
||||
]).filter(isRenderableNote);
|
||||
|
||||
this._edgelessOnlyNotes$.value = getNotesFromDoc(this.doc, [
|
||||
NoteDisplayMode.EdgelessOnly,
|
||||
]).filter(isRenderableNote);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private _watchDragAndDrop() {
|
||||
const std = this.editor.std;
|
||||
this.disposables.add(
|
||||
std.dnd.monitor<NoteCardEntity, NoteDropPayload>({
|
||||
onDragStart: () => {
|
||||
this._dragging$.value = true;
|
||||
this._selectedNotes$.value = this._selectedNotes$
|
||||
.peek()
|
||||
.filter(note => {
|
||||
return this._pageVisibleNotes$.value.includes(note);
|
||||
});
|
||||
},
|
||||
onDrag: data => {
|
||||
const target = data.location.current.dropTargets[0];
|
||||
if (!target) return;
|
||||
const edge = target.data.edge;
|
||||
const rect = target.element.getBoundingClientRect();
|
||||
const parentRect = this._pageVisibleList.getBoundingClientRect();
|
||||
this._indicatorTranslateY$.value =
|
||||
edge === 'top'
|
||||
? rect.top - parentRect.top
|
||||
: rect.bottom - parentRect.top;
|
||||
},
|
||||
onDrop: data => {
|
||||
this._dragging$.value = false;
|
||||
const target = data.location.current.dropTargets[0];
|
||||
if (!target) return;
|
||||
|
||||
const edge = target.data.edge;
|
||||
const index = this._pageVisibleNotes$
|
||||
.peek()
|
||||
.findIndex(({ id }) => id === target.data.noteId);
|
||||
|
||||
if (index === -1) return;
|
||||
|
||||
this._moveSelectedNotes(edge === 'top' ? index : index + 1);
|
||||
},
|
||||
})
|
||||
);
|
||||
this.disposables.add(
|
||||
std.dnd.autoScroll<NoteCardEntity>({
|
||||
element: this,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.classList.add(styles.outlinePanelBody);
|
||||
|
||||
this.disposables.add(
|
||||
observeActiveHeadingDuringScroll(
|
||||
() => this.editor,
|
||||
newHeadingId => {
|
||||
if (this._lockActiveHeadingId) return;
|
||||
this._activeHeadingId$.value = newHeadingId;
|
||||
}
|
||||
)
|
||||
);
|
||||
this._watchNotes();
|
||||
this._watchSelectedNotes();
|
||||
this._watchDragAndDrop();
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._clearHighlightMask();
|
||||
}
|
||||
|
||||
private _renderDocTitle() {
|
||||
if (!this.doc.root) return nothing;
|
||||
|
||||
const hasNotEmptyHeadings =
|
||||
getHeadingBlocksFromDoc(
|
||||
this.doc,
|
||||
[NoteDisplayMode.DocOnly, NoteDisplayMode.DocAndEdgeless],
|
||||
true
|
||||
).length > 0;
|
||||
|
||||
if (!hasNotEmptyHeadings) return nothing;
|
||||
|
||||
const rootId = this.doc.root.id;
|
||||
const active = rootId === this._activeHeadingId$.value;
|
||||
|
||||
return html`<affine-outline-block-preview
|
||||
class=${classMap({ active: active })}
|
||||
.block=${this.doc.root}
|
||||
@click=${() => {
|
||||
this._scrollToBlock(rootId).catch(console.error);
|
||||
}}
|
||||
></affine-outline-block-preview>`;
|
||||
}
|
||||
|
||||
private _renderNoteCards(notes: NoteBlockModel[]) {
|
||||
return repeat(
|
||||
notes,
|
||||
({ id }) => id,
|
||||
(note, index) =>
|
||||
html`<affine-outline-note-card
|
||||
data-note-id=${note.id}
|
||||
index=${index}
|
||||
.note=${note}
|
||||
.activeHeadingId=${this._activeHeadingId$.value}
|
||||
.status=${this._selectedNotes$.value.includes(note)
|
||||
? this._dragging$.value
|
||||
? 'dragging'
|
||||
: 'selected'
|
||||
: 'normal'}
|
||||
@fitview=${this._fitToElement}
|
||||
@select=${this._selectNote}
|
||||
@displaymodechange=${this._handleDisplayModeChange}
|
||||
@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._dragging$.value,
|
||||
() =>
|
||||
html`<div
|
||||
class=${styles.insertIndicator}
|
||||
style=${`transform: translateY(${this._indicatorTranslateY$.value}px)`}
|
||||
></div>`
|
||||
)}
|
||||
${this._renderNoteCards(this._pageVisibleNotes$.value)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderEdgelessOnlyCardList() {
|
||||
const items = this._edgelessOnlyNotes$.value;
|
||||
return html`<div class=${styles.cardList}>
|
||||
${when(
|
||||
items.length > 0,
|
||||
() =>
|
||||
html`<div class=${styles.edgelessCardListTitle}>Hidden Contents</div>`
|
||||
)}
|
||||
${this._renderNoteCards(items)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
${this._renderDocTitle()}
|
||||
${when(
|
||||
this._shouldRenderEmptyPanel,
|
||||
() => this._renderEmptyPanel(),
|
||||
() => html`
|
||||
${this._renderPageVisibleCardList()}
|
||||
${this._renderEdgelessOnlyCardList()}
|
||||
`
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
@query('.page-visible-card-list')
|
||||
private accessor _pageVisibleList!: HTMLElement;
|
||||
|
||||
@consume({ context: tocContext })
|
||||
private accessor _context!: TocContext;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_OUTLINE_PANEL_BODY]: OutlinePanelBody;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
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="dragging"]': {
|
||||
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="dragging"] &`]: {
|
||||
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',
|
||||
|
||||
':hover': {
|
||||
cursor: 'grab',
|
||||
},
|
||||
selectors: {
|
||||
[`${outlineCard}[data-sortable="true"] &`]: {
|
||||
display: 'flex',
|
||||
},
|
||||
[`${outlineCard}[data-visibility="edgeless"] &:hover`]: {
|
||||
cursor: 'default',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const invisibleCard = style({
|
||||
selectors: {
|
||||
[`${outlineCard}[data-visibility="edgeless"] &`]: {
|
||||
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',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import { type NoteBlockModel, NoteDisplayMode } from '@blocksuite/affine-model';
|
||||
import { createButtonPopper } from '@blocksuite/affine-shared/utils';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import { ArrowDownSmallIcon, InvisibleIcon } from '@blocksuite/icons/lit';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
import { consume, ContextProvider } from '@lit/context';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { html } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
|
||||
import { type TocContext, tocContext } from '../config';
|
||||
import type { SelectEvent } from '../utils/custom-events';
|
||||
import type { NoteCardEntity, NoteDropPayload } from '../utils/drag';
|
||||
import * as styles from './outline-card.css';
|
||||
|
||||
export const AFFINE_OUTLINE_NOTE_CARD = 'affine-outline-note-card';
|
||||
|
||||
export class OutlineNoteCard extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
private _displayModePopper: ReturnType<typeof createButtonPopper> | null =
|
||||
null;
|
||||
|
||||
private readonly _showPopper$ = signal(false);
|
||||
|
||||
private _dispatchClickBlockEvent(block: BlockModel) {
|
||||
const event = new CustomEvent('clickblock', {
|
||||
detail: {
|
||||
blockId: block.id,
|
||||
},
|
||||
});
|
||||
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
private _dispatchDisplayModeChangeEvent(newMode: NoteDisplayMode) {
|
||||
const event = new CustomEvent('displaymodechange', {
|
||||
detail: {
|
||||
note: this.note,
|
||||
newMode,
|
||||
},
|
||||
});
|
||||
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
private _dispatchFitViewEvent(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
|
||||
const event = new CustomEvent('fitview', {
|
||||
detail: {
|
||||
block: this.note,
|
||||
},
|
||||
});
|
||||
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
private _dispatchSelectEvent(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
const event = new CustomEvent('select', {
|
||||
detail: {
|
||||
id: this.note.id,
|
||||
selected: this.status !== 'selected',
|
||||
multiselect: e.shiftKey,
|
||||
},
|
||||
}) as SelectEvent;
|
||||
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
private _getCurrentModeLabel(mode: NoteDisplayMode) {
|
||||
switch (mode) {
|
||||
case NoteDisplayMode.DocAndEdgeless:
|
||||
return 'Both';
|
||||
case NoteDisplayMode.EdgelessOnly:
|
||||
return 'Edgeless';
|
||||
case NoteDisplayMode.DocOnly:
|
||||
return 'Page';
|
||||
default:
|
||||
return 'Both';
|
||||
}
|
||||
}
|
||||
|
||||
private _watchDragEvents() {
|
||||
const std = this._context.editor$.value.std;
|
||||
this.disposables.add(
|
||||
std.dnd.draggable<NoteCardEntity>({
|
||||
element: this,
|
||||
canDrag: () => this.note.displayMode !== NoteDisplayMode.EdgelessOnly,
|
||||
onDragStart: () => {
|
||||
if (this.status !== 'selected') {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('select', {
|
||||
detail: {
|
||||
id: this.note.id,
|
||||
selected: true,
|
||||
multiselect: false,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
setDragData: () => ({
|
||||
type: 'toc-card',
|
||||
noteId: this.note.id,
|
||||
}),
|
||||
setDragPreview: ({ container, setOffset, location }) => {
|
||||
const preview = document.createElement(AFFINE_OUTLINE_NOTE_CARD);
|
||||
preview.note = this.note;
|
||||
preview.index = this.index;
|
||||
preview._context = this._context;
|
||||
|
||||
const { clientX, clientY } = location.current.input;
|
||||
const { left, top } = this.getBoundingClientRect();
|
||||
setOffset({ x: clientX - left, y: clientY - top });
|
||||
|
||||
container.style.width = `${this.parentElement?.clientWidth ?? 0}px`;
|
||||
container.style.backgroundColor = cssVarV2(
|
||||
'layer/background/primary'
|
||||
);
|
||||
container.append(preview);
|
||||
const provider = new ContextProvider(container, {
|
||||
context: tocContext,
|
||||
initialValue: this._context,
|
||||
});
|
||||
provider.hostConnected();
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.add(
|
||||
std.dnd.dropTarget<NoteCardEntity, NoteDropPayload>({
|
||||
element: this,
|
||||
setDropData: () => ({
|
||||
noteId: this.note.id,
|
||||
}),
|
||||
canDrop: () => {
|
||||
return this.note.displayMode !== NoteDisplayMode.EdgelessOnly;
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._watchDragEvents();
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
this._displayModePopper = createButtonPopper(
|
||||
this._displayModeButtonGroup,
|
||||
this._displayModePanel,
|
||||
({ display }) => {
|
||||
this._showPopper$.value = display === 'show';
|
||||
},
|
||||
{
|
||||
mainAxis: 0,
|
||||
crossAxis: -60,
|
||||
}
|
||||
);
|
||||
|
||||
this.disposables.add(this._displayModePopper);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { children, displayMode } = this.note;
|
||||
const currentMode = this._getCurrentModeLabel(displayMode);
|
||||
const invisible =
|
||||
this.note.displayMode$.value === NoteDisplayMode.EdgelessOnly;
|
||||
|
||||
const enableSorting = this._context.enableSorting$.value;
|
||||
|
||||
return html`
|
||||
<div
|
||||
data-visibility=${this.note.displayMode}
|
||||
data-sortable=${enableSorting}
|
||||
data-status=${this.status}
|
||||
class=${styles.outlineCard}
|
||||
>
|
||||
<div
|
||||
class=${styles.cardPreview}
|
||||
@click=${this._dispatchSelectEvent}
|
||||
@dblclick=${this._dispatchFitViewEvent}
|
||||
>
|
||||
${html`<div class=${styles.cardHeader}>
|
||||
${
|
||||
invisible
|
||||
? html`<span class=${styles.headerIcon}
|
||||
>${InvisibleIcon({ width: '20px', height: '20px' })}</span
|
||||
>`
|
||||
: html`<span class=${styles.headerNumber}
|
||||
>${this.index + 1}</span
|
||||
>`
|
||||
}
|
||||
<span class=${styles.divider}></span>
|
||||
<div class=${styles.displayModeButtonGroup}>
|
||||
<span>Show in</span>
|
||||
<edgeless-tool-icon-button
|
||||
.tooltip=${this._showPopper$.value ? '' : '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=${styles.displayModeButton}>
|
||||
<span class=${styles.currentModeLabel}>${currentMode}</span>
|
||||
${ArrowDownSmallIcon({ width: '16px', height: '16px' })}
|
||||
</div>
|
||||
</edgeless-tool-icon-button>
|
||||
</div>
|
||||
</div>
|
||||
<note-display-mode-panel
|
||||
class=${styles.modeChangePanel}
|
||||
.displayMode=${displayMode}
|
||||
.panelWidth=${220}
|
||||
.onSelect=${(newMode: NoteDisplayMode) => {
|
||||
this._dispatchDisplayModeChangeEvent(newMode);
|
||||
this._displayModePopper?.hide();
|
||||
}}
|
||||
>
|
||||
</note-display-mode-panel>
|
||||
</div>`}
|
||||
<div class=${styles.cardContent}>
|
||||
${children.map(block => {
|
||||
return html`<affine-outline-block-preview
|
||||
class=${classMap({ active: this.activeHeadingId === block.id })}
|
||||
.block=${block}
|
||||
.disabledIcon=${invisible}
|
||||
@click=${() => {
|
||||
if (invisible) return;
|
||||
this._dispatchClickBlockEvent(block);
|
||||
}}
|
||||
></affine-outline-block-preview>`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@query(`.${styles.displayModeButtonGroup}`)
|
||||
private accessor _displayModeButtonGroup!: HTMLDivElement;
|
||||
|
||||
@query('note-display-mode-panel')
|
||||
private accessor _displayModePanel!: HTMLDivElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor activeHeadingId: string | null = null;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor note!: NoteBlockModel;
|
||||
|
||||
@property({ attribute: true, type: Number })
|
||||
accessor index: number = 0;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor status: 'selected' | 'dragging' | 'normal' = 'normal';
|
||||
|
||||
@consume({ context: tocContext })
|
||||
private accessor _context!: TocContext;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_OUTLINE_NOTE_CARD]: OutlineNoteCard;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import type {
|
||||
AttachmentBlockModel,
|
||||
BookmarkBlockModel,
|
||||
CodeBlockModel,
|
||||
DatabaseBlockModel,
|
||||
ImageBlockModel,
|
||||
ListBlockModel,
|
||||
ParagraphBlockModel,
|
||||
RootBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { DocDisplayMetaProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { noop, SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import { LinkedPageIcon } from '@blocksuite/icons/lit';
|
||||
import type { DeltaInsert } from '@blocksuite/inline';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
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 {
|
||||
placeholderMap,
|
||||
previewIconMap,
|
||||
type TocContext,
|
||||
tocContext,
|
||||
} from '../config.js';
|
||||
import { isHeadingBlock, isRootBlock } from '../utils/query.js';
|
||||
import * as styles from './outline-preview.css';
|
||||
|
||||
function assertType<T>(value: unknown): asserts value is T {
|
||||
noop(value);
|
||||
}
|
||||
|
||||
export const AFFINE_OUTLINE_BLOCK_PREVIEW = 'affine-outline-block-preview';
|
||||
|
||||
export class OutlineBlockPreview extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
private get _docDisplayMetaService() {
|
||||
return this._context.editor$.value.std.get(DocDisplayMetaProvider);
|
||||
}
|
||||
|
||||
private _TextBlockPreview(block: ParagraphBlockModel | ListBlockModel) {
|
||||
const deltas: DeltaInsert<AffineTextAttributes>[] =
|
||||
block.text.yText.toDelta();
|
||||
if (!block.text.length) return nothing;
|
||||
const iconClass = this.disabledIcon ? styles.iconDisabled : styles.icon;
|
||||
|
||||
const previewText = deltas.map(delta => {
|
||||
if (delta.attributes?.reference) {
|
||||
// If linked doc, render linked doc icon and the doc title.
|
||||
const refAttribute = delta.attributes.reference;
|
||||
const refMeta = block.doc.workspace.meta.docMetas.find(
|
||||
doc => doc.id === refAttribute.pageId
|
||||
);
|
||||
const unavailable = !refMeta;
|
||||
|
||||
const icon = unavailable
|
||||
? LinkedPageIcon({ width: '1.1em', height: '1.1em' })
|
||||
: this._docDisplayMetaService.icon(refMeta.id).value;
|
||||
const title = unavailable
|
||||
? 'Deleted doc'
|
||||
: this._docDisplayMetaService.title(refMeta.id).value;
|
||||
|
||||
return html`<span
|
||||
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 class=${styles.textSpan}
|
||||
>${delta.insert.toString()}</span
|
||||
>`
|
||||
: nothing;
|
||||
}
|
||||
});
|
||||
|
||||
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._context.showIcons$.value
|
||||
? html`<span class=${iconClass}>${previewIconMap[block.type]}</span>`
|
||||
: nothing}`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`<div class=${styles.outlineBlockPreview}>
|
||||
${this.renderBlockByFlavour()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
renderBlockByFlavour() {
|
||||
const { block } = this;
|
||||
const iconClass = this.disabledIcon ? styles.iconDisabled : styles.icon;
|
||||
|
||||
if (
|
||||
!this._context.enableSorting$.value &&
|
||||
!isHeadingBlock(block) &&
|
||||
!isRootBlock(block)
|
||||
)
|
||||
return nothing;
|
||||
|
||||
const showPreviewIcon = this._context.showIcons$.value;
|
||||
|
||||
switch (block.flavour) {
|
||||
case 'affine:page':
|
||||
assertType<RootBlockModel>(block);
|
||||
return block.title.length > 0
|
||||
? html`<span
|
||||
data-testid="outline-block-preview-title"
|
||||
class="${styles.text} ${styles.subtypeStyles.title}"
|
||||
>
|
||||
${block.title$.value}
|
||||
</span>`
|
||||
: nothing;
|
||||
case 'affine:paragraph':
|
||||
assertType<ParagraphBlockModel>(block);
|
||||
return this._TextBlockPreview(block);
|
||||
case 'affine:list':
|
||||
assertType<ListBlockModel>(block);
|
||||
return this._TextBlockPreview(block);
|
||||
case 'affine:bookmark':
|
||||
assertType<BookmarkBlockModel>(block);
|
||||
return html`
|
||||
<span class="${styles.text} ${styles.textGeneral}"
|
||||
>${block.title || block.url || placeholderMap['bookmark']}</span
|
||||
>
|
||||
${showPreviewIcon
|
||||
? html`<span class=${iconClass}
|
||||
>${previewIconMap['bookmark']}</span
|
||||
>`
|
||||
: nothing}
|
||||
`;
|
||||
case 'affine:code':
|
||||
assertType<CodeBlockModel>(block);
|
||||
return html`
|
||||
<span class="${styles.text} ${styles.textGeneral}"
|
||||
>${block.language ?? placeholderMap['code']}</span
|
||||
>
|
||||
${showPreviewIcon
|
||||
? html`<span class=${iconClass}>${previewIconMap['code']}</span>`
|
||||
: nothing}
|
||||
`;
|
||||
case 'affine:database':
|
||||
assertType<DatabaseBlockModel>(block);
|
||||
return html`
|
||||
<span class="${styles.text} ${styles.textGeneral}"
|
||||
>${block.title.toString().length
|
||||
? block.title.toString()
|
||||
: placeholderMap['database']}</span
|
||||
>
|
||||
${showPreviewIcon
|
||||
? html`<span class=${iconClass}>${previewIconMap['table']}</span>`
|
||||
: nothing}
|
||||
`;
|
||||
case 'affine:image':
|
||||
assertType<ImageBlockModel>(block);
|
||||
return html`
|
||||
<span class="${styles.text} ${styles.textGeneral}"
|
||||
>${block.caption?.length
|
||||
? block.caption
|
||||
: placeholderMap['image']}</span
|
||||
>
|
||||
${showPreviewIcon
|
||||
? html`<span class=${iconClass}>${previewIconMap['image']}</span>`
|
||||
: nothing}
|
||||
`;
|
||||
case 'affine:attachment':
|
||||
assertType<AttachmentBlockModel>(block);
|
||||
return html`
|
||||
<span class="${styles.text} ${styles.textGeneral}"
|
||||
>${block.name?.length
|
||||
? block.name
|
||||
: placeholderMap['attachment']}</span
|
||||
>
|
||||
${showPreviewIcon
|
||||
? html`<span class=${iconClass}
|
||||
>${previewIconMap['attachment']}</span
|
||||
>`
|
||||
: nothing}
|
||||
`;
|
||||
default:
|
||||
return nothing;
|
||||
}
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor block!: BlockModel;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor disabledIcon = false;
|
||||
|
||||
@consume({ context: tocContext })
|
||||
private accessor _context!: TocContext;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_OUTLINE_BLOCK_PREVIEW]: OutlineBlockPreview;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { ParagraphBlockModel } from '@blocksuite/affine-model';
|
||||
import type { EditorHost } from '@blocksuite/block-std';
|
||||
import {
|
||||
AttachmentIcon,
|
||||
BlockIcon,
|
||||
BookmarkIcon,
|
||||
BulletedListIcon,
|
||||
CheckBoxCheckLinearIcon,
|
||||
CodeBlockIcon,
|
||||
DatabaseKanbanViewIcon,
|
||||
DatabaseTableViewIcon,
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
Heading4Icon,
|
||||
Heading5Icon,
|
||||
Heading6Icon,
|
||||
ImageIcon,
|
||||
NumberedListIcon,
|
||||
QuoteIcon,
|
||||
TextIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { createContext } from '@lit/context';
|
||||
import type { Signal } from '@preact/signals-core';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
const _16px = { width: '16px', height: '16px' };
|
||||
|
||||
const paragraphIconMap: Record<
|
||||
ParagraphBlockModel['type'],
|
||||
TemplateResult<1>
|
||||
> = {
|
||||
quote: QuoteIcon(_16px),
|
||||
text: TextIcon(_16px),
|
||||
h1: Heading1Icon(_16px),
|
||||
h2: Heading2Icon(_16px),
|
||||
h3: Heading3Icon(_16px),
|
||||
h4: Heading4Icon(_16px),
|
||||
h5: Heading5Icon(_16px),
|
||||
h6: Heading6Icon(_16px),
|
||||
};
|
||||
|
||||
export const previewIconMap = {
|
||||
...paragraphIconMap,
|
||||
code: CodeBlockIcon(_16px),
|
||||
numbered: NumberedListIcon(_16px),
|
||||
bulleted: BulletedListIcon(_16px),
|
||||
todo: CheckBoxCheckLinearIcon(_16px),
|
||||
toggle: BlockIcon(_16px),
|
||||
bookmark: BookmarkIcon(_16px),
|
||||
image: ImageIcon(_16px),
|
||||
table: DatabaseTableViewIcon(_16px),
|
||||
kanban: DatabaseKanbanViewIcon(_16px),
|
||||
attachment: AttachmentIcon(_16px),
|
||||
};
|
||||
|
||||
const paragraphPlaceholderMap: Record<ParagraphBlockModel['type'], string> = {
|
||||
quote: 'Quote',
|
||||
text: 'Text Block',
|
||||
h1: 'Heading 1',
|
||||
h2: 'Heading 2',
|
||||
h3: 'Heading 3',
|
||||
h4: 'Heading 4',
|
||||
h5: 'Heading 5',
|
||||
h6: 'Heading 6',
|
||||
};
|
||||
|
||||
export const placeholderMap = {
|
||||
code: 'Code Block',
|
||||
bulleted: 'Bulleted List',
|
||||
numbered: 'Numbered List',
|
||||
toggle: 'Toggle List',
|
||||
todo: 'Todo',
|
||||
bookmark: 'Bookmark',
|
||||
image: 'Image',
|
||||
database: 'Database',
|
||||
attachment: 'Attachment',
|
||||
...paragraphPlaceholderMap,
|
||||
};
|
||||
|
||||
export const headingKeys = new Set(
|
||||
Object.keys(paragraphPlaceholderMap).filter(key => key.startsWith('h'))
|
||||
);
|
||||
|
||||
export const outlineSettingsKey = 'outlinePanelSettings';
|
||||
|
||||
export type TocContext = {
|
||||
editor$: Signal<EditorHost>;
|
||||
enableSorting$: Signal<boolean>;
|
||||
showIcons$: Signal<boolean>;
|
||||
fitPadding$: Signal<number[]>;
|
||||
};
|
||||
|
||||
export const tocContext = createContext<TocContext>('tocContext');
|
||||
@@ -0,0 +1,39 @@
|
||||
import { AFFINE_OUTLINE_NOTICE, OutlineNotice } from './body/outline-notice';
|
||||
import {
|
||||
AFFINE_OUTLINE_PANEL_BODY,
|
||||
OutlinePanelBody,
|
||||
} from './body/outline-panel-body';
|
||||
import { AFFINE_OUTLINE_NOTE_CARD, OutlineNoteCard } from './card/outline-card';
|
||||
import {
|
||||
AFFINE_OUTLINE_BLOCK_PREVIEW,
|
||||
OutlineBlockPreview,
|
||||
} from './card/outline-preview';
|
||||
import {
|
||||
AFFINE_OUTLINE_PANEL_HEADER,
|
||||
OutlinePanelHeader,
|
||||
} from './header/outline-panel-header';
|
||||
import {
|
||||
AFFINE_OUTLINE_NOTE_PREVIEW_SETTING_MENU,
|
||||
OutlineNotePreviewSettingMenu,
|
||||
} from './header/outline-setting-menu';
|
||||
import {
|
||||
AFFINE_MOBILE_OUTLINE_MENU,
|
||||
MobileOutlineMenu,
|
||||
} from './mobile-outline-panel';
|
||||
import { AFFINE_OUTLINE_PANEL, OutlinePanel } from './outline-panel';
|
||||
import { AFFINE_OUTLINE_VIEWER, OutlineViewer } from './outline-viewer';
|
||||
|
||||
export function effects() {
|
||||
customElements.define(
|
||||
AFFINE_OUTLINE_NOTE_PREVIEW_SETTING_MENU,
|
||||
OutlineNotePreviewSettingMenu
|
||||
);
|
||||
customElements.define(AFFINE_OUTLINE_NOTICE, OutlineNotice);
|
||||
customElements.define(AFFINE_OUTLINE_PANEL, OutlinePanel);
|
||||
customElements.define(AFFINE_OUTLINE_PANEL_HEADER, OutlinePanelHeader);
|
||||
customElements.define(AFFINE_OUTLINE_NOTE_CARD, OutlineNoteCard);
|
||||
customElements.define(AFFINE_OUTLINE_VIEWER, OutlineViewer);
|
||||
customElements.define(AFFINE_MOBILE_OUTLINE_MENU, MobileOutlineMenu);
|
||||
customElements.define(AFFINE_OUTLINE_BLOCK_PREVIEW, OutlineBlockPreview);
|
||||
customElements.define(AFFINE_OUTLINE_PANEL_BODY, OutlinePanelBody);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createButtonPopper } from '@blocksuite/affine-shared/utils';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import { SettingsIcon, SortIcon } from '@blocksuite/icons/lit';
|
||||
import { consume } from '@lit/context';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { html } from 'lit';
|
||||
import { query } from 'lit/decorators.js';
|
||||
|
||||
import { type TocContext, tocContext } from '../config';
|
||||
import * as styles from './outline-panel-header.css';
|
||||
|
||||
export const AFFINE_OUTLINE_PANEL_HEADER = 'affine-outline-panel-header';
|
||||
|
||||
export class OutlinePanelHeader extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
private _notePreviewSettingMenuPopper: ReturnType<
|
||||
typeof createButtonPopper
|
||||
> | null = null;
|
||||
|
||||
private readonly _settingPopperShow$ = signal(false);
|
||||
|
||||
override firstUpdated() {
|
||||
this._notePreviewSettingMenuPopper = createButtonPopper(
|
||||
this._noteSettingButton,
|
||||
this._notePreviewSettingMenu,
|
||||
({ display }) => {
|
||||
this._settingPopperShow$.value = display === 'show';
|
||||
},
|
||||
{
|
||||
mainAxis: 14,
|
||||
crossAxis: -30,
|
||||
}
|
||||
);
|
||||
this.disposables.add(this._notePreviewSettingMenuPopper);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const sortingEnabled = this._context.enableSorting$.value;
|
||||
const showSettingPopper = this._settingPopperShow$.value;
|
||||
|
||||
return html`<div class=${styles.container}>
|
||||
<div class=${styles.noteSettingContainer}>
|
||||
<span class=${styles.label}>Table of Contents</span>
|
||||
<edgeless-tool-icon-button
|
||||
data-testid="toggle-toc-setting-button"
|
||||
class="${showSettingPopper ? 'active' : ''}"
|
||||
.tooltip=${showSettingPopper ? '' : 'Preview Settings'}
|
||||
.tipPosition=${'bottom'}
|
||||
.active=${showSettingPopper}
|
||||
.activeMode=${'background'}
|
||||
@click=${() => this._notePreviewSettingMenuPopper?.toggle()}
|
||||
>
|
||||
${SettingsIcon({ width: '20px', height: '20px' })}
|
||||
</edgeless-tool-icon-button>
|
||||
</div>
|
||||
<edgeless-tool-icon-button
|
||||
data-testid="toggle-notes-sorting-button"
|
||||
class="${sortingEnabled ? 'active' : ''}"
|
||||
.tooltip=${'Visibility and sort'}
|
||||
.tipPosition=${'left'}
|
||||
.iconContainerPadding=${0}
|
||||
.active=${sortingEnabled}
|
||||
.activeMode=${'color'}
|
||||
@click=${() => {
|
||||
this._context.enableSorting$.value = !sortingEnabled;
|
||||
}}
|
||||
>
|
||||
${SortIcon({ width: '20px', height: '20px' })}
|
||||
</edgeless-tool-icon-button>
|
||||
</div>
|
||||
<div class=${styles.notePreviewSettingContainer}>
|
||||
<affine-outline-note-preview-setting-menu></affine-outline-note-preview-setting-menu>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@query(`.${styles.notePreviewSettingContainer}`)
|
||||
private accessor _notePreviewSettingMenu!: HTMLDivElement;
|
||||
|
||||
@query(`.${styles.noteSettingContainer}`)
|
||||
private accessor _noteSettingButton!: HTMLDivElement;
|
||||
|
||||
@consume({ context: tocContext })
|
||||
private accessor _context!: TocContext;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_OUTLINE_PANEL_HEADER]: OutlinePanelHeader;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import { consume } from '@lit/context';
|
||||
import { html } from 'lit';
|
||||
|
||||
import { type TocContext, tocContext } from '../config';
|
||||
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 SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
override render() {
|
||||
const showPreviewIcon = this._context.showIcons$.value;
|
||||
|
||||
return html`<div
|
||||
class=${styles.notePreviewSettingMenuContainer}
|
||||
@click=${(e: MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
<div class=${styles.notePreviewSettingMenuItem}>
|
||||
<div class=${styles.settingLabel}>Settings</div>
|
||||
</div>
|
||||
<div class="${styles.notePreviewSettingMenuItem} ${styles.action}">
|
||||
<div class=${styles.actionLabel}>Show type icon</div>
|
||||
<div class=${styles.toggleButton}>
|
||||
<toggle-switch
|
||||
.on=${showPreviewIcon}
|
||||
.onChange=${() => {
|
||||
this._context.showIcons$.value = !showPreviewIcon;
|
||||
}}
|
||||
></toggle-switch>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@consume({ context: tocContext })
|
||||
private accessor _context!: TocContext;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_OUTLINE_NOTE_PREVIEW_SETTING_MENU]: OutlineNotePreviewSettingMenu;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './mobile-outline-panel.js';
|
||||
export * from './outline-panel.js';
|
||||
export * from './outline-viewer.js';
|
||||
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
NoteDisplayMode,
|
||||
ParagraphBlockModel,
|
||||
RootBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { DocModeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
type EditorHost,
|
||||
PropTypes,
|
||||
requiredProperties,
|
||||
} from '@blocksuite/block-std';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import { getHeadingBlocksFromDoc } from './utils/query.js';
|
||||
import {
|
||||
observeActiveHeadingDuringScroll,
|
||||
scrollToBlockWithHighlight,
|
||||
} from './utils/scroll.js';
|
||||
|
||||
export const AFFINE_MOBILE_OUTLINE_MENU = 'affine-mobile-outline-menu';
|
||||
|
||||
@requiredProperties({
|
||||
editor: PropTypes.object,
|
||||
})
|
||||
export class MobileOutlineMenu extends SignalWatcher(
|
||||
WithDisposable(LitElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
position: relative;
|
||||
display: flex;
|
||||
max-height: 100%;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
:host::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.outline-menu-item {
|
||||
display: inline;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
padding: 11px 8px;
|
||||
overflow: hidden;
|
||||
color: ${unsafeCSSVarV2('text/primary')};
|
||||
text-overflow: ellipsis;
|
||||
/* Body/Regular */
|
||||
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-size: 17px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
letter-spacing: -0.43px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.outline-menu-item.active {
|
||||
color: ${unsafeCSSVarV2('text/emphasis')};
|
||||
}
|
||||
|
||||
.outline-menu-item:active {
|
||||
background: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.outline-menu-item.title,
|
||||
.outline-menu-item.h1 {
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.outline-menu-item.h2 {
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.outline-menu-item.h3 {
|
||||
padding-left: 48px;
|
||||
}
|
||||
|
||||
.outline-menu-item.h4 {
|
||||
padding-left: 68px;
|
||||
}
|
||||
|
||||
.outline-menu-item.h5 {
|
||||
padding-left: 88px;
|
||||
}
|
||||
|
||||
.outline-menu-item.h6 {
|
||||
padding-left: 108px;
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _activeHeadingId$ = signal<string | null>(null);
|
||||
|
||||
private _highlightMaskDisposable = () => {};
|
||||
|
||||
private _lockActiveHeadingId = false;
|
||||
|
||||
private async _scrollToBlock(blockId: string) {
|
||||
this._lockActiveHeadingId = true;
|
||||
this._activeHeadingId$.value = blockId;
|
||||
this._highlightMaskDisposable = await scrollToBlockWithHighlight(
|
||||
this.editor,
|
||||
blockId
|
||||
);
|
||||
this._lockActiveHeadingId = false;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.disposables.add(
|
||||
observeActiveHeadingDuringScroll(
|
||||
() => this.editor,
|
||||
newHeadingId => {
|
||||
if (this._lockActiveHeadingId) return;
|
||||
this._activeHeadingId$.value = newHeadingId;
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._highlightMaskDisposable();
|
||||
}
|
||||
|
||||
renderItem = (item: BlockModel) => {
|
||||
let className = '';
|
||||
let text = '';
|
||||
if (matchModels(item, [RootBlockModel])) {
|
||||
className = 'title';
|
||||
text = item.title$.value.toString();
|
||||
} else if (
|
||||
matchModels(item, [ParagraphBlockModel]) &&
|
||||
['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(item.type$.value)
|
||||
) {
|
||||
className = item.type$.value;
|
||||
text = item.text$.value.toString();
|
||||
} else {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<div
|
||||
class=${classMap({
|
||||
'outline-menu-item': true,
|
||||
[className]: true,
|
||||
active: this._activeHeadingId$.value === item.id,
|
||||
})}
|
||||
@click=${() => {
|
||||
this._scrollToBlock(item.id).catch(console.error);
|
||||
}}
|
||||
>
|
||||
${text}
|
||||
</div>`;
|
||||
};
|
||||
|
||||
override render() {
|
||||
const docModeService = this.editor.std.get(DocModeProvider);
|
||||
const mode = docModeService.getEditorMode();
|
||||
if (this.editor.doc.root === null || mode === 'edgeless') return nothing;
|
||||
|
||||
const headingBlocks = getHeadingBlocksFromDoc(
|
||||
this.editor.doc,
|
||||
[NoteDisplayMode.DocAndEdgeless, NoteDisplayMode.DocOnly],
|
||||
true
|
||||
);
|
||||
|
||||
if (headingBlocks.length === 0) return nothing;
|
||||
|
||||
const items = [
|
||||
...(this.editor.doc.meta?.title !== '' ? [this.editor.doc.root] : []),
|
||||
...headingBlocks,
|
||||
];
|
||||
|
||||
return repeat(items, block => block.id, this.renderItem);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor editor!: EditorHost;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_MOBILE_OUTLINE_MENU]: MobileOutlineMenu;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { DocModeProvider } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
type EditorHost,
|
||||
PropTypes,
|
||||
requiredProperties,
|
||||
ShadowlessElement,
|
||||
} from '@blocksuite/block-std';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import { provide } from '@lit/context';
|
||||
import { effect, signal } from '@preact/signals-core';
|
||||
import { html, type PropertyValues } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import { outlineSettingsKey, type TocContext, tocContext } from './config.js';
|
||||
import * as styles from './outline-panel.css';
|
||||
|
||||
export const AFFINE_OUTLINE_PANEL = 'affine-outline-panel';
|
||||
|
||||
@requiredProperties({
|
||||
editor: PropTypes.object,
|
||||
})
|
||||
export class OutlinePanel extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
private _getEditorMode(host: EditorHost) {
|
||||
const docModeService = host.std.get(DocModeProvider);
|
||||
const mode = docModeService.getEditorMode();
|
||||
return mode;
|
||||
}
|
||||
|
||||
private _setContext() {
|
||||
this._context = {
|
||||
editor$: signal(this.editor),
|
||||
showIcons$: signal<boolean>(false),
|
||||
enableSorting$: signal<boolean>(false),
|
||||
fitPadding$: signal<number[]>(this.fitPadding),
|
||||
};
|
||||
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const settingsString = localStorage.getItem(outlineSettingsKey);
|
||||
const settings = settingsString ? JSON.parse(settingsString) : null;
|
||||
|
||||
if (settings) {
|
||||
this._context.showIcons$.value = settings.showIcons;
|
||||
}
|
||||
|
||||
const editor = this._context.editor$.value;
|
||||
if (this._getEditorMode(editor) === 'edgeless') {
|
||||
this._context.enableSorting$.value = true;
|
||||
} else if (settings) {
|
||||
this._context.enableSorting$.value = settings.enableSorting;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private _watchSettingsChange() {
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
if (this._getEditorMode(this._context.editor$.value) === 'edgeless')
|
||||
return;
|
||||
|
||||
const showPreviewIcon = this._context.showIcons$.value;
|
||||
const enableNotesSorting = this._context.enableSorting$.value;
|
||||
localStorage.setItem(
|
||||
outlineSettingsKey,
|
||||
JSON.stringify({
|
||||
showIcons: showPreviewIcon,
|
||||
enableSorting: enableNotesSorting,
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.classList.add(styles.outlinePanel);
|
||||
|
||||
this._setContext();
|
||||
this._watchSettingsChange();
|
||||
}
|
||||
|
||||
override willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
if (changedProperties.has('editor')) {
|
||||
this._context.editor$.value = this.editor;
|
||||
}
|
||||
if (changedProperties.has('fitPadding')) {
|
||||
this._context.fitPadding$.value = this.fitPadding;
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.editor) return;
|
||||
|
||||
return html`
|
||||
<affine-outline-panel-header></affine-outline-panel-header>
|
||||
<affine-outline-panel-body> </affine-outline-panel-body>
|
||||
<affine-outline-notice></affine-outline-notice>
|
||||
`;
|
||||
}
|
||||
|
||||
@provide({ context: tocContext })
|
||||
private accessor _context!: TocContext;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor editor!: EditorHost;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor fitPadding!: number[];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_OUTLINE_PANEL]: OutlinePanel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { NoteDisplayMode } from '@blocksuite/affine-model';
|
||||
import { DocModeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { scrollbarStyle } from '@blocksuite/affine-shared/styles';
|
||||
import {
|
||||
type EditorHost,
|
||||
PropTypes,
|
||||
requiredProperties,
|
||||
ShadowlessElement,
|
||||
} from '@blocksuite/block-std';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import { TocIcon } from '@blocksuite/icons/lit';
|
||||
import { provide } from '@lit/context';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { css, html, nothing, type PropertyValues } 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 TocContext, tocContext } from './config.js';
|
||||
import { getHeadingBlocksFromDoc } from './utils/query.js';
|
||||
import {
|
||||
observeActiveHeadingDuringScroll,
|
||||
scrollToBlockWithHighlight,
|
||||
} from './utils/scroll.js';
|
||||
|
||||
export const AFFINE_OUTLINE_VIEWER = 'affine-outline-viewer';
|
||||
|
||||
@requiredProperties({
|
||||
editor: PropTypes.object,
|
||||
})
|
||||
export class OutlineViewer extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
affine-outline-viewer {
|
||||
display: flex;
|
||||
}
|
||||
.outline-viewer-root {
|
||||
--duration: 120ms;
|
||||
--timing: cubic-bezier(0.42, 0, 0.58, 1);
|
||||
|
||||
max-height: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.outline-viewer-indicators-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.outline-viewer-indicator-wrapper {
|
||||
flex: 1 1 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.outline-viewer-indicator {
|
||||
width: 20px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
overflow: hidden;
|
||||
background: var(--affine-black-10, rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
.outline-viewer-indicator.active {
|
||||
width: 24px;
|
||||
background: var(--affine-text-primary-color);
|
||||
}
|
||||
|
||||
.outline-viewer-panel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 0px;
|
||||
max-height: 100%;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
border-radius: 8px;
|
||||
border-width: 0px;
|
||||
border-style: solid;
|
||||
border-color: var(--affine-border-color);
|
||||
background: var(--affine-background-overlay-panel-color);
|
||||
box-shadow: 0px 6px 16px 0px rgba(0, 0, 0, 0.14);
|
||||
|
||||
overflow-y: auto;
|
||||
|
||||
opacity: 0;
|
||||
transform: translateX(0px);
|
||||
transition:
|
||||
width 0s var(--duration),
|
||||
padding 0s var(--duration),
|
||||
border-width 0s var(--duration),
|
||||
transform var(--duration) var(--timing),
|
||||
opacity var(--duration) var(--timing);
|
||||
}
|
||||
|
||||
${scrollbarStyle('.outline-viewer-panel')}
|
||||
|
||||
.outline-viewer-header {
|
||||
display: flex;
|
||||
padding: 6px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
align-self: stretch;
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
color: var(--affine-text-secondary-color);
|
||||
text-overflow: ellipsis;
|
||||
|
||||
font-family: var(--affine-font-family);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.outline-viewer-item {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.outline-viewer-root:hover {
|
||||
.outline-viewer-indicators-container {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.outline-viewer-panel {
|
||||
width: 200px;
|
||||
border-width: 1px;
|
||||
padding: 8px 4px 8px 8px;
|
||||
opacity: 1;
|
||||
transform: translateX(-10px);
|
||||
transition:
|
||||
transform var(--duration) var(--timing),
|
||||
opacity var(--duration) var(--timing);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _activeHeadingId$ = signal<string | null>(null);
|
||||
|
||||
private _highlightMaskDisposable = () => {};
|
||||
|
||||
private _lockActiveHeadingId = false;
|
||||
|
||||
private readonly _scrollPanel = () => {
|
||||
this._activeItem?.scrollIntoView({
|
||||
behavior: 'instant',
|
||||
block: 'center',
|
||||
});
|
||||
};
|
||||
|
||||
private async _scrollToBlock(blockId: string) {
|
||||
this._lockActiveHeadingId = true;
|
||||
this._activeHeadingId$.value = blockId;
|
||||
this._highlightMaskDisposable = await scrollToBlockWithHighlight(
|
||||
this.editor,
|
||||
blockId
|
||||
);
|
||||
this._lockActiveHeadingId = false;
|
||||
}
|
||||
|
||||
private _toggleOutlinePanel() {
|
||||
if (this.toggleOutlinePanel) {
|
||||
this._showViewer = false;
|
||||
this.toggleOutlinePanel();
|
||||
}
|
||||
}
|
||||
|
||||
private _setContext() {
|
||||
this._context = {
|
||||
editor$: signal(this.editor),
|
||||
showIcons$: signal<boolean>(false),
|
||||
enableSorting$: signal<boolean>(false),
|
||||
fitPadding$: signal<number[]>([]),
|
||||
};
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.disposables.add(
|
||||
observeActiveHeadingDuringScroll(
|
||||
() => this.editor,
|
||||
newHeadingId => {
|
||||
if (this._lockActiveHeadingId) return;
|
||||
this._activeHeadingId$.value = newHeadingId;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.disposables.add(
|
||||
this.editor.doc.workspace.meta.docMetaUpdated.on(() => {
|
||||
this.requestUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
this._setContext();
|
||||
}
|
||||
|
||||
override willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
if (changedProperties.has('editor')) {
|
||||
this._context.editor$.value = this.editor;
|
||||
}
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._highlightMaskDisposable();
|
||||
}
|
||||
|
||||
override render() {
|
||||
const docModeService = this.editor.std.get(DocModeProvider);
|
||||
const mode = docModeService.getEditorMode();
|
||||
if (this.editor.doc.root === null || mode === 'edgeless') return nothing;
|
||||
|
||||
const headingBlocks = getHeadingBlocksFromDoc(
|
||||
this.editor.doc,
|
||||
[NoteDisplayMode.DocAndEdgeless, NoteDisplayMode.DocOnly],
|
||||
true
|
||||
);
|
||||
|
||||
if (headingBlocks.length === 0) return nothing;
|
||||
|
||||
const items = [
|
||||
...(this.editor.doc.meta?.title !== '' ? [this.editor.doc.root] : []),
|
||||
...headingBlocks,
|
||||
];
|
||||
|
||||
const toggleOutlinePanelButton =
|
||||
this.toggleOutlinePanel !== null
|
||||
? html`<edgeless-tool-icon-button
|
||||
.tooltip=${'Open in sidebar'}
|
||||
.tipPosition=${'top-end'}
|
||||
.activeMode=${'background'}
|
||||
@click=${this._toggleOutlinePanel}
|
||||
data-testid="toggle-outline-panel-button"
|
||||
>
|
||||
${TocIcon({ width: '1em', height: '1em' })}
|
||||
</edgeless-tool-icon-button>`
|
||||
: nothing;
|
||||
|
||||
return html`
|
||||
<div class="outline-viewer-root" @mouseenter=${this._scrollPanel}>
|
||||
<div class="outline-viewer-indicators-container">
|
||||
${repeat(
|
||||
items,
|
||||
block => block.id,
|
||||
block =>
|
||||
html`<div class="outline-viewer-indicator-wrapper">
|
||||
<div
|
||||
class=${classMap({
|
||||
'outline-viewer-indicator': true,
|
||||
active: this._activeHeadingId$.value === block.id,
|
||||
})}
|
||||
></div>
|
||||
</div>`
|
||||
)}
|
||||
</div>
|
||||
<div class="outline-viewer-panel">
|
||||
<div class="outline-viewer-item outline-viewer-header">
|
||||
<span>Table of Contents</span>
|
||||
${toggleOutlinePanelButton}
|
||||
</div>
|
||||
${repeat(
|
||||
items,
|
||||
block => block.id,
|
||||
block => {
|
||||
return html`<div
|
||||
class=${classMap({
|
||||
'outline-viewer-item': true,
|
||||
active: this._activeHeadingId$.value === block.id,
|
||||
})}
|
||||
>
|
||||
<affine-outline-block-preview
|
||||
class=${classMap({
|
||||
active: this._activeHeadingId$.value === block.id,
|
||||
})}
|
||||
.block=${block}
|
||||
@click=${() => {
|
||||
this._scrollToBlock(block.id).catch(console.error);
|
||||
}}
|
||||
>
|
||||
</affine-outline-block-preview>
|
||||
</div>`;
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@provide({ context: tocContext })
|
||||
private accessor _context!: TocContext;
|
||||
|
||||
@query('.outline-viewer-item.active')
|
||||
private accessor _activeItem: HTMLElement | null = null;
|
||||
|
||||
@state()
|
||||
private accessor _showViewer: boolean = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor editor!: EditorHost;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor toggleOutlinePanel: (() => void) | null = null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
[AFFINE_OUTLINE_VIEWER]: OutlineViewer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { NoteBlockModel, NoteDisplayMode } from '@blocksuite/affine-model';
|
||||
|
||||
export type ReorderEvent = CustomEvent<{
|
||||
currentNumber: number;
|
||||
targetNumber: number;
|
||||
realIndex: number;
|
||||
}>;
|
||||
|
||||
export type SelectEvent = CustomEvent<{
|
||||
id: string;
|
||||
selected: boolean;
|
||||
number: number;
|
||||
multiselect: boolean;
|
||||
}>;
|
||||
|
||||
export type FitViewEvent = CustomEvent<{
|
||||
block: NoteBlockModel;
|
||||
}>;
|
||||
|
||||
export type ClickBlockEvent = CustomEvent<{
|
||||
blockId: string;
|
||||
}>;
|
||||
|
||||
export type DisplayModeChangeEvent = CustomEvent<{
|
||||
note: NoteBlockModel;
|
||||
newMode: NoteDisplayMode;
|
||||
}>;
|
||||
@@ -0,0 +1,8 @@
|
||||
export type NoteCardEntity = {
|
||||
type: 'toc-card';
|
||||
noteId: string;
|
||||
};
|
||||
|
||||
export type NoteDropPayload = {
|
||||
noteId: string;
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
NoteBlockModel,
|
||||
NoteDisplayMode,
|
||||
ParagraphBlockModel,
|
||||
RootBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import type { BlockModel, Store } from '@blocksuite/store';
|
||||
|
||||
import { headingKeys } from '../config.js';
|
||||
|
||||
export function getNotesFromDoc(
|
||||
doc: Store,
|
||||
modes: NoteDisplayMode[] = [
|
||||
NoteDisplayMode.DocAndEdgeless,
|
||||
NoteDisplayMode.DocOnly,
|
||||
NoteDisplayMode.EdgelessOnly,
|
||||
]
|
||||
) {
|
||||
const rootModel = doc.root;
|
||||
if (!rootModel) return [];
|
||||
|
||||
const notes: NoteBlockModel[] = [];
|
||||
|
||||
rootModel.children.forEach(block => {
|
||||
if (!matchModels(block, [NoteBlockModel])) return;
|
||||
|
||||
if (modes.includes(block.displayMode$.value)) {
|
||||
notes.push(block);
|
||||
}
|
||||
});
|
||||
|
||||
return notes;
|
||||
}
|
||||
|
||||
export function isRootBlock(block: BlockModel): block is RootBlockModel {
|
||||
return matchModels(block, [RootBlockModel]);
|
||||
}
|
||||
|
||||
export function isHeadingBlock(
|
||||
block: BlockModel
|
||||
): block is ParagraphBlockModel {
|
||||
return (
|
||||
matchModels(block, [ParagraphBlockModel]) &&
|
||||
headingKeys.has(block.type$.value)
|
||||
);
|
||||
}
|
||||
|
||||
export function getHeadingBlocksFromNote(
|
||||
note: NoteBlockModel,
|
||||
ignoreEmpty = false
|
||||
) {
|
||||
const models = note.children.filter(block => {
|
||||
const empty = block.text && block.text.length > 0;
|
||||
return isHeadingBlock(block) && (!ignoreEmpty || empty);
|
||||
});
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
export function getHeadingBlocksFromDoc(
|
||||
doc: Store,
|
||||
modes: NoteDisplayMode[] = [
|
||||
NoteDisplayMode.DocAndEdgeless,
|
||||
NoteDisplayMode.DocOnly,
|
||||
NoteDisplayMode.EdgelessOnly,
|
||||
],
|
||||
ignoreEmpty = false
|
||||
) {
|
||||
const notes = getNotesFromDoc(doc, modes);
|
||||
return notes.map(note => getHeadingBlocksFromNote(note, ignoreEmpty)).flat();
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { getDocTitleByEditorHost } from '@blocksuite/affine-components/doc-title';
|
||||
import { NoteDisplayMode } from '@blocksuite/affine-model';
|
||||
import { DocModeProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { Viewport } from '@blocksuite/affine-shared/types';
|
||||
import type { EditorHost } from '@blocksuite/block-std';
|
||||
import { clamp, DisposableGroup } from '@blocksuite/global/utils';
|
||||
|
||||
import { getHeadingBlocksFromDoc } from './query.js';
|
||||
|
||||
export function scrollToBlock(host: EditorHost, blockId: string) {
|
||||
const docModeService = host.std.get(DocModeProvider);
|
||||
const mode = docModeService.getEditorMode();
|
||||
if (mode === 'edgeless') return;
|
||||
|
||||
if (host.doc.root?.id === blockId) {
|
||||
const docTitle = getDocTitleByEditorHost(host);
|
||||
if (!docTitle) return;
|
||||
|
||||
docTitle.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
} else {
|
||||
const block = host.view.getBlock(blockId);
|
||||
if (!block) return;
|
||||
block.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function isBlockBeforeViewportCenter(
|
||||
blockId: string,
|
||||
editorHost: EditorHost
|
||||
) {
|
||||
const block = editorHost.view.getBlock(blockId);
|
||||
if (!block) return false;
|
||||
|
||||
const editorRect = (
|
||||
editorHost.parentElement ?? editorHost
|
||||
).getBoundingClientRect();
|
||||
const blockRect = block.getBoundingClientRect();
|
||||
|
||||
const editorCenter =
|
||||
clamp(editorRect.top, 0, document.documentElement.clientHeight) +
|
||||
Math.min(editorRect.height, document.documentElement.clientHeight) / 2;
|
||||
|
||||
const blockCenter = blockRect.top + blockRect.height / 2;
|
||||
|
||||
return blockCenter < editorCenter + blockRect.height;
|
||||
}
|
||||
|
||||
export const observeActiveHeadingDuringScroll = (
|
||||
getEditor: () => EditorHost, // workaround for editor changed
|
||||
update: (activeHeading: string | null) => void
|
||||
) => {
|
||||
const handler = () => {
|
||||
const host = getEditor();
|
||||
|
||||
const headings = getHeadingBlocksFromDoc(
|
||||
host.doc,
|
||||
[NoteDisplayMode.DocAndEdgeless, NoteDisplayMode.DocOnly],
|
||||
true
|
||||
);
|
||||
|
||||
let activeHeadingId = host.doc.root?.id ?? null;
|
||||
headings.forEach(heading => {
|
||||
if (isBlockBeforeViewportCenter(heading.id, host)) {
|
||||
activeHeadingId = heading.id;
|
||||
}
|
||||
});
|
||||
update(activeHeadingId);
|
||||
};
|
||||
handler();
|
||||
|
||||
const disposables = new DisposableGroup();
|
||||
disposables.addFromEvent(window, 'scroll', handler, true);
|
||||
|
||||
return disposables;
|
||||
};
|
||||
|
||||
let highlightMask: HTMLDivElement | null = null;
|
||||
let highlightTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function highlightBlock(host: EditorHost, blockId: string) {
|
||||
const emptyClear = () => {};
|
||||
|
||||
if (host.doc.root?.id === blockId) return emptyClear;
|
||||
|
||||
const rootComponent = host.querySelector<
|
||||
HTMLElement & { viewport: Viewport }
|
||||
>('affine-page-root');
|
||||
if (!rootComponent) return emptyClear;
|
||||
|
||||
if (!rootComponent.viewport) {
|
||||
console.error('viewport should exist');
|
||||
return emptyClear;
|
||||
}
|
||||
|
||||
const {
|
||||
top: offsetY,
|
||||
left: offsetX,
|
||||
scrollTop,
|
||||
scrollLeft,
|
||||
} = rootComponent.viewport;
|
||||
|
||||
const block = host.view.getBlock(blockId);
|
||||
if (!block) return emptyClear;
|
||||
|
||||
const blockRect = block.getBoundingClientRect();
|
||||
const { top, left, width, height } = blockRect;
|
||||
|
||||
if (!highlightMask) {
|
||||
highlightMask = document.createElement('div');
|
||||
rootComponent.append(highlightMask);
|
||||
}
|
||||
|
||||
Object.assign(highlightMask.style, {
|
||||
position: 'absolute',
|
||||
top: `${top - offsetY + scrollTop}px`,
|
||||
left: `${left - offsetX + scrollLeft}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
background: 'var(--affine-hover-color)',
|
||||
borderRadius: '4px',
|
||||
display: 'block',
|
||||
});
|
||||
|
||||
// Clear the previous timeout if it exists
|
||||
if (highlightTimeoutId !== null) {
|
||||
clearTimeout(highlightTimeoutId);
|
||||
}
|
||||
|
||||
highlightTimeoutId = setTimeout(() => {
|
||||
if (highlightMask) {
|
||||
highlightMask.style.display = 'none';
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
if (highlightMask !== null) {
|
||||
highlightMask.remove();
|
||||
highlightMask = null;
|
||||
}
|
||||
if (highlightTimeoutId !== null) {
|
||||
clearTimeout(highlightTimeoutId);
|
||||
highlightTimeoutId = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// this function is useful when the scroll need smooth animation
|
||||
let highlightIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||
export async function scrollToBlockWithHighlight(
|
||||
host: EditorHost,
|
||||
blockId: string,
|
||||
timeout = 3000
|
||||
) {
|
||||
scrollToBlock(host, blockId);
|
||||
|
||||
let timeCount = 0;
|
||||
|
||||
return new Promise<ReturnType<typeof highlightBlock>>(resolve => {
|
||||
if (highlightIntervalId !== null) {
|
||||
clearInterval(highlightIntervalId);
|
||||
}
|
||||
|
||||
// wait block be scrolled into view
|
||||
let lastTop = -1;
|
||||
highlightIntervalId = setInterval(() => {
|
||||
if (highlightIntervalId === null) {
|
||||
console.error('unreachable code');
|
||||
return;
|
||||
}
|
||||
|
||||
const block = host.view.getBlock(blockId);
|
||||
|
||||
if (!block || timeCount > timeout) {
|
||||
clearInterval(highlightIntervalId);
|
||||
resolve(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
const blockRect = block.getBoundingClientRect();
|
||||
const { top } = blockRect;
|
||||
|
||||
if (top !== lastTop) {
|
||||
timeCount += 100;
|
||||
lastTop = top;
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(highlightIntervalId);
|
||||
|
||||
// highlight block
|
||||
resolve(highlightBlock(host, blockId));
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../block-note" },
|
||||
{ "path": "../components" },
|
||||
{ "path": "../model" },
|
||||
{ "path": "../shared" },
|
||||
{ "path": "../../framework/block-std" },
|
||||
{ "path": "../../framework/global" },
|
||||
{ "path": "../../framework/inline" },
|
||||
{ "path": "../../framework/store" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user