refactor(editor): unify directories naming (#11516)
**Directory Structure Changes** - Renamed multiple block-related directories by removing the "block-" prefix: - `block-attachment` → `attachment` - `block-bookmark` → `bookmark` - `block-callout` → `callout` - `block-code` → `code` - `block-data-view` → `data-view` - `block-database` → `database` - `block-divider` → `divider` - `block-edgeless-text` → `edgeless-text` - `block-embed` → `embed`
This commit is contained in:
@@ -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 { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { CloseIcon, SortIcon } from '@blocksuite/icons/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
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,481 @@
|
||||
import { changeNoteDisplayMode } from '@blocksuite/affine-block-note';
|
||||
import { NoteBlockModel, NoteDisplayMode } from '@blocksuite/affine-model';
|
||||
import { DocModeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { focusTitle, matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
BlockSelection,
|
||||
ShadowlessElement,
|
||||
SurfaceSelection,
|
||||
} from '@blocksuite/std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
import { consume } from '@lit/context';
|
||||
import {
|
||||
batch,
|
||||
computed,
|
||||
effect,
|
||||
type Signal,
|
||||
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$: Record<
|
||||
NoteDisplayMode,
|
||||
Signal<NoteBlockModel[]>
|
||||
> = {
|
||||
[NoteDisplayMode.DocOnly]: signal<NoteBlockModel[]>([]),
|
||||
[NoteDisplayMode.DocAndEdgeless]: signal<NoteBlockModel[]>([]),
|
||||
[NoteDisplayMode.EdgelessOnly]: signal<NoteBlockModel[]>([]),
|
||||
};
|
||||
|
||||
private readonly _allSelectedNotes$ = computed(() =>
|
||||
[
|
||||
NoteDisplayMode.DocAndEdgeless,
|
||||
NoteDisplayMode.DocOnly,
|
||||
NoteDisplayMode.EdgelessOnly,
|
||||
].flatMap(mode => this._selectedNotes$[mode].value)
|
||||
);
|
||||
|
||||
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._allSelectedNotes$.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) {
|
||||
// if focus title
|
||||
if (blockId === this.doc.root?.id) {
|
||||
this.editor.std.selection.setGroup('note', []);
|
||||
this.editor.std.event.active = false;
|
||||
focusTitle(this.editor);
|
||||
} else {
|
||||
this.editor.std.event.active = true;
|
||||
this.editor.std.selection.setGroup('note', [
|
||||
this.editor.std.selection.create(BlockSelection, {
|
||||
blockId,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
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;
|
||||
|
||||
// map from signal to value
|
||||
const selectedNotes = Object.fromEntries(
|
||||
Object.entries(this._selectedNotes$).map(([k, v]) => [k, v.peek()])
|
||||
) as Record<NoteDisplayMode, NoteBlockModel[]>;
|
||||
|
||||
if (multiselect) {
|
||||
selectedNotes[note.props.displayMode] = selected
|
||||
? [...selectedNotes[note.props.displayMode], note]
|
||||
: selectedNotes[note.props.displayMode].filter(_note => _note !== note);
|
||||
} else {
|
||||
selectedNotes[note.props.displayMode] = selected ? [note] : [];
|
||||
Object.keys(this._selectedNotes$).forEach(mode => {
|
||||
if (mode !== note.props.displayMode) {
|
||||
selectedNotes[mode as NoteDisplayMode] = [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// We use gfx.selection and effect to keep sync between canvas and outline panel
|
||||
if (editorMode === 'edgeless') {
|
||||
gfx.selection.set({
|
||||
elements: [...selectedNotes.both, ...selectedNotes.edgeless].map(
|
||||
({ id }) => id
|
||||
),
|
||||
editing: false,
|
||||
});
|
||||
this._selectedNotes$.doc.value = selectedNotes.doc;
|
||||
} else {
|
||||
[NoteDisplayMode.DocOnly, NoteDisplayMode.DocAndEdgeless].forEach(
|
||||
mode => {
|
||||
this._selectedNotes$[mode].value = selectedNotes[mode];
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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]);
|
||||
});
|
||||
|
||||
// update selected notes from edgeless selection
|
||||
batch(() => {
|
||||
[NoteDisplayMode.DocAndEdgeless, NoteDisplayMode.EdgelessOnly].forEach(
|
||||
mode => {
|
||||
this._selectedNotes$[mode].value = currSelectedNotes.filter(
|
||||
note => note.props.displayMode === mode
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
},
|
||||
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._allSelectedNotes$.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;
|
||||
}
|
||||
}
|
||||
166
blocksuite/affine/fragments/outline/src/card/outline-card.css.ts
Normal file
166
blocksuite/affine/fragments/outline/src/card/outline-card.css.ts
Normal file
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
276
blocksuite/affine/fragments/outline/src/card/outline-card.ts
Normal file
276
blocksuite/affine/fragments/outline/src/card/outline-card.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { type NoteBlockModel, NoteDisplayMode } from '@blocksuite/affine-model';
|
||||
import { createButtonPopper } from '@blocksuite/affine-shared/utils';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { ArrowDownSmallIcon, InvisibleIcon } from '@blocksuite/icons/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
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.props.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.props.displayMode !== NoteDisplayMode.EdgelessOnly;
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._watchDragEvents();
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
this._displayModePopper = createButtonPopper({
|
||||
reference: this._displayModeButtonGroup,
|
||||
popperElement: this._displayModePanel,
|
||||
stateUpdated: ({ display }) => {
|
||||
this._showPopper$.value = display === 'show';
|
||||
},
|
||||
mainAxis: 0,
|
||||
crossAxis: -60,
|
||||
});
|
||||
|
||||
this.disposables.add(this._displayModePopper);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { displayMode } = this.note.props;
|
||||
const { children } = this.note;
|
||||
const currentMode = this._getCurrentModeLabel(displayMode);
|
||||
const invisible =
|
||||
this.note.props.displayMode$.value === NoteDisplayMode.EdgelessOnly;
|
||||
|
||||
const enableSorting = this._context.enableSorting$.value;
|
||||
|
||||
return html`
|
||||
<div
|
||||
data-visibility=${this.note.props.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',
|
||||
});
|
||||
227
blocksuite/affine/fragments/outline/src/card/outline-preview.ts
Normal file
227
blocksuite/affine/fragments/outline/src/card/outline-preview.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
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 { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { noop } from '@blocksuite/global/utils';
|
||||
import { LinkedPageIcon } from '@blocksuite/icons/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
import type { BlockModel, DeltaInsert } 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.props.text.yText.toDelta();
|
||||
if (!block.props.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.props.type in styles.subtypeStyles
|
||||
? styles.subtypeStyles[
|
||||
block.props.type as keyof typeof styles.subtypeStyles
|
||||
]
|
||||
: '';
|
||||
|
||||
return html`<span
|
||||
data-testid="outline-block-preview-${block.props.type}"
|
||||
class="${styles.text} ${styles.textGeneral} ${headingClass}"
|
||||
>${previewText}</span
|
||||
>
|
||||
${this._context.showIcons$.value
|
||||
? html`<span class=${iconClass}
|
||||
>${previewIconMap[block.props.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.props.title.length > 0
|
||||
? html`<span
|
||||
data-testid="outline-block-preview-title"
|
||||
class="${styles.text} ${styles.subtypeStyles.title}"
|
||||
>
|
||||
${block.props.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.props.title ||
|
||||
block.props.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.props.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.props.title.toString().length
|
||||
? block.props.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.props.caption?.length
|
||||
? block.props.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.props.name?.length
|
||||
? block.props.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;
|
||||
}
|
||||
}
|
||||
97
blocksuite/affine/fragments/outline/src/config.ts
Normal file
97
blocksuite/affine/fragments/outline/src/config.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { ParagraphBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
AttachmentIcon,
|
||||
BlockIcon,
|
||||
BookmarkIcon,
|
||||
BulletedListIcon,
|
||||
CheckBoxCheckLinearIcon,
|
||||
CodeBlockIcon,
|
||||
DatabaseKanbanViewIcon,
|
||||
DatabaseTableViewIcon,
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
Heading4Icon,
|
||||
Heading5Icon,
|
||||
Heading6Icon,
|
||||
ImageIcon,
|
||||
NumberedListIcon,
|
||||
QuoteIcon,
|
||||
TextIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import type { EditorHost } from '@blocksuite/std';
|
||||
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['props']['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['props']['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');
|
||||
39
blocksuite/affine/fragments/outline/src/effects.ts
Normal file
39
blocksuite/affine/fragments/outline/src/effects.ts
Normal file
@@ -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,90 @@
|
||||
import { createButtonPopper } from '@blocksuite/affine-shared/utils';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { SettingsIcon, SortIcon } from '@blocksuite/icons/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
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({
|
||||
reference: this._noteSettingButton,
|
||||
popperElement: this._notePreviewSettingMenu,
|
||||
stateUpdated: ({ 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 { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { ShadowlessElement } from '@blocksuite/std';
|
||||
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
|
||||
.subscribe=${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;
|
||||
}
|
||||
}
|
||||
3
blocksuite/affine/fragments/outline/src/index.ts
Normal file
3
blocksuite/affine/fragments/outline/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './mobile-outline-panel.js';
|
||||
export * from './outline-panel.js';
|
||||
export * from './outline-viewer.js';
|
||||
197
blocksuite/affine/fragments/outline/src/mobile-outline-panel.ts
Normal file
197
blocksuite/affine/fragments/outline/src/mobile-outline-panel.ts
Normal file
@@ -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 { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
type EditorHost,
|
||||
PropTypes,
|
||||
requiredProperties,
|
||||
} from '@blocksuite/std';
|
||||
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.props.title$.value.toString();
|
||||
} else if (
|
||||
matchModels(item, [ParagraphBlockModel]) &&
|
||||
['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(item.props.type$.value)
|
||||
) {
|
||||
className = item.props.type$.value;
|
||||
text = item.props.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;
|
||||
}
|
||||
}
|
||||
15
blocksuite/affine/fragments/outline/src/outline-panel.css.ts
Normal file
15
blocksuite/affine/fragments/outline/src/outline-panel.css.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const outlinePanel = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: cssVarV2('layer/background/primary'),
|
||||
boxSizing: 'border-box',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
fontFamily: cssVar('fontSansFamily'),
|
||||
paddingTop: '8px',
|
||||
position: 'relative',
|
||||
});
|
||||
118
blocksuite/affine/fragments/outline/src/outline-panel.ts
Normal file
118
blocksuite/affine/fragments/outline/src/outline-panel.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { DocModeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
type EditorHost,
|
||||
PropTypes,
|
||||
requiredProperties,
|
||||
ShadowlessElement,
|
||||
} from '@blocksuite/std';
|
||||
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;
|
||||
}
|
||||
}
|
||||
324
blocksuite/affine/fragments/outline/src/outline-viewer.ts
Normal file
324
blocksuite/affine/fragments/outline/src/outline-viewer.ts
Normal file
@@ -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 { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { TocIcon } from '@blocksuite/icons/lit';
|
||||
import {
|
||||
type EditorHost,
|
||||
PropTypes,
|
||||
requiredProperties,
|
||||
ShadowlessElement,
|
||||
} from '@blocksuite/std';
|
||||
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.subscribe(() => {
|
||||
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;
|
||||
}>;
|
||||
8
blocksuite/affine/fragments/outline/src/utils/drag.ts
Normal file
8
blocksuite/affine/fragments/outline/src/utils/drag.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type NoteCardEntity = {
|
||||
type: 'toc-card';
|
||||
noteId: string;
|
||||
};
|
||||
|
||||
export type NoteDropPayload = {
|
||||
noteId: string;
|
||||
};
|
||||
72
blocksuite/affine/fragments/outline/src/utils/query.ts
Normal file
72
blocksuite/affine/fragments/outline/src/utils/query.ts
Normal file
@@ -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.props.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.props.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();
|
||||
}
|
||||
201
blocksuite/affine/fragments/outline/src/utils/scroll.ts
Normal file
201
blocksuite/affine/fragments/outline/src/utils/scroll.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { getDocTitleByEditorHost } from '@blocksuite/affine-fragment-doc-title';
|
||||
import { NoteDisplayMode } from '@blocksuite/affine-model';
|
||||
import { DocModeProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { Viewport } from '@blocksuite/affine-shared/types';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { clamp } from '@blocksuite/global/gfx';
|
||||
import type { EditorHost } from '@blocksuite/std';
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user