mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-26 02:35:58 +08:00
feat(editor): show doc title in page block (#9975)
Close [BS-2392](https://linear.app/affine-design/issue/BS-2392/page-block-需要显示文章title) ### What Changes - Add `<doc-title>` to edgeless page block (a.k.a the first page visible note block) - Refactors: - Move `<doc-title>` to `@blocksuite/affine-component`, but you can aslo import it from `@blocksuite/preset` - Extract `<edgeless-note-mask>` and `<edgeless-note-background>` from `<affine-edgeless-note>` to a seperate file - Rewrite styles of `<affine-edgeless-note>` with `@vanilla-extract/css` https://github.com/user-attachments/assets/a0c03239-803e-4bfa-b30e-33b919213b12
This commit is contained in:
@@ -28,6 +28,7 @@
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.7",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@vanilla-extract/css": "^1.17.0",
|
||||
"lit": "^3.2.0",
|
||||
"minimatch": "^10.0.1",
|
||||
"zod": "^3.23.8"
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
import {
|
||||
ACTIVE_NOTE_EXTRA_PADDING,
|
||||
edgelessNoteContainer,
|
||||
} from '../note-edgeless-block.css';
|
||||
|
||||
export const background = style({
|
||||
position: 'absolute',
|
||||
borderColor: cssVar('black10'),
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
|
||||
selectors: {
|
||||
[`${edgelessNoteContainer}[data-editing="true"] &`]: {
|
||||
left: `${-ACTIVE_NOTE_EXTRA_PADDING}px`,
|
||||
top: `${-ACTIVE_NOTE_EXTRA_PADDING}px`,
|
||||
width: `calc(100% + ${ACTIVE_NOTE_EXTRA_PADDING * 2}px)`,
|
||||
height: `calc(100% + ${ACTIVE_NOTE_EXTRA_PADDING * 2}px)`,
|
||||
transition: 'left 0.3s, top 0.3s, width 0.3s, height 0.3s',
|
||||
boxShadow: cssVar('activeShadow'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import {
|
||||
DefaultTheme,
|
||||
NoteBlockModel,
|
||||
NoteDisplayMode,
|
||||
StrokeStyle,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
FeatureFlagService,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
getClosestBlockComponentByPoint,
|
||||
handleNativeRangeAtPoint,
|
||||
matchFlavours,
|
||||
stopPropagation,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
type BlockComponent,
|
||||
type BlockStdScope,
|
||||
PropTypes,
|
||||
requiredProperties,
|
||||
ShadowlessElement,
|
||||
stdContext,
|
||||
TextSelection,
|
||||
} from '@blocksuite/block-std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
|
||||
import {
|
||||
clamp,
|
||||
Point,
|
||||
SignalWatcher,
|
||||
WithDisposable,
|
||||
} from '@blocksuite/global/utils';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
import { consume } from '@lit/context';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { html, nothing } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import * as styles from './edgeless-note-background.css';
|
||||
|
||||
@requiredProperties({
|
||||
note: PropTypes.instanceOf(NoteBlockModel),
|
||||
})
|
||||
export class EdgelessNoteBackground extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
readonly backgroundStyle$ = computed(() => {
|
||||
const themeProvider = this.std.get(ThemeProvider);
|
||||
const theme = themeProvider.theme$.value;
|
||||
const backgroundColor = themeProvider.generateColorProperty(
|
||||
this.note.background$.value,
|
||||
DefaultTheme.noteBackgrounColor,
|
||||
theme
|
||||
);
|
||||
|
||||
const { borderRadius, borderSize, borderStyle, shadowType } =
|
||||
this.note.edgeless$.value.style;
|
||||
|
||||
return {
|
||||
borderRadius: borderRadius + 'px',
|
||||
backgroundColor: backgroundColor,
|
||||
borderWidth: `${borderSize}px`,
|
||||
borderStyle: borderStyle === StrokeStyle.Dash ? 'dashed' : borderStyle,
|
||||
boxShadow: !shadowType ? 'none' : `var(${shadowType})`,
|
||||
};
|
||||
});
|
||||
|
||||
get gfx() {
|
||||
return this.std.get(GfxControllerIdentifier);
|
||||
}
|
||||
|
||||
get doc() {
|
||||
return this.std.host.doc;
|
||||
}
|
||||
|
||||
private get _isPageBlock() {
|
||||
return (
|
||||
this.std.get(FeatureFlagService).getFlag('enable_page_block') &&
|
||||
// is the first page visible note
|
||||
this.note.parent?.children.find(
|
||||
child =>
|
||||
matchFlavours(child, ['affine:note']) &&
|
||||
child.displayMode !== NoteDisplayMode.EdgelessOnly
|
||||
) === this.note
|
||||
);
|
||||
}
|
||||
|
||||
private _tryAddParagraph(x: number, y: number) {
|
||||
const nearest = getClosestBlockComponentByPoint(
|
||||
new Point(x, y)
|
||||
) as BlockComponent | null;
|
||||
if (!nearest) return;
|
||||
|
||||
const nearestBBox = nearest.getBoundingClientRect();
|
||||
const yRel = y - nearestBBox.top;
|
||||
|
||||
const insertPos: 'before' | 'after' =
|
||||
yRel < nearestBBox.height / 2 ? 'before' : 'after';
|
||||
|
||||
const nearestModel = nearest.model as BlockModel;
|
||||
const nearestModelIdx = this.note.children.indexOf(nearestModel);
|
||||
|
||||
const children = this.note.children;
|
||||
const siblingModel =
|
||||
children[
|
||||
clamp(
|
||||
nearestModelIdx + (insertPos === 'before' ? -1 : 1),
|
||||
0,
|
||||
children.length
|
||||
)
|
||||
];
|
||||
|
||||
if (
|
||||
(!nearestModel.text ||
|
||||
!matchFlavours(nearestModel, ['affine:paragraph', 'affine:list'])) &&
|
||||
(!siblingModel ||
|
||||
!siblingModel.text ||
|
||||
!matchFlavours(siblingModel, ['affine:paragraph', 'affine:list']))
|
||||
) {
|
||||
const [pId] = this.doc.addSiblingBlocks(
|
||||
nearestModel,
|
||||
[{ flavour: 'affine:paragraph' }],
|
||||
insertPos
|
||||
);
|
||||
|
||||
this.updateComplete
|
||||
.then(() => {
|
||||
this.std.selection.setGroup('note', [
|
||||
this.std.selection.create(TextSelection, {
|
||||
from: {
|
||||
blockId: pId,
|
||||
index: 0,
|
||||
length: 0,
|
||||
},
|
||||
to: null,
|
||||
}),
|
||||
]);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleClickAtBackground(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (!this.editing) return;
|
||||
|
||||
const { zoom } = this.gfx.viewport;
|
||||
|
||||
const rect = this.getBoundingClientRect();
|
||||
const offsetY = 16 * zoom;
|
||||
const offsetX = 2 * zoom;
|
||||
const x = clamp(e.x, rect.left + offsetX, rect.right - offsetX);
|
||||
const y = clamp(e.y, rect.top + offsetY, rect.bottom - offsetY);
|
||||
handleNativeRangeAtPoint(x, y);
|
||||
|
||||
if (this.std.host.doc.readonly) return;
|
||||
|
||||
this._tryAddParagraph(x, y);
|
||||
}
|
||||
|
||||
private _renderHeader() {
|
||||
const header = this.std
|
||||
.getConfig('affine:note')
|
||||
?.edgelessNoteHeader({ note: this.note, std: this.std });
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`<div
|
||||
class=${styles.background}
|
||||
style=${styleMap(this.backgroundStyle$.value)}
|
||||
@pointerdown=${stopPropagation}
|
||||
@click=${this._handleClickAtBackground}
|
||||
>
|
||||
${this._isPageBlock ? this._renderHeader() : nothing}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@consume({ context: stdContext })
|
||||
accessor std!: BlockStdScope;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor editing: boolean = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor note!: NoteBlockModel;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-note-background': EdgelessNoteBackground;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { NoteBlockModel } from '@blocksuite/affine-model';
|
||||
import { type EditorHost, ShadowlessElement } from '@blocksuite/block-std';
|
||||
import {
|
||||
almostEqual,
|
||||
Bound,
|
||||
SignalWatcher,
|
||||
WithDisposable,
|
||||
} from '@blocksuite/global/utils';
|
||||
import { html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { ACTIVE_NOTE_EXTRA_PADDING } from '../note-edgeless-block.css';
|
||||
|
||||
export class EdgelessNoteMask extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
protected override firstUpdated() {
|
||||
const maskDOM = this.renderRoot!.querySelector('.affine-note-mask');
|
||||
const observer = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
if (!this.model.edgeless.collapse) {
|
||||
const bound = Bound.deserialize(this.model.xywh);
|
||||
const scale = this.model.edgeless.scale ?? 1;
|
||||
const height = entry.contentRect.height * scale;
|
||||
|
||||
if (!height || almostEqual(bound.h, height)) {
|
||||
return;
|
||||
}
|
||||
|
||||
bound.h = height;
|
||||
this.model.stash('xywh');
|
||||
this.model.xywh = bound.serialize();
|
||||
this.model.pop('xywh');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(maskDOM!);
|
||||
|
||||
this._disposables.add(() => {
|
||||
observer.disconnect();
|
||||
});
|
||||
}
|
||||
|
||||
override render() {
|
||||
const extra = this.editing ? ACTIVE_NOTE_EXTRA_PADDING : 0;
|
||||
return html`
|
||||
<div
|
||||
class="affine-note-mask"
|
||||
style=${styleMap({
|
||||
position: 'absolute',
|
||||
top: `${-extra}px`,
|
||||
left: `${-extra}px`,
|
||||
bottom: `${-extra}px`,
|
||||
right: `${-extra}px`,
|
||||
zIndex: '1',
|
||||
pointerEvents: this.editing ? 'none' : 'auto',
|
||||
borderRadius: `${
|
||||
this.model.edgeless.style.borderRadius * this.zoom
|
||||
}px`,
|
||||
})}
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor editing!: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor host!: EditorHost;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor model!: NoteBlockModel;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor zoom!: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'edgeless-note-mask': EdgelessNoteMask;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
import { EdgelessNoteBackground } from './components/edgeless-note-background';
|
||||
import { EdgelessNoteMask } from './components/edgeless-note-mask';
|
||||
import type { NoteConfig } from './config';
|
||||
import { NoteBlockComponent } from './note-block';
|
||||
import {
|
||||
AFFINE_EDGELESS_NOTE,
|
||||
EdgelessNoteBlockComponent,
|
||||
EdgelessNoteMask,
|
||||
} from './note-edgeless-block';
|
||||
import type { NoteBlockService } from './note-service';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('affine-note', NoteBlockComponent);
|
||||
customElements.define(AFFINE_EDGELESS_NOTE, EdgelessNoteBlockComponent);
|
||||
customElements.define('edgeless-note-mask', EdgelessNoteMask);
|
||||
customElements.define('affine-edgeless-note', EdgelessNoteBlockComponent);
|
||||
customElements.define('edgeless-note-background', EdgelessNoteBackground);
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './adapters';
|
||||
export * from './commands';
|
||||
export * from './components/edgeless-note-background';
|
||||
export * from './config';
|
||||
export * from './note-block';
|
||||
export * from './note-edgeless-block';
|
||||
|
||||
85
blocksuite/affine/block-note/src/note-edgeless-block.css.ts
Normal file
85
blocksuite/affine/block-note/src/note-edgeless-block.css.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { EDGELESS_BLOCK_CHILD_PADDING } from '@blocksuite/affine-shared/consts';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const ACTIVE_NOTE_EXTRA_PADDING = 20;
|
||||
|
||||
export const edgelessNoteContainer = style({
|
||||
height: '100%',
|
||||
padding: `${EDGELESS_BLOCK_CHILD_PADDING}px`,
|
||||
boxSizing: 'border-box',
|
||||
pointerEvents: 'all',
|
||||
transformOrigin: '0 0',
|
||||
fontWeight: '400',
|
||||
lineHeight: cssVar('lineHeight'),
|
||||
});
|
||||
|
||||
export const collapseButton = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
zIndex: 2,
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
opacity: 0.2,
|
||||
transition: 'opacity 0.3s',
|
||||
|
||||
':hover': {
|
||||
opacity: 1,
|
||||
},
|
||||
selectors: {
|
||||
'&.flip': {
|
||||
transform: 'translateX(-50%) rotate(180deg)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const noteBackground = style({
|
||||
position: 'absolute',
|
||||
borderColor: cssVar('black10'),
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
|
||||
selectors: {
|
||||
[`${edgelessNoteContainer}[data-editing="true"] &`]: {
|
||||
left: `${-ACTIVE_NOTE_EXTRA_PADDING}px`,
|
||||
top: `${-ACTIVE_NOTE_EXTRA_PADDING}px`,
|
||||
width: `calc(100% + ${ACTIVE_NOTE_EXTRA_PADDING * 2}px)`,
|
||||
height: `calc(100% + ${ACTIVE_NOTE_EXTRA_PADDING * 2}px)`,
|
||||
transition: 'left 0.3s, top 0.3s, width 0.3s, height 0.3s',
|
||||
boxShadow: cssVar('activeShadow'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
globalStyle(`${edgelessNoteContainer} > doc-title`, {
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
globalStyle(`${edgelessNoteContainer} > doc-title .doc-title-container`, {
|
||||
padding: '26px 0px',
|
||||
fontSize: cssVar('fontTitle'),
|
||||
fontWeight: 700,
|
||||
lineHeight: '44px',
|
||||
});
|
||||
|
||||
export const pageContent = style({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
export const collapsedContent = style({
|
||||
position: 'absolute',
|
||||
background: cssVar('white'),
|
||||
opacity: 0.5,
|
||||
pointerEvents: 'none',
|
||||
border: `2px ${cssVar('blue')} solid`,
|
||||
borderTop: 'unset',
|
||||
borderRadius: '0 0 8px 8px',
|
||||
});
|
||||
@@ -1,160 +1,35 @@
|
||||
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import type { DocTitle } from '@blocksuite/affine-components/doc-title';
|
||||
import { MoreIndicatorIcon } from '@blocksuite/affine-components/icons';
|
||||
import type { NoteBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
DefaultTheme,
|
||||
NoteDisplayMode,
|
||||
StrokeStyle,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { NoteDisplayMode } from '@blocksuite/affine-model';
|
||||
import { EDGELESS_BLOCK_CHILD_PADDING } from '@blocksuite/affine-shared/consts';
|
||||
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
FeatureFlagService,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
getClosestBlockComponentByPoint,
|
||||
handleNativeRangeAtPoint,
|
||||
matchFlavours,
|
||||
stopPropagation,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import type { BlockComponent, EditorHost } from '@blocksuite/block-std';
|
||||
import {
|
||||
ShadowlessElement,
|
||||
TextSelection,
|
||||
toGfxBlockComponent,
|
||||
} from '@blocksuite/block-std';
|
||||
import {
|
||||
almostEqual,
|
||||
Bound,
|
||||
clamp,
|
||||
Point,
|
||||
WithDisposable,
|
||||
} from '@blocksuite/global/utils';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { css, html, nothing } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { toGfxBlockComponent } from '@blocksuite/block-std';
|
||||
import { Bound } from '@blocksuite/global/utils';
|
||||
import { html, nothing } from 'lit';
|
||||
import { query, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { NoteBlockComponent } from './note-block.js';
|
||||
import { NoteBlockComponent } from './note-block';
|
||||
import { ACTIVE_NOTE_EXTRA_PADDING } from './note-edgeless-block.css';
|
||||
import * as styles from './note-edgeless-block.css';
|
||||
|
||||
export class EdgelessNoteMask extends WithDisposable(ShadowlessElement) {
|
||||
protected override firstUpdated() {
|
||||
const maskDOM = this.renderRoot!.querySelector('.affine-note-mask');
|
||||
const observer = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
if (!this.model.edgeless.collapse) {
|
||||
const bound = Bound.deserialize(this.model.xywh);
|
||||
const scale = this.model.edgeless.scale ?? 1;
|
||||
const height = entry.contentRect.height * scale;
|
||||
|
||||
if (!height || almostEqual(bound.h, height)) {
|
||||
return;
|
||||
}
|
||||
|
||||
bound.h = height;
|
||||
this.model.stash('xywh');
|
||||
this.model.xywh = bound.serialize();
|
||||
this.model.pop('xywh');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(maskDOM!);
|
||||
|
||||
this._disposables.add(() => {
|
||||
observer.disconnect();
|
||||
});
|
||||
}
|
||||
|
||||
override render() {
|
||||
const extra = this.editing ? ACTIVE_NOTE_EXTRA_PADDING : 0;
|
||||
return html`
|
||||
<div
|
||||
class="affine-note-mask"
|
||||
style=${styleMap({
|
||||
position: 'absolute',
|
||||
top: `${-extra}px`,
|
||||
left: `${-extra}px`,
|
||||
bottom: `${-extra}px`,
|
||||
right: `${-extra}px`,
|
||||
zIndex: '1',
|
||||
pointerEvents: this.editing ? 'none' : 'auto',
|
||||
borderRadius: `${
|
||||
this.model.edgeless.style.borderRadius * this.zoom
|
||||
}px`,
|
||||
})}
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor editing!: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor host!: EditorHost;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor model!: NoteBlockModel;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor zoom!: number;
|
||||
}
|
||||
|
||||
const ACTIVE_NOTE_EXTRA_PADDING = 20;
|
||||
export const AFFINE_EDGELESS_NOTE = 'affine-edgeless-note';
|
||||
|
||||
export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
|
||||
NoteBlockComponent
|
||||
) {
|
||||
static override styles = css`
|
||||
.edgeless-note-collapse-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
opacity: 0.2;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.edgeless-note-collapse-button:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.edgeless-note-collapse-button.flip {
|
||||
transform: translateX(-50%) rotate(180deg);
|
||||
}
|
||||
|
||||
.edgeless-note-container:has(.affine-embed-synced-doc-container.editing)
|
||||
> .note-background {
|
||||
left: ${-ACTIVE_NOTE_EXTRA_PADDING}px !important;
|
||||
top: ${-ACTIVE_NOTE_EXTRA_PADDING}px !important;
|
||||
width: calc(100% + ${ACTIVE_NOTE_EXTRA_PADDING * 2}px) !important;
|
||||
height: calc(100% + ${ACTIVE_NOTE_EXTRA_PADDING * 2}px) !important;
|
||||
}
|
||||
|
||||
.edgeless-note-container:has(.affine-embed-synced-doc-container.editing)
|
||||
> edgeless-note-mask {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _backgroundColor$ = computed(() => {
|
||||
const themeProvider = this.std.get(ThemeProvider);
|
||||
const theme = themeProvider.theme$.value;
|
||||
return themeProvider.generateColorProperty(
|
||||
this.model.background$.value,
|
||||
DefaultTheme.noteBackgrounColor,
|
||||
theme
|
||||
private get _isPageBlock() {
|
||||
return (
|
||||
this.std.get(FeatureFlagService).getFlag('enable_page_block') &&
|
||||
this._isFirstVisibleNote()
|
||||
);
|
||||
});
|
||||
|
||||
private get _enablePageHeader() {
|
||||
return this.std.get(FeatureFlagService).getFlag('enable_page_block_header');
|
||||
}
|
||||
|
||||
private get _isShowCollapsedContent() {
|
||||
@@ -201,38 +76,21 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
|
||||
|
||||
return html`
|
||||
<div
|
||||
class=${styles.collapsedContent}
|
||||
style=${styleMap({
|
||||
width: `${width}px`,
|
||||
height: `${this._noteFullHeight - height}px`,
|
||||
position: 'absolute',
|
||||
left: `${-(extraPadding + extraBorder / 2)}px`,
|
||||
top: `${height + extraPadding + extraBorder / 2}px`,
|
||||
background: 'var(--affine-white)',
|
||||
opacity: 0.5,
|
||||
pointerEvents: 'none',
|
||||
borderLeft: '2px var(--affine-blue) solid',
|
||||
borderBottom: '2px var(--affine-blue) solid',
|
||||
borderRight: '2px var(--affine-blue) solid',
|
||||
borderRadius: '0 0 8px 8px',
|
||||
})}
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleClickAtBackground(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (!this._editing) return;
|
||||
|
||||
const rect = this.getBoundingClientRect();
|
||||
const offsetY = 16 * this._zoom;
|
||||
const offsetX = 2 * this._zoom;
|
||||
const x = clamp(e.x, rect.left + offsetX, rect.right - offsetX);
|
||||
const y = clamp(e.y, rect.top + offsetY, rect.bottom - offsetY);
|
||||
handleNativeRangeAtPoint(x, y);
|
||||
|
||||
if (this.doc.readonly) return;
|
||||
|
||||
this._tryAddParagraph(x, y);
|
||||
private _handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowUp' && this._isPageBlock) {
|
||||
this._docTitle?.inlineEditor?.focusEnd();
|
||||
}
|
||||
}
|
||||
|
||||
private _hovered() {
|
||||
@@ -261,14 +119,6 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
|
||||
}
|
||||
}
|
||||
|
||||
private _renderHeader() {
|
||||
const header = this.host.std
|
||||
.getConfig('affine:note')
|
||||
?.edgelessNoteHeader({ note: this.model, std: this.std });
|
||||
|
||||
return header;
|
||||
}
|
||||
|
||||
private _setCollapse(event: MouseEvent) {
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
@@ -291,61 +141,6 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
|
||||
this.selection.clear();
|
||||
}
|
||||
|
||||
private _tryAddParagraph(x: number, y: number) {
|
||||
const nearest = getClosestBlockComponentByPoint(
|
||||
new Point(x, y)
|
||||
) as BlockComponent | null;
|
||||
if (!nearest) return;
|
||||
|
||||
const nearestBBox = nearest.getBoundingClientRect();
|
||||
const yRel = y - nearestBBox.top;
|
||||
|
||||
const insertPos: 'before' | 'after' =
|
||||
yRel < nearestBBox.height / 2 ? 'before' : 'after';
|
||||
|
||||
const nearestModel = nearest.model as BlockModel;
|
||||
const nearestModelIdx = this.model.children.indexOf(nearestModel);
|
||||
|
||||
const children = this.model.children;
|
||||
const siblingModel =
|
||||
children[
|
||||
clamp(
|
||||
nearestModelIdx + (insertPos === 'before' ? -1 : 1),
|
||||
0,
|
||||
children.length
|
||||
)
|
||||
];
|
||||
|
||||
if (
|
||||
(!nearestModel.text ||
|
||||
!matchFlavours(nearestModel, ['affine:paragraph', 'affine:list'])) &&
|
||||
(!siblingModel ||
|
||||
!siblingModel.text ||
|
||||
!matchFlavours(siblingModel, ['affine:paragraph', 'affine:list']))
|
||||
) {
|
||||
const [pId] = this.doc.addSiblingBlocks(
|
||||
nearestModel,
|
||||
[{ flavour: 'affine:paragraph' }],
|
||||
insertPos
|
||||
);
|
||||
|
||||
this.updateComplete
|
||||
.then(() => {
|
||||
this.std.selection.setGroup('note', [
|
||||
this.std.selection.create(TextSelection, {
|
||||
from: {
|
||||
blockId: pId,
|
||||
index: 0,
|
||||
length: 0,
|
||||
},
|
||||
to: null,
|
||||
}),
|
||||
]);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
@@ -361,6 +156,8 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.addFromEvent(this, 'keydown', this._handleKeyDown);
|
||||
}
|
||||
|
||||
get edgelessSlots() {
|
||||
@@ -423,49 +220,19 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
|
||||
return nothing;
|
||||
|
||||
const { xywh, edgeless } = model;
|
||||
const { borderRadius, borderSize, borderStyle, shadowType } =
|
||||
edgeless.style;
|
||||
const { collapse, collapsedHeight, scale = 1 } = edgeless;
|
||||
const { borderRadius } = edgeless.style;
|
||||
const { collapse = false, collapsedHeight, scale = 1 } = edgeless;
|
||||
|
||||
const bound = Bound.deserialize(xywh);
|
||||
const width = bound.w / scale;
|
||||
const height = bound.h / scale;
|
||||
|
||||
const style = {
|
||||
height: '100%',
|
||||
padding: `${EDGELESS_BLOCK_CHILD_PADDING}px`,
|
||||
boxSizing: 'border-box',
|
||||
borderRadius: borderRadius + 'px',
|
||||
pointerEvents: 'all',
|
||||
transformOrigin: '0 0',
|
||||
transform: `scale(${scale})`,
|
||||
fontWeight: '400',
|
||||
lineHeight: 'var(--affine-line-height)',
|
||||
};
|
||||
|
||||
const extra = this._editing ? ACTIVE_NOTE_EXTRA_PADDING : 0;
|
||||
|
||||
const backgroundStyle = {
|
||||
position: 'absolute',
|
||||
left: `${-extra}px`,
|
||||
top: `${-extra}px`,
|
||||
width: `${width + extra * 2}px`,
|
||||
height: `calc(100% + ${extra * 2}px)`,
|
||||
borderRadius: borderRadius + 'px',
|
||||
transition: this._editing
|
||||
? 'left 0.3s, top 0.3s, width 0.3s, height 0.3s'
|
||||
: 'none',
|
||||
backgroundColor: this._backgroundColor$.value,
|
||||
border: `${borderSize}px ${
|
||||
borderStyle === StrokeStyle.Dash ? 'dashed' : borderStyle
|
||||
} var(--affine-black-10)`,
|
||||
boxShadow: this._editing
|
||||
? 'var(--affine-active-shadow)'
|
||||
: !shadowType
|
||||
? 'none'
|
||||
: `var(${shadowType})`,
|
||||
};
|
||||
|
||||
const isCollapsable =
|
||||
collapse != null &&
|
||||
collapsedHeight != null &&
|
||||
@@ -477,21 +244,27 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="edgeless-note-container"
|
||||
class=${styles.edgelessNoteContainer}
|
||||
style=${styleMap(style)}
|
||||
data-model-height="${bound.h}"
|
||||
data-editing=${this._editing}
|
||||
data-collapse=${ifDefined(collapse)}
|
||||
data-testid="edgeless-note-container"
|
||||
@mouseleave=${this._leaved}
|
||||
@mousemove=${this._hovered}
|
||||
data-scale="${scale}"
|
||||
>
|
||||
<div
|
||||
class="note-background"
|
||||
style=${styleMap(backgroundStyle)}
|
||||
@pointerdown=${stopPropagation}
|
||||
@click=${this._handleClickAtBackground}
|
||||
>
|
||||
${this._enablePageHeader ? this._renderHeader() : nothing}
|
||||
</div>
|
||||
<edgeless-note-background
|
||||
.editing=${this._editing}
|
||||
.note=${this.model}
|
||||
></edgeless-note-background>
|
||||
|
||||
${this._isPageBlock && !collapse
|
||||
? html`<doc-title
|
||||
.doc=${this.doc}
|
||||
.wrapText=${!collapse}
|
||||
></doc-title>`
|
||||
: nothing}
|
||||
|
||||
<div
|
||||
class="edgeless-note-page-content"
|
||||
@@ -511,16 +284,16 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
|
||||
.editing=${this._editing}
|
||||
></edgeless-note-mask>
|
||||
|
||||
${isCollapsable &&
|
||||
(!this._isFirstVisibleNote() || !this._enablePageHeader)
|
||||
${isCollapsable && !this._isPageBlock
|
||||
? html`<div
|
||||
class="${classMap({
|
||||
'edgeless-note-collapse-button': true,
|
||||
[styles.collapseButton]: true,
|
||||
flip: isCollapseArrowUp,
|
||||
})}"
|
||||
style=${styleMap({
|
||||
bottom: this._editing ? `${-extra}px` : '0',
|
||||
})}
|
||||
data-testid="edgeless-note-collapse-button"
|
||||
@mousedown=${stopPropagation}
|
||||
@mouseup=${stopPropagation}
|
||||
@click=${this._setCollapse}
|
||||
@@ -547,10 +320,13 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
|
||||
|
||||
@query('.edgeless-note-page-content .affine-note-block-container')
|
||||
private accessor _notePageContent: HTMLElement | null = null;
|
||||
|
||||
@query('doc-title')
|
||||
private accessor _docTitle: DocTitle | null = null;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-edgeless-note': EdgelessNoteBlockComponent;
|
||||
[AFFINE_EDGELESS_NOTE]: EdgelessNoteBlockComponent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,8 @@
|
||||
"./toggle-switch": "./src/toggle-switch/index.ts",
|
||||
"./notification": "./src/notification/index.ts",
|
||||
"./block-zero-width": "./src/block-zero-width/index.ts",
|
||||
"./block-selection": "./src/block-selection/index.ts"
|
||||
"./block-selection": "./src/block-selection/index.ts",
|
||||
"./doc-title": "./src/doc-title/index.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
|
||||
223
blocksuite/affine/components/src/doc-title/doc-title.ts
Normal file
223
blocksuite/affine/components/src/doc-title/doc-title.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import {
|
||||
type NoteBlockModel,
|
||||
NoteDisplayMode,
|
||||
type RootBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { matchFlavours } from '@blocksuite/affine-shared/utils';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import type { Store } from '@blocksuite/store';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { css, html } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
|
||||
import { focusTextModel, type RichText } from '../rich-text';
|
||||
|
||||
const DOC_BLOCK_CHILD_PADDING = 24;
|
||||
|
||||
export class DocTitle extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = css`
|
||||
.doc-title-container {
|
||||
box-sizing: border-box;
|
||||
font-family: var(--affine-font-family);
|
||||
font-size: var(--affine-font-base);
|
||||
line-height: var(--affine-line-height);
|
||||
color: var(--affine-text-primary-color);
|
||||
font-size: 40px;
|
||||
line-height: 50px;
|
||||
font-weight: 700;
|
||||
outline: none;
|
||||
resize: none;
|
||||
border: 0;
|
||||
width: 100%;
|
||||
max-width: var(--affine-editor-width);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 38px 0;
|
||||
|
||||
padding-left: var(
|
||||
--affine-editor-side-padding,
|
||||
${DOC_BLOCK_CHILD_PADDING}px
|
||||
);
|
||||
padding-right: var(
|
||||
--affine-editor-side-padding,
|
||||
${DOC_BLOCK_CHILD_PADDING}px
|
||||
);
|
||||
}
|
||||
|
||||
/* Extra small devices (phones, 640px and down) */
|
||||
@container viewport (width <= 640px) {
|
||||
.doc-title-container {
|
||||
padding-left: ${DOC_BLOCK_CHILD_PADDING}px;
|
||||
padding-right: ${DOC_BLOCK_CHILD_PADDING}px;
|
||||
}
|
||||
}
|
||||
|
||||
.doc-title-container-empty::before {
|
||||
content: 'Title';
|
||||
color: var(--affine-placeholder-color);
|
||||
position: absolute;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.doc-title-container:disabled {
|
||||
background-color: transparent;
|
||||
}
|
||||
`;
|
||||
|
||||
private _getOrCreateFirstPageVisibleNote() {
|
||||
const note = this._rootModel.children.find(
|
||||
(child): child is NoteBlockModel =>
|
||||
matchFlavours(child, ['affine:note']) &&
|
||||
child.displayMode !== NoteDisplayMode.EdgelessOnly
|
||||
);
|
||||
if (note) return note;
|
||||
|
||||
const noteId = this.doc.addBlock('affine:note', {}, this._rootModel, 0);
|
||||
return this.doc.getBlock(noteId)?.model as NoteBlockModel;
|
||||
}
|
||||
|
||||
private readonly _onTitleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.isComposing || this.doc.readonly) return;
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const inlineRange = this.inlineEditor?.getInlineRange();
|
||||
if (inlineRange) {
|
||||
const rightText = this._rootModel.title.split(inlineRange.index);
|
||||
const newFirstParagraphId = this.doc.addBlock(
|
||||
'affine:paragraph',
|
||||
{ text: rightText },
|
||||
this._getOrCreateFirstPageVisibleNote(),
|
||||
0
|
||||
);
|
||||
if (this._std) focusTextModel(this._std, newFirstParagraphId);
|
||||
}
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const note = this._getOrCreateFirstPageVisibleNote();
|
||||
const firstText = note?.children.find(block =>
|
||||
matchFlavours(block, ['affine:paragraph', 'affine:list', 'affine:code'])
|
||||
);
|
||||
if (firstText) {
|
||||
if (this._std) focusTextModel(this._std, firstText.id);
|
||||
} else {
|
||||
const newFirstParagraphId = this.doc.addBlock(
|
||||
'affine:paragraph',
|
||||
{},
|
||||
note,
|
||||
0
|
||||
);
|
||||
if (this._std) focusTextModel(this._std, newFirstParagraphId);
|
||||
}
|
||||
} else if (event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _updateTitleInMeta = () => {
|
||||
this.doc.workspace.meta.setDocMeta(this.doc.id, {
|
||||
title: this._rootModel.title.toString(),
|
||||
});
|
||||
};
|
||||
|
||||
private get _std() {
|
||||
return this._viewport?.querySelector('editor-host')?.std;
|
||||
}
|
||||
|
||||
private get _rootModel() {
|
||||
return this.doc.root as RootBlockModel;
|
||||
}
|
||||
|
||||
private get _viewport() {
|
||||
return (
|
||||
this.closest<HTMLElement>('.affine-page-viewport') ??
|
||||
this.closest<HTMLElement>('.affine-edgeless-viewport')
|
||||
);
|
||||
}
|
||||
|
||||
get inlineEditor() {
|
||||
return this._richTextElement.inlineEditor;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this._isReadonly = this.doc.readonly;
|
||||
this._disposables.add(
|
||||
effect(() => {
|
||||
if (this._isReadonly !== this.doc.readonly) {
|
||||
this._isReadonly = this.doc.readonly;
|
||||
this.requestUpdate();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this._disposables.addFromEvent(this, 'keydown', this._onTitleKeyDown);
|
||||
|
||||
// Workaround for inline editor skips composition event
|
||||
this._disposables.addFromEvent(
|
||||
this,
|
||||
'compositionstart',
|
||||
() => (this._isComposing = true)
|
||||
);
|
||||
|
||||
this._disposables.addFromEvent(
|
||||
this,
|
||||
'compositionend',
|
||||
() => (this._isComposing = false)
|
||||
);
|
||||
|
||||
const updateMetaTitle = () => {
|
||||
this._updateTitleInMeta();
|
||||
this.requestUpdate();
|
||||
};
|
||||
this._rootModel.title.yText.observe(updateMetaTitle);
|
||||
this._disposables.add(() => {
|
||||
this._rootModel.title.yText.unobserve(updateMetaTitle);
|
||||
});
|
||||
}
|
||||
|
||||
override render() {
|
||||
const isEmpty = !this._rootModel.title.length && !this._isComposing;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="doc-title-container ${isEmpty
|
||||
? 'doc-title-container-empty'
|
||||
: ''}"
|
||||
data-block-is-title="true"
|
||||
>
|
||||
<rich-text
|
||||
.yText=${this._rootModel.title.yText}
|
||||
.undoManager=${this.doc.history}
|
||||
.verticalScrollContainerGetter=${() => this._viewport}
|
||||
.readonly=${this.doc.readonly}
|
||||
.enableFormat=${false}
|
||||
.wrapText=${this.wrapText}
|
||||
></rich-text>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@state()
|
||||
private accessor _isComposing = false;
|
||||
|
||||
@state()
|
||||
private accessor _isReadonly = false;
|
||||
|
||||
@query('rich-text')
|
||||
private accessor _richTextElement!: RichText;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor doc!: Store;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor wrapText = false;
|
||||
}
|
||||
11
blocksuite/affine/components/src/doc-title/effects.ts
Normal file
11
blocksuite/affine/components/src/doc-title/effects.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { DocTitle } from './doc-title';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('doc-title', DocTitle);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'doc-title': DocTitle;
|
||||
}
|
||||
}
|
||||
3
blocksuite/affine/components/src/doc-title/index.ts
Normal file
3
blocksuite/affine/components/src/doc-title/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { DocTitle } from './doc-title';
|
||||
export { effects } from './effects';
|
||||
export { getDocTitleByEditorHost } from './utils';
|
||||
11
blocksuite/affine/components/src/doc-title/utils.ts
Normal file
11
blocksuite/affine/components/src/doc-title/utils.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { EditorHost } from '@blocksuite/block-std';
|
||||
|
||||
import type { DocTitle } from './doc-title';
|
||||
|
||||
export function getDocTitleByEditorHost(
|
||||
editorHost: EditorHost
|
||||
): DocTitle | null {
|
||||
const docViewport = editorHost.closest('.affine-page-viewport');
|
||||
if (!docViewport) return null;
|
||||
return docViewport.querySelector('doc-title');
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export interface BlockSuiteFlags {
|
||||
enable_shape_shadow_blur: boolean;
|
||||
enable_mobile_keyboard_toolbar: boolean;
|
||||
enable_mobile_linked_doc_menu: boolean;
|
||||
enable_page_block_header: boolean;
|
||||
enable_page_block: boolean;
|
||||
}
|
||||
|
||||
export class FeatureFlagService extends StoreExtension {
|
||||
@@ -41,7 +41,7 @@ export class FeatureFlagService extends StoreExtension {
|
||||
enable_shape_shadow_blur: false,
|
||||
enable_mobile_keyboard_toolbar: false,
|
||||
enable_mobile_linked_doc_menu: false,
|
||||
enable_page_block_header: false,
|
||||
enable_page_block: false,
|
||||
});
|
||||
|
||||
setFlag(key: keyof BlockSuiteFlags, value: boolean) {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
AFFINE_EDGELESS_NOTE,
|
||||
type EdgelessNoteBlockComponent,
|
||||
} from '@blocksuite/affine-block-note';
|
||||
import { ParagraphBlockComponent } from '@blocksuite/affine-block-paragraph';
|
||||
import type { ParagraphBlockModel } from '@blocksuite/affine-model';
|
||||
import { DocModeProvider } from '@blocksuite/affine-shared/services';
|
||||
@@ -155,7 +159,7 @@ export const getClosestNoteBlock = (
|
||||
editorHost.std.get(DocModeProvider).getEditorMode() === 'page';
|
||||
return isInsidePageEditor
|
||||
? findClosestBlockComponent(rootComponent, point, 'affine-note')
|
||||
: getHoveringNote(point)?.closest('affine-edgeless-note');
|
||||
: getHoveringNote(point);
|
||||
};
|
||||
|
||||
export const getClosestBlockByPoint = (
|
||||
@@ -261,11 +265,11 @@ export function getDuplicateBlocks(blocks: BlockModel[]) {
|
||||
*/
|
||||
function getHoveringNote(point: Point) {
|
||||
return (
|
||||
document.elementsFromPoint(point.x, point.y).find(isEdgelessChildNote) ||
|
||||
null
|
||||
document
|
||||
.elementsFromPoint(point.x, point.y)
|
||||
.find(
|
||||
(e): e is EdgelessNoteBlockComponent =>
|
||||
e.tagName.toLowerCase() === AFFINE_EDGELESS_NOTE
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
function isEdgelessChildNote({ classList }: Element) {
|
||||
return classList.contains('note-background');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user