chore(editor): reorg packages (#10702)
This commit is contained in:
47
blocksuite/affine/blocks/block-attachment/package.json
Normal file
47
blocksuite/affine/blocks/block-attachment/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@blocksuite/affine-block-attachment",
|
||||
"description": "Attachment block for BlockSuite.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test:unit": "nx vite:test --run --passWithNoTests",
|
||||
"test:unit:coverage": "nx vite:test --run --coverage",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-block-embed": "workspace:*",
|
||||
"@blocksuite/affine-block-surface": "workspace:*",
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||
"@blocksuite/block-std": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.1",
|
||||
"@blocksuite/inline": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.12",
|
||||
"file-type": "^20.0.0",
|
||||
"lit": "^3.2.0",
|
||||
"minimatch": "^10.0.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./effects": "./src/effects.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.20.0"
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { AttachmentBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockNotionHtmlAdapterExtension,
|
||||
type BlockNotionHtmlAdapterMatcher,
|
||||
FetchUtils,
|
||||
HastUtils,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { getFilenameFromContentDisposition } from '@blocksuite/affine-shared/utils';
|
||||
import { sha } from '@blocksuite/global/utils';
|
||||
import { getAssetName, nanoid } from '@blocksuite/store';
|
||||
|
||||
export const attachmentBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher =
|
||||
{
|
||||
flavour: AttachmentBlockSchema.model.flavour,
|
||||
toMatch: o => {
|
||||
return (
|
||||
HastUtils.isElement(o.node) &&
|
||||
o.node.tagName === 'figure' &&
|
||||
!!HastUtils.querySelector(o.node, '.source')
|
||||
);
|
||||
},
|
||||
fromMatch: () => false,
|
||||
toBlockSnapshot: {
|
||||
enter: async (o, context) => {
|
||||
if (!HastUtils.isElement(o.node)) {
|
||||
return;
|
||||
}
|
||||
const { assets, walkerContext } = context;
|
||||
if (!assets) {
|
||||
return;
|
||||
}
|
||||
|
||||
const embededFigureWrapper = HastUtils.querySelector(o.node, '.source');
|
||||
let embededURL = '';
|
||||
if (embededFigureWrapper) {
|
||||
const embedA = HastUtils.querySelector(embededFigureWrapper, 'a');
|
||||
embededURL =
|
||||
typeof embedA?.properties.href === 'string'
|
||||
? embedA.properties.href
|
||||
: '';
|
||||
}
|
||||
if (embededURL) {
|
||||
let blobId = '';
|
||||
let name = '';
|
||||
let type = '';
|
||||
let size = 0;
|
||||
if (!FetchUtils.fetchable(embededURL)) {
|
||||
const embededURLSplit = embededURL.split('/');
|
||||
while (embededURLSplit.length > 0) {
|
||||
const key = assets
|
||||
.getPathBlobIdMap()
|
||||
.get(decodeURIComponent(embededURLSplit.join('/')));
|
||||
if (key) {
|
||||
blobId = key;
|
||||
break;
|
||||
}
|
||||
embededURLSplit.shift();
|
||||
}
|
||||
const value = assets.getAssets().get(blobId);
|
||||
if (value) {
|
||||
name = getAssetName(assets.getAssets(), blobId);
|
||||
size = value.size;
|
||||
type = value.type;
|
||||
}
|
||||
} else {
|
||||
const res = await fetch(embededURL).catch(error => {
|
||||
console.warn('Error fetching embed:', error);
|
||||
return null;
|
||||
});
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
const resCloned = res.clone();
|
||||
name =
|
||||
getFilenameFromContentDisposition(
|
||||
res.headers.get('Content-Disposition') ?? ''
|
||||
) ??
|
||||
(embededURL.split('/').at(-1) ?? 'file') +
|
||||
'.' +
|
||||
(res.headers.get('Content-Type')?.split('/').at(-1) ?? 'blob');
|
||||
const file = new File([await res.blob()], name, {
|
||||
type: res.headers.get('Content-Type') ?? '',
|
||||
});
|
||||
size = file.size;
|
||||
type = file.type;
|
||||
blobId = await sha(await resCloned.arrayBuffer());
|
||||
assets?.getAssets().set(blobId, file);
|
||||
await assets?.writeToBlob(blobId);
|
||||
}
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: AttachmentBlockSchema.model.flavour,
|
||||
props: {
|
||||
name,
|
||||
size,
|
||||
type,
|
||||
sourceId: blobId,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode();
|
||||
walkerContext.skipAllChildren();
|
||||
}
|
||||
},
|
||||
},
|
||||
fromBlockSnapshot: {},
|
||||
};
|
||||
|
||||
export const AttachmentBlockNotionHtmlAdapterExtension =
|
||||
BlockNotionHtmlAdapterExtension(attachmentBlockNotionHtmlAdapterMatcher);
|
||||
@@ -0,0 +1,255 @@
|
||||
import { getEmbedCardIcons } from '@blocksuite/affine-block-embed';
|
||||
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
|
||||
import {
|
||||
AttachmentIcon16,
|
||||
getAttachmentFileIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { Peekable } from '@blocksuite/affine-components/peek';
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import {
|
||||
type AttachmentBlockModel,
|
||||
AttachmentBlockStyles,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
FileSizeLimitService,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { humanFileSize } from '@blocksuite/affine-shared/utils';
|
||||
import { BlockSelection } from '@blocksuite/block-std';
|
||||
import { Slice } from '@blocksuite/store';
|
||||
import { html } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { AttachmentEmbedProvider } from './embed';
|
||||
import { styles } from './styles';
|
||||
import { checkAttachmentBlob, downloadAttachmentBlob } from './utils';
|
||||
|
||||
@Peekable({
|
||||
enableOn: ({ model }: AttachmentBlockComponent) => {
|
||||
return model.type.endsWith('pdf');
|
||||
},
|
||||
})
|
||||
export class AttachmentBlockComponent extends CaptionedBlockComponent<AttachmentBlockModel> {
|
||||
static override styles = styles;
|
||||
|
||||
protected _isDragging = false;
|
||||
|
||||
protected _isResizing = false;
|
||||
|
||||
blockDraggable = true;
|
||||
|
||||
protected containerStyleMap = styleMap({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
margin: '18px 0px',
|
||||
});
|
||||
|
||||
private get _maxFileSize() {
|
||||
return this.std.store.get(FileSizeLimitService).maxFileSize;
|
||||
}
|
||||
|
||||
convertTo = () => {
|
||||
return this.std
|
||||
.get(AttachmentEmbedProvider)
|
||||
.convertTo(this.model, this._maxFileSize);
|
||||
};
|
||||
|
||||
copy = () => {
|
||||
const slice = Slice.fromModels(this.doc, [this.model]);
|
||||
this.std.clipboard.copySlice(slice).catch(console.error);
|
||||
toast(this.host, 'Copied to clipboard');
|
||||
};
|
||||
|
||||
download = () => {
|
||||
downloadAttachmentBlob(this);
|
||||
};
|
||||
|
||||
embedded = () => {
|
||||
return this.std
|
||||
.get(AttachmentEmbedProvider)
|
||||
.embedded(this.model, this._maxFileSize);
|
||||
};
|
||||
|
||||
open = () => {
|
||||
if (!this.blobUrl) {
|
||||
return;
|
||||
}
|
||||
window.open(this.blobUrl, '_blank');
|
||||
};
|
||||
|
||||
refreshData = () => {
|
||||
checkAttachmentBlob(this).catch(console.error);
|
||||
};
|
||||
|
||||
protected get embedView() {
|
||||
return this.std
|
||||
.get(AttachmentEmbedProvider)
|
||||
.render(this.model, this.blobUrl, this._maxFileSize);
|
||||
}
|
||||
|
||||
private _selectBlock() {
|
||||
const selectionManager = this.host.selection;
|
||||
const blockSelection = selectionManager.create(BlockSelection, {
|
||||
blockId: this.blockId,
|
||||
});
|
||||
selectionManager.setGroup('note', [blockSelection]);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.refreshData();
|
||||
|
||||
this.contentEditable = 'false';
|
||||
|
||||
if (!this.model.style) {
|
||||
this.doc.withoutTransact(() => {
|
||||
this.doc.updateBlock(this.model, {
|
||||
style: AttachmentBlockStyles[1],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.model.propsUpdated.on(({ key }) => {
|
||||
if (key === 'sourceId') {
|
||||
// Reset the blob url when the sourceId is changed
|
||||
if (this.blobUrl) {
|
||||
URL.revokeObjectURL(this.blobUrl);
|
||||
this.blobUrl = undefined;
|
||||
}
|
||||
this.refreshData();
|
||||
}
|
||||
});
|
||||
|
||||
// Workaround for https://github.com/toeverything/blocksuite/issues/4724
|
||||
this.disposables.add(
|
||||
this.std.get(ThemeProvider).theme$.subscribe(() => this.requestUpdate())
|
||||
);
|
||||
|
||||
// this is required to prevent iframe from capturing pointer events
|
||||
this.disposables.add(
|
||||
this.selected$.subscribe(selected => {
|
||||
this._showOverlay = this._isResizing || this._isDragging || !selected;
|
||||
})
|
||||
);
|
||||
// this is required to prevent iframe from capturing pointer events
|
||||
this.handleEvent('dragStart', () => {
|
||||
this._isDragging = true;
|
||||
this._showOverlay =
|
||||
this._isResizing || this._isDragging || !this.selected$.peek();
|
||||
});
|
||||
|
||||
this.handleEvent('dragEnd', () => {
|
||||
this._isDragging = false;
|
||||
this._showOverlay =
|
||||
this._isResizing || this._isDragging || !this.selected$.peek();
|
||||
});
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
if (this.blobUrl) {
|
||||
URL.revokeObjectURL(this.blobUrl);
|
||||
}
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
// lazy bindings
|
||||
this.disposables.addFromEvent(this, 'click', this.onClick);
|
||||
}
|
||||
|
||||
protected onClick(event: MouseEvent) {
|
||||
// the peek view need handle shift + click
|
||||
if (event.defaultPrevented) return;
|
||||
|
||||
event.stopPropagation();
|
||||
|
||||
this._selectBlock();
|
||||
}
|
||||
|
||||
override renderBlock() {
|
||||
const { name, size, style } = this.model;
|
||||
const cardStyle = style ?? AttachmentBlockStyles[1];
|
||||
|
||||
const theme = this.std.get(ThemeProvider).theme;
|
||||
const { LoadingIcon } = getEmbedCardIcons(theme);
|
||||
|
||||
const titleIcon = this.loading ? LoadingIcon : AttachmentIcon16;
|
||||
const titleText = this.loading ? 'Loading...' : name;
|
||||
const infoText = this.error ? 'File loading failed.' : humanFileSize(size);
|
||||
|
||||
const fileType = name.split('.').pop() ?? '';
|
||||
const FileTypeIcon = getAttachmentFileIcon(fileType);
|
||||
|
||||
const embedView = this.embedView;
|
||||
|
||||
return html`
|
||||
<div class="affine-attachment-container" style=${this.containerStyleMap}>
|
||||
${embedView
|
||||
? html`<div class="affine-attachment-embed-container">
|
||||
${embedView}
|
||||
|
||||
<div
|
||||
class=${classMap({
|
||||
'affine-attachment-iframe-overlay': true,
|
||||
hide: !this._showOverlay,
|
||||
})}
|
||||
></div>
|
||||
</div>`
|
||||
: html`<div
|
||||
class=${classMap({
|
||||
'affine-attachment-card': true,
|
||||
[cardStyle]: true,
|
||||
loading: this.loading,
|
||||
error: this.error,
|
||||
unsynced: false,
|
||||
})}
|
||||
>
|
||||
<div class="affine-attachment-content">
|
||||
<div class="affine-attachment-content-title">
|
||||
<div class="affine-attachment-content-title-icon">
|
||||
${titleIcon}
|
||||
</div>
|
||||
|
||||
<div class="affine-attachment-content-title-text">
|
||||
${titleText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="affine-attachment-content-info">${infoText}</div>
|
||||
</div>
|
||||
|
||||
<div class="affine-attachment-banner">${FileTypeIcon}</div>
|
||||
</div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@state()
|
||||
protected accessor _showOverlay = true;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor allowEmbed = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor blobUrl: string | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor downloading = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor error = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor loading = false;
|
||||
|
||||
override accessor useCaptionEditor = true;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-attachment': AttachmentBlockComponent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
|
||||
import { AttachmentBlockStyles } from '@blocksuite/affine-model';
|
||||
import {
|
||||
EMBED_CARD_HEIGHT,
|
||||
EMBED_CARD_WIDTH,
|
||||
} from '@blocksuite/affine-shared/consts';
|
||||
import { toGfxBlockComponent } from '@blocksuite/block-std';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { AttachmentBlockComponent } from './attachment-block.js';
|
||||
|
||||
export class AttachmentEdgelessBlockComponent extends toGfxBlockComponent(
|
||||
AttachmentBlockComponent
|
||||
) {
|
||||
override blockDraggable = false;
|
||||
|
||||
get slots() {
|
||||
return this.std.get(EdgelessLegacySlotIdentifier);
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this._disposables.add(
|
||||
this.slots.elementResizeStart.on(() => {
|
||||
this._isResizing = true;
|
||||
this._showOverlay = true;
|
||||
})
|
||||
);
|
||||
|
||||
this._disposables.add(
|
||||
this.slots.elementResizeEnd.on(() => {
|
||||
this._isResizing = false;
|
||||
this._showOverlay =
|
||||
this._isResizing || this._isDragging || !this.selected$.peek();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override onClick(_: MouseEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
override renderGfxBlock() {
|
||||
const { style$ } = this.model;
|
||||
const cardStyle = style$.value ?? AttachmentBlockStyles[1];
|
||||
const width = EMBED_CARD_WIDTH[cardStyle];
|
||||
const height = EMBED_CARD_HEIGHT[cardStyle];
|
||||
const bound = this.model.elementBound;
|
||||
const scaleX = bound.w / width;
|
||||
const scaleY = bound.h / height;
|
||||
|
||||
this.containerStyleMap = styleMap({
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
transform: `scale(${scaleX}, ${scaleY})`,
|
||||
transformOrigin: '0 0',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
return this.renderPageContent();
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-edgeless-attachment': AttachmentEdgelessBlockComponent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { SurfaceBlockModel } from '@blocksuite/affine-block-surface';
|
||||
import { FileDropConfigExtension } from '@blocksuite/affine-components/drop-indicator';
|
||||
import { AttachmentBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
FileSizeLimitService,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
isInsideEdgelessEditor,
|
||||
matchModels,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
|
||||
|
||||
import { addAttachments, addSiblingAttachmentBlocks } from './utils.js';
|
||||
|
||||
export const AttachmentDropOption = FileDropConfigExtension({
|
||||
flavour: AttachmentBlockSchema.model.flavour,
|
||||
onDrop: ({ files, targetModel, placement, point, std }) => {
|
||||
// generic attachment block for all files except images
|
||||
const attachmentFiles = files.filter(
|
||||
file => !file.type.startsWith('image/')
|
||||
);
|
||||
if (!attachmentFiles.length) return false;
|
||||
|
||||
const maxFileSize = std.store.get(FileSizeLimitService).maxFileSize;
|
||||
|
||||
if (targetModel && !matchModels(targetModel, [SurfaceBlockModel])) {
|
||||
addSiblingAttachmentBlocks(
|
||||
std.host,
|
||||
attachmentFiles,
|
||||
maxFileSize,
|
||||
targetModel,
|
||||
placement
|
||||
).catch(console.error);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isInsideEdgelessEditor(std.host)) {
|
||||
const gfx = std.get(GfxControllerIdentifier);
|
||||
point = gfx.viewport.toViewCoordFromClientCoord(point);
|
||||
addAttachments(std, attachmentFiles, point).catch(console.error);
|
||||
|
||||
std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
|
||||
control: 'canvas:drop',
|
||||
page: 'whiteboard editor',
|
||||
module: 'toolbar',
|
||||
segment: 'toolbar',
|
||||
type: 'attachment',
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { AttachmentBlockSchema } from '@blocksuite/affine-model';
|
||||
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
|
||||
import { SlashMenuConfigExtension } from '@blocksuite/affine-widget-slash-menu';
|
||||
import {
|
||||
BlockFlavourIdentifier,
|
||||
BlockViewExtension,
|
||||
FlavourExtension,
|
||||
} from '@blocksuite/block-std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import { literal } from 'lit/static-html.js';
|
||||
|
||||
import { AttachmentBlockNotionHtmlAdapterExtension } from './adapters/notion-html.js';
|
||||
import { AttachmentDropOption } from './attachment-service.js';
|
||||
import { attachmentSlashMenuConfig } from './configs/slash-menu.js';
|
||||
import { builtinToolbarConfig } from './configs/toolbar';
|
||||
import {
|
||||
AttachmentEmbedConfigExtension,
|
||||
AttachmentEmbedService,
|
||||
} from './embed';
|
||||
|
||||
const flavour = AttachmentBlockSchema.model.flavour;
|
||||
|
||||
export const AttachmentBlockSpec: ExtensionType[] = [
|
||||
FlavourExtension(flavour),
|
||||
BlockViewExtension(flavour, model => {
|
||||
return model.parent?.flavour === 'affine:surface'
|
||||
? literal`affine-edgeless-attachment`
|
||||
: literal`affine-attachment`;
|
||||
}),
|
||||
AttachmentDropOption,
|
||||
AttachmentEmbedConfigExtension(),
|
||||
AttachmentEmbedService,
|
||||
AttachmentBlockNotionHtmlAdapterExtension,
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier(flavour),
|
||||
config: builtinToolbarConfig,
|
||||
}),
|
||||
SlashMenuConfigExtension(flavour, attachmentSlashMenuConfig),
|
||||
];
|
||||
@@ -0,0 +1,90 @@
|
||||
import { ConfirmIcon } from '@blocksuite/affine-components/icons';
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import type { AttachmentBlockModel } from '@blocksuite/affine-model';
|
||||
import type { EditorHost } from '@blocksuite/block-std';
|
||||
import { html } from 'lit';
|
||||
import { createRef, ref } from 'lit/directives/ref.js';
|
||||
|
||||
import { renameStyles } from './styles';
|
||||
|
||||
export const RenameModal = ({
|
||||
editorHost,
|
||||
model,
|
||||
abortController,
|
||||
}: {
|
||||
editorHost: EditorHost;
|
||||
model: AttachmentBlockModel;
|
||||
abortController: AbortController;
|
||||
}) => {
|
||||
const inputRef = createRef<HTMLInputElement>();
|
||||
// Fix auto focus
|
||||
setTimeout(() => inputRef.value?.focus());
|
||||
const originalName = model.name;
|
||||
const nameWithoutExtension = originalName.slice(
|
||||
0,
|
||||
originalName.lastIndexOf('.')
|
||||
);
|
||||
const originalExtension = originalName.slice(originalName.lastIndexOf('.'));
|
||||
const includeExtension =
|
||||
originalExtension.includes('.') &&
|
||||
originalExtension.length <= 7 &&
|
||||
// including the dot
|
||||
originalName.length > originalExtension.length;
|
||||
|
||||
let fileName = includeExtension ? nameWithoutExtension : originalName;
|
||||
const extension = includeExtension ? originalExtension : '';
|
||||
|
||||
const abort = () => abortController.abort();
|
||||
const onConfirm = () => {
|
||||
const newFileName = fileName + extension;
|
||||
if (!newFileName) {
|
||||
toast(editorHost, 'File name cannot be empty');
|
||||
return;
|
||||
}
|
||||
model.doc.updateBlock(model, {
|
||||
name: newFileName,
|
||||
});
|
||||
abort();
|
||||
};
|
||||
const onInput = (e: InputEvent) => {
|
||||
fileName = (e.target as HTMLInputElement).value;
|
||||
};
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === 'Escape' && !e.isComposing) {
|
||||
abort();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.isComposing) {
|
||||
onConfirm();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return html`
|
||||
<style>
|
||||
${renameStyles}
|
||||
</style>
|
||||
<div class="affine-attachment-rename-overlay-mask" @click="${abort}"></div>
|
||||
<div class="affine-attachment-rename-container">
|
||||
<div class="affine-attachment-rename-input-wrapper">
|
||||
<input
|
||||
${ref(inputRef)}
|
||||
type="text"
|
||||
.value=${fileName}
|
||||
@input=${onInput}
|
||||
@keydown=${onKeydown}
|
||||
/>
|
||||
<span class="affine-attachment-rename-extension">${extension}</span>
|
||||
</div>
|
||||
<editor-icon-button
|
||||
class="affine-confirm-button"
|
||||
.iconSize=${'24px'}
|
||||
@click=${onConfirm}
|
||||
>
|
||||
${ConfirmIcon}
|
||||
</editor-icon-button>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { FONT_XS, PANEL_BASE } from '@blocksuite/affine-shared/styles';
|
||||
import { css } from 'lit';
|
||||
|
||||
export const renameStyles = css`
|
||||
.affine-attachment-rename-container {
|
||||
${PANEL_BASE};
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 320px;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
}
|
||||
|
||||
.affine-attachment-rename-input-wrapper {
|
||||
display: flex;
|
||||
min-width: 280px;
|
||||
height: 30px;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 10px;
|
||||
background: var(--affine-white-10);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--affine-border-color);
|
||||
}
|
||||
|
||||
.affine-attachment-rename-input-wrapper:focus-within {
|
||||
border-color: var(--affine-blue-700);
|
||||
box-shadow: var(--affine-active-shadow);
|
||||
}
|
||||
|
||||
.affine-attachment-rename-input-wrapper input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--affine-text-primary-color);
|
||||
${FONT_XS};
|
||||
}
|
||||
|
||||
.affine-attachment-rename-input-wrapper input::placeholder {
|
||||
color: var(--affine-placeholder-color);
|
||||
}
|
||||
|
||||
.affine-attachment-rename-extension {
|
||||
font-size: var(--affine-font-xs);
|
||||
color: var(--affine-text-secondary-color);
|
||||
}
|
||||
|
||||
.affine-attachment-rename-overlay-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
}
|
||||
`;
|
||||
|
||||
export const styles = css`
|
||||
:host {
|
||||
z-index: 1;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { FileSizeLimitService } from '@blocksuite/affine-shared/services';
|
||||
import { openFileOrFiles } from '@blocksuite/affine-shared/utils';
|
||||
import { type SlashMenuConfig } from '@blocksuite/affine-widget-slash-menu';
|
||||
import { ExportToPdfIcon, FileIcon } from '@blocksuite/icons/lit';
|
||||
|
||||
import { addSiblingAttachmentBlocks } from '../utils';
|
||||
import { AttachmentTooltip, PDFTooltip } from './tooltips';
|
||||
|
||||
export const attachmentSlashMenuConfig: SlashMenuConfig = {
|
||||
items: [
|
||||
{
|
||||
name: 'Attachment',
|
||||
description: 'Attach a file to document.',
|
||||
icon: FileIcon(),
|
||||
tooltip: {
|
||||
figure: AttachmentTooltip,
|
||||
caption: 'Attachment',
|
||||
},
|
||||
searchAlias: ['file'],
|
||||
group: '4_Content & Media@3',
|
||||
when: ({ model }) =>
|
||||
model.doc.schema.flavourSchemaMap.has('affine:attachment'),
|
||||
action: ({ std, model }) => {
|
||||
(async () => {
|
||||
const file = await openFileOrFiles();
|
||||
if (!file) return;
|
||||
const maxFileSize = std.store.get(FileSizeLimitService).maxFileSize;
|
||||
await addSiblingAttachmentBlocks(
|
||||
std.host,
|
||||
[file],
|
||||
maxFileSize,
|
||||
model
|
||||
);
|
||||
if (model.text?.length === 0) {
|
||||
std.store.deleteBlock(model);
|
||||
}
|
||||
})().catch(console.error);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'PDF',
|
||||
description: 'Upload a PDF to document.',
|
||||
icon: ExportToPdfIcon(),
|
||||
tooltip: {
|
||||
figure: PDFTooltip,
|
||||
caption: 'PDF',
|
||||
},
|
||||
group: '4_Content & Media@4',
|
||||
when: ({ model }) =>
|
||||
model.doc.schema.flavourSchemaMap.has('affine:attachment'),
|
||||
action: ({ std, model }) => {
|
||||
(async () => {
|
||||
const file = await openFileOrFiles();
|
||||
if (!file) return;
|
||||
|
||||
const maxFileSize = std.store.get(FileSizeLimitService).maxFileSize;
|
||||
|
||||
await addSiblingAttachmentBlocks(
|
||||
std.host,
|
||||
[file],
|
||||
maxFileSize,
|
||||
model,
|
||||
'after',
|
||||
true
|
||||
);
|
||||
if (model.text?.length === 0) {
|
||||
std.store.deleteBlock(model);
|
||||
}
|
||||
})().catch(console.error);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
278
blocksuite/affine/blocks/block-attachment/src/configs/toolbar.ts
Normal file
278
blocksuite/affine/blocks/block-attachment/src/configs/toolbar.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { createLitPortal } from '@blocksuite/affine-components/portal';
|
||||
import {
|
||||
AttachmentBlockModel,
|
||||
defaultAttachmentProps,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
EMBED_CARD_HEIGHT,
|
||||
EMBED_CARD_WIDTH,
|
||||
} from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
ActionPlacement,
|
||||
type ToolbarAction,
|
||||
type ToolbarActionGroup,
|
||||
type ToolbarContext,
|
||||
type ToolbarModuleConfig,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { BlockSelection, SurfaceSelection } from '@blocksuite/block-std';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import {
|
||||
CaptionIcon,
|
||||
CopyIcon,
|
||||
DeleteIcon,
|
||||
DownloadIcon,
|
||||
DuplicateIcon,
|
||||
EditIcon,
|
||||
ResetIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import type { SelectionConstructor } from '@blocksuite/store';
|
||||
import { flip, offset } from '@floating-ui/dom';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { html } from 'lit';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
|
||||
import { AttachmentBlockComponent } from '../attachment-block';
|
||||
import { RenameModal } from '../components/rename-model';
|
||||
import { AttachmentEmbedProvider } from '../embed';
|
||||
import { cloneAttachmentProperties } from '../utils';
|
||||
|
||||
const trackBaseProps = {
|
||||
segment: 'doc',
|
||||
page: 'doc editor',
|
||||
module: 'toolbar',
|
||||
category: 'attachment',
|
||||
type: 'card view',
|
||||
};
|
||||
|
||||
const createAttachmentViewDropdownMenuWith = <T extends SelectionConstructor>(
|
||||
t: T
|
||||
) => {
|
||||
return {
|
||||
id: 'b.conversions',
|
||||
actions: [
|
||||
{
|
||||
id: 'card',
|
||||
label: 'Card view',
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(t, AttachmentBlockModel);
|
||||
if (!model) return;
|
||||
|
||||
const style = defaultAttachmentProps.style!;
|
||||
const width = EMBED_CARD_WIDTH[style];
|
||||
const height = EMBED_CARD_HEIGHT[style];
|
||||
const bound = Bound.deserialize(model.xywh);
|
||||
bound.w = width;
|
||||
bound.h = height;
|
||||
|
||||
ctx.store.updateBlock(model, {
|
||||
style,
|
||||
embed: false,
|
||||
xywh: bound.serialize(),
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'embed',
|
||||
label: 'Embed view',
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(t, AttachmentBlockModel);
|
||||
if (!model) return;
|
||||
|
||||
// Clears
|
||||
ctx.reset();
|
||||
ctx.select('note');
|
||||
|
||||
ctx.std.get(AttachmentEmbedProvider).convertTo(model);
|
||||
|
||||
ctx.track('SelectedView', {
|
||||
...trackBaseProps,
|
||||
control: 'select view',
|
||||
type: 'embed view',
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentModelByType(t, AttachmentBlockModel);
|
||||
if (!model) return null;
|
||||
|
||||
const embedProvider = ctx.std.get(AttachmentEmbedProvider);
|
||||
const actions = this.actions.map(action => ({ ...action }));
|
||||
const viewType$ = computed(() => {
|
||||
const [cardAction, embedAction] = actions;
|
||||
const embed = model.embed$.value ?? false;
|
||||
|
||||
cardAction.disabled = !embed;
|
||||
embedAction.disabled = embed && embedProvider.embedded(model);
|
||||
|
||||
return embed ? embedAction.label : cardAction.label;
|
||||
});
|
||||
const toggle = (e: CustomEvent<boolean>) => {
|
||||
const opened = e.detail;
|
||||
if (!opened) return;
|
||||
|
||||
ctx.track('OpenedViewSelector', {
|
||||
...trackBaseProps,
|
||||
control: 'switch view',
|
||||
});
|
||||
};
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-view-dropdown-menu
|
||||
.actions=${actions}
|
||||
.context=${ctx}
|
||||
.toggle=${toggle}
|
||||
.viewType$=${viewType$}
|
||||
></affine-view-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>;
|
||||
};
|
||||
|
||||
export const builtinToolbarConfig = {
|
||||
actions: [
|
||||
{
|
||||
id: 'a.rename',
|
||||
content(cx) {
|
||||
const component = cx.getCurrentBlockComponentBy(
|
||||
BlockSelection,
|
||||
AttachmentBlockComponent
|
||||
);
|
||||
if (!component) return null;
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortController.signal.onabort = () => cx.show();
|
||||
|
||||
return html`
|
||||
<editor-icon-button
|
||||
aria-label="Rename"
|
||||
.tooltip="${'Rename'}"
|
||||
@click=${() => {
|
||||
cx.hide();
|
||||
|
||||
createLitPortal({
|
||||
template: RenameModal({
|
||||
model: component.model,
|
||||
editorHost: cx.host,
|
||||
abortController,
|
||||
}),
|
||||
computePosition: {
|
||||
referenceElement: component,
|
||||
placement: 'top-start',
|
||||
middleware: [flip(), offset(4)],
|
||||
},
|
||||
abortController,
|
||||
});
|
||||
}}
|
||||
>
|
||||
${EditIcon()}
|
||||
</editor-icon-button>
|
||||
`;
|
||||
},
|
||||
},
|
||||
createAttachmentViewDropdownMenuWith(BlockSelection),
|
||||
{
|
||||
id: 'c.download',
|
||||
tooltip: 'Download',
|
||||
icon: DownloadIcon(),
|
||||
run(ctx) {
|
||||
const component = ctx.getCurrentBlockComponentBy(
|
||||
BlockSelection,
|
||||
AttachmentBlockComponent
|
||||
);
|
||||
component?.download();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'd.caption',
|
||||
tooltip: 'Caption',
|
||||
icon: CaptionIcon(),
|
||||
run(ctx) {
|
||||
const component = ctx.getCurrentBlockComponentBy(
|
||||
BlockSelection,
|
||||
AttachmentBlockComponent
|
||||
);
|
||||
component?.captionEditor?.show();
|
||||
|
||||
ctx.track('OpenedCaptionEditor', {
|
||||
...trackBaseProps,
|
||||
control: 'add caption',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'a.clipboard',
|
||||
actions: [
|
||||
{
|
||||
id: 'copy',
|
||||
label: 'Copy',
|
||||
icon: CopyIcon(),
|
||||
run(ctx) {
|
||||
// TODO(@fundon): unify `clone` method
|
||||
const component = ctx.getCurrentBlockComponentBy(
|
||||
BlockSelection,
|
||||
AttachmentBlockComponent
|
||||
);
|
||||
component?.copy();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'duplicate',
|
||||
label: 'Duplicate',
|
||||
icon: DuplicateIcon(),
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentBlockComponentBy(
|
||||
BlockSelection,
|
||||
AttachmentBlockComponent
|
||||
)?.model;
|
||||
if (!model) return;
|
||||
|
||||
// TODO(@fundon): unify `duplicate` method
|
||||
ctx.store.addSiblingBlocks(model, [
|
||||
{
|
||||
flavour: model.flavour,
|
||||
...cloneAttachmentProperties(model),
|
||||
},
|
||||
]);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'b.refresh',
|
||||
label: 'Reload',
|
||||
icon: ResetIcon(),
|
||||
run(ctx) {
|
||||
const component = ctx.getCurrentBlockComponentBy(
|
||||
BlockSelection,
|
||||
AttachmentBlockComponent
|
||||
);
|
||||
component?.refreshData();
|
||||
},
|
||||
},
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'c.delete',
|
||||
label: 'Delete',
|
||||
icon: DeleteIcon(),
|
||||
variant: 'destructive',
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentBlockBy(BlockSelection)?.model;
|
||||
if (!model) return;
|
||||
|
||||
ctx.store.deleteBlock(model);
|
||||
|
||||
// Clears
|
||||
ctx.select('note');
|
||||
ctx.reset();
|
||||
},
|
||||
},
|
||||
],
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
|
||||
export const attachmentViewDropdownMenu = (ctx: ToolbarContext) => {
|
||||
return createAttachmentViewDropdownMenuWith(SurfaceSelection).content(ctx);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { html } from 'lit';
|
||||
// prettier-ignore
|
||||
export const AttachmentTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="170" height="68" rx="2" fill="white"/>
|
||||
<mask id="mask0_16460_1013" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
|
||||
<rect width="170" height="68" rx="2" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_16460_1013)">
|
||||
<rect x="8.5" y="28.5" width="169" height="67" rx="3.5" fill="white" stroke="#E3E2E4"/>
|
||||
<mask id="mask1_16460_1013" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="8" y="28" width="170" height="68">
|
||||
<rect x="8" y="28" width="170" height="68" rx="4" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask1_16460_1013)">
|
||||
<g filter="url(#filter0_d_16460_1013)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M149.686 42.516C149.796 41.7313 149.251 41.0057 148.47 40.8954L130.084 38.2995C128.913 38.1341 127.829 38.9542 127.665 40.1313L122.298 78.494C122.133 79.671 122.95 80.7593 124.121 80.9248L157.357 85.6174C158.529 85.7828 159.612 84.9627 159.777 83.7856L163.057 60.3418C163.166 59.5571 162.622 58.8315 161.841 58.7212L151.941 57.3234C149.598 56.9926 147.965 54.816 148.294 52.4619L149.686 42.516ZM133.567 59.8003C133.649 59.2118 134.191 58.8017 134.776 58.8844L152.455 61.3805C153.041 61.4632 153.449 62.0074 153.367 62.5959C153.284 63.1844 152.743 63.5945 152.157 63.5118L134.478 61.0157C133.892 60.933 133.484 60.3888 133.567 59.8003ZM133.932 64.923C133.346 64.8403 132.804 65.2504 132.722 65.8389C132.64 66.4274 133.048 66.9716 133.634 67.0543L151.312 69.5504C151.898 69.6331 152.44 69.223 152.522 68.6345C152.604 68.046 152.196 67.5018 151.61 67.4191L133.932 64.923Z" fill="white"/>
|
||||
<path d="M153.295 43.1135C152.819 42.4792 151.818 42.7394 151.708 43.5259L150.416 52.7614C150.251 53.9385 151.067 55.0268 152.239 55.1922L161.432 56.4901C162.215 56.6007 162.74 55.7051 162.264 55.0708L153.295 43.1135Z" fill="white"/>
|
||||
</g>
|
||||
<g clip-path="url(#clip0_16460_1013)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.1088 36.804L17.0887 39.8241C16.1371 40.7509 16.1371 42.2492 17.0886 43.1759C18.0457 44.108 19.6014 44.108 20.5584 43.1759L23.5034 40.3144C23.6519 40.1701 23.8893 40.1735 24.0337 40.322C24.178 40.4706 24.1746 40.708 24.026 40.8523L21.0817 43.7132C21.0817 43.7133 21.0818 43.7131 21.0817 43.7132C19.8334 44.9288 17.8136 44.9289 16.5653 43.7132C15.3122 42.4926 15.3116 40.5099 16.5635 39.2886L19.5838 36.2683C20.4645 35.4105 21.8884 35.4106 22.7691 36.2683C23.6548 37.1309 23.6554 38.5332 22.771 39.3965L19.7507 42.4169C19.2376 42.9167 18.4095 42.9166 17.8964 42.4168C17.3777 41.9116 17.3777 41.0884 17.8964 40.5832L20.9956 37.5647C21.1439 37.4202 21.3813 37.4233 21.5259 37.5717C21.6704 37.7201 21.6672 37.9575 21.5189 38.102L18.4197 41.1205C18.2033 41.3312 18.2033 41.6688 18.4197 41.8796C18.6411 42.0952 19.0038 42.0957 19.2259 41.881L22.2458 38.861C22.8298 38.2923 22.8298 37.3744 22.2459 36.8056C21.6569 36.232 20.6984 36.2315 20.1088 36.804Z" fill="#77757D"/>
|
||||
</g>
|
||||
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" font-weight="600" letter-spacing="0em"><tspan x="30" y="43.6364">Rickroll.mp3</tspan></text>
|
||||
</g>
|
||||
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="10" y="18.6364">Attach a file.</tspan></text>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_16460_1013" x="118.277" y="34.2783" width="48.7936" height="55.3602" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="2"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_16460_1013"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_16460_1013" result="shape"/>
|
||||
</filter>
|
||||
<clipPath id="clip0_16460_1013">
|
||||
<rect width="12" height="12" fill="white" transform="translate(14 34)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// prettier-ignore
|
||||
export const PDFTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="170" height="68" rx="2" fill="white"/>
|
||||
<mask id="mask0_689_35293" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
|
||||
<rect width="170" height="68" rx="2" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_689_35293)">
|
||||
<rect x="8.5" y="28.5" width="169" height="67" rx="3.5" fill="white" stroke="#E3E2E4"/>
|
||||
<mask id="mask1_689_35293" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="8" y="28" width="170" height="68">
|
||||
<rect x="8" y="28" width="170" height="68" rx="4" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask1_689_35293)">
|
||||
<g filter="url(#filter0_d_689_35293)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M149.686 42.516C149.796 41.7313 149.251 41.0057 148.47 40.8954L130.084 38.2995C128.913 38.1341 127.829 38.9542 127.665 40.1313L122.298 78.494C122.133 79.671 122.95 80.7593 124.121 80.9248L157.357 85.6174C158.529 85.7828 159.612 84.9627 159.777 83.7856L163.057 60.3418C163.166 59.5571 162.622 58.8315 161.841 58.7212L151.941 57.3234C149.598 56.9926 147.965 54.816 148.294 52.4619L149.686 42.516ZM133.567 59.8003C133.649 59.2118 134.191 58.8017 134.776 58.8844L152.455 61.3805C153.041 61.4632 153.449 62.0074 153.367 62.5959C153.284 63.1844 152.743 63.5945 152.157 63.5118L134.478 61.0157C133.892 60.933 133.484 60.3888 133.567 59.8003ZM133.932 64.923C133.346 64.8403 132.804 65.2504 132.722 65.8389C132.64 66.4274 133.048 66.9716 133.634 67.0543L151.312 69.5504C151.898 69.6331 152.44 69.223 152.522 68.6345C152.604 68.046 152.196 67.5018 151.61 67.4191L133.932 64.923Z" fill="white"/>
|
||||
<path d="M153.295 43.1135C152.819 42.4792 151.818 42.7394 151.708 43.5259L150.416 52.7614C150.251 53.9385 151.067 55.0268 152.239 55.1922L161.432 56.4901C162.215 56.6007 162.74 55.7051 162.264 55.0708L153.295 43.1135Z" fill="white"/>
|
||||
</g>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M18 35.125C17.2406 35.125 16.625 35.7406 16.625 36.5V38C16.625 38.2071 16.7929 38.375 17 38.375C17.2071 38.375 17.375 38.2071 17.375 38V36.5C17.375 36.1548 17.6548 35.875 18 35.875H20.5H21.7609C21.9538 35.875 22.1359 35.964 22.2543 36.1163L23.4933 37.7094C23.5787 37.8191 23.625 37.9541 23.625 38.0931V40V43C23.625 43.3452 23.3452 43.625 23 43.625H18C17.6548 43.625 17.375 43.3452 17.375 43V42.25C17.375 42.0429 17.2071 41.875 17 41.875C16.7929 41.875 16.625 42.0429 16.625 42.25V43C16.625 43.7594 17.2406 44.375 18 44.375H23C23.7594 44.375 24.375 43.7594 24.375 43V40V38.0931C24.375 37.7873 24.2731 37.4903 24.0854 37.2489L22.8463 35.6558C22.5858 35.3209 22.1852 35.125 21.7609 35.125H20.5H18ZM15.3965 40.9614C15.3965 41.1694 15.5093 41.2866 15.707 41.2866C15.9048 41.2866 16.0176 41.1694 16.0176 40.9614V40.6729H16.3193C16.813 40.6729 17.1484 40.3726 17.1484 39.9067C17.1484 39.4365 16.832 39.1362 16.3618 39.1362H15.707C15.5093 39.1362 15.3965 39.2534 15.3965 39.4614V40.9614ZM16.1919 40.2144H16.0176V39.6035H16.1963C16.3984 39.6035 16.5215 39.7075 16.5215 39.9082C16.5215 40.1104 16.3984 40.2144 16.1919 40.2144ZM17.3682 40.9248C17.3682 41.1328 17.481 41.25 17.6787 41.25H18.272C18.9121 41.25 19.29 40.8545 19.29 40.1777C19.29 39.501 18.9136 39.1362 18.272 39.1362H17.6787C17.481 39.1362 17.3682 39.2534 17.3682 39.4614V40.9248ZM18.1841 40.7563H17.9893V39.6299H18.1841C18.4814 39.6299 18.6572 39.8218 18.6572 40.1777C18.6572 40.5674 18.4946 40.7563 18.1841 40.7563ZM19.5332 40.9614C19.5332 41.1694 19.646 41.2866 19.8438 41.2866C20.0415 41.2866 20.1543 41.1694 20.1543 40.9614V40.5015H20.7109C20.8691 40.5015 20.9658 40.4165 20.9658 40.2686C20.9658 40.1206 20.8662 40.0356 20.7109 40.0356H20.1543V39.6313H20.7739C20.938 39.6313 21.0479 39.542 21.0479 39.3838C21.0479 39.2256 20.9409 39.1362 20.7739 39.1362H19.8438C19.646 39.1362 19.5332 39.2534 19.5332 39.4614V40.9614Z" fill="#7A7A7A"/>
|
||||
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" font-weight="600" letter-spacing="0em"><tspan x="30" y="43.6364">Rickroll.pdf</tspan></text>
|
||||
</g>
|
||||
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="10" y="18.6364">Upload a pdf to document.</tspan></text>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_689_35293" x="118.277" y="34.2783" width="48.7936" height="55.3604" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="2"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.258824 0 0 0 0 0.254902 0 0 0 0 0.286275 0 0 0 0.18 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_689_35293"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_689_35293" result="shape"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
`;
|
||||
10
blocksuite/affine/blocks/block-attachment/src/effects.ts
Normal file
10
blocksuite/affine/blocks/block-attachment/src/effects.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { AttachmentBlockComponent } from './attachment-block';
|
||||
import { AttachmentEdgelessBlockComponent } from './attachment-edgeless-block';
|
||||
|
||||
export function effects() {
|
||||
customElements.define(
|
||||
'affine-edgeless-attachment',
|
||||
AttachmentEdgelessBlockComponent
|
||||
);
|
||||
customElements.define('affine-attachment', AttachmentBlockComponent);
|
||||
}
|
||||
232
blocksuite/affine/blocks/block-attachment/src/embed.ts
Normal file
232
blocksuite/affine/blocks/block-attachment/src/embed.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import {
|
||||
type AttachmentBlockModel,
|
||||
type ImageBlockProps,
|
||||
MAX_IMAGE_WIDTH,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { FileSizeLimitService } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
readImageSize,
|
||||
transformModel,
|
||||
withTempBlobData,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { type BlockStdScope, StdIdentifier } from '@blocksuite/block-std';
|
||||
import type { Container } from '@blocksuite/global/di';
|
||||
import { createIdentifier } from '@blocksuite/global/di';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import { Extension } from '@blocksuite/store';
|
||||
import type { TemplateResult } from 'lit';
|
||||
import { html } from 'lit';
|
||||
|
||||
import { getAttachmentBlob } from './utils';
|
||||
|
||||
export type AttachmentEmbedConfig = {
|
||||
name: string;
|
||||
/**
|
||||
* Check if the attachment can be turned into embed view.
|
||||
*/
|
||||
check: (model: AttachmentBlockModel, maxFileSize: number) => boolean;
|
||||
/**
|
||||
* The action will be executed when the 「Turn into embed view」 button is clicked.
|
||||
*/
|
||||
action?: (
|
||||
model: AttachmentBlockModel,
|
||||
std: BlockStdScope
|
||||
) => Promise<void> | void;
|
||||
/**
|
||||
* The template will be used to render the embed view.
|
||||
*/
|
||||
template?: (model: AttachmentBlockModel, blobUrl: string) => TemplateResult;
|
||||
};
|
||||
|
||||
// Single embed config.
|
||||
export const AttachmentEmbedConfigIdentifier =
|
||||
createIdentifier<AttachmentEmbedConfig>(
|
||||
'AffineAttachmentEmbedConfigIdentifier'
|
||||
);
|
||||
|
||||
export function AttachmentEmbedConfigExtension(
|
||||
configs: AttachmentEmbedConfig[] = embedConfig
|
||||
): ExtensionType {
|
||||
return {
|
||||
setup: di => {
|
||||
configs.forEach(option => {
|
||||
di.addImpl(AttachmentEmbedConfigIdentifier(option.name), () => option);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// A embed config map.
|
||||
export const AttachmentEmbedConfigMapIdentifier = createIdentifier<
|
||||
Map<string, AttachmentEmbedConfig>
|
||||
>('AffineAttachmentEmbedConfigMapIdentifier');
|
||||
|
||||
export const AttachmentEmbedProvider = createIdentifier<AttachmentEmbedService>(
|
||||
'AffineAttachmentEmbedProvider'
|
||||
);
|
||||
|
||||
export class AttachmentEmbedService extends Extension {
|
||||
private get _maxFileSize() {
|
||||
return this.std.store.get(FileSizeLimitService).maxFileSize;
|
||||
}
|
||||
|
||||
get keys() {
|
||||
return this.configs.keys();
|
||||
}
|
||||
|
||||
get values() {
|
||||
return this.configs.values();
|
||||
}
|
||||
|
||||
get configs(): Map<string, AttachmentEmbedConfig> {
|
||||
return this.std.get(AttachmentEmbedConfigMapIdentifier);
|
||||
}
|
||||
|
||||
constructor(private readonly std: BlockStdScope) {
|
||||
super();
|
||||
}
|
||||
|
||||
static override setup(di: Container) {
|
||||
di.addImpl(AttachmentEmbedConfigMapIdentifier, provider =>
|
||||
provider.getAll(AttachmentEmbedConfigIdentifier)
|
||||
);
|
||||
di.addImpl(AttachmentEmbedProvider, this, [StdIdentifier]);
|
||||
}
|
||||
|
||||
// Converts to embed view.
|
||||
convertTo(model: AttachmentBlockModel, maxFileSize = this._maxFileSize) {
|
||||
const config = this.values.find(config => config.check(model, maxFileSize));
|
||||
if (!config?.action) {
|
||||
model.doc.updateBlock(model, { embed: true });
|
||||
return;
|
||||
}
|
||||
config.action(model, this.std)?.catch(console.error);
|
||||
}
|
||||
|
||||
embedded(model: AttachmentBlockModel, maxFileSize = this._maxFileSize) {
|
||||
return this.values.some(config => config.check(model, maxFileSize));
|
||||
}
|
||||
|
||||
render(
|
||||
model: AttachmentBlockModel,
|
||||
blobUrl?: string,
|
||||
maxFileSize = this._maxFileSize
|
||||
) {
|
||||
if (!model.embed || !blobUrl) return;
|
||||
|
||||
const config = this.values.find(config => config.check(model, maxFileSize));
|
||||
if (!config || !config.template) {
|
||||
console.error('No embed view template found!', model, model.type);
|
||||
return;
|
||||
}
|
||||
|
||||
return config.template(model, blobUrl);
|
||||
}
|
||||
}
|
||||
|
||||
const embedConfig: AttachmentEmbedConfig[] = [
|
||||
{
|
||||
name: 'image',
|
||||
check: model =>
|
||||
model.doc.schema.flavourSchemaMap.has('affine:image') &&
|
||||
model.type.startsWith('image/'),
|
||||
async action(model, std) {
|
||||
const component = std.view.getBlock(model.id);
|
||||
if (!component) return;
|
||||
|
||||
await turnIntoImageBlock(model);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'pdf',
|
||||
check: (model, maxFileSize) =>
|
||||
model.type === 'application/pdf' && model.size <= maxFileSize,
|
||||
template: (_, blobUrl) => {
|
||||
// More options: https://tinytip.co/tips/html-pdf-params/
|
||||
// https://chromium.googlesource.com/chromium/src/+/refs/tags/121.0.6153.1/chrome/browser/resources/pdf/open_pdf_params_parser.ts
|
||||
const parameters = '#toolbar=0';
|
||||
return html`<iframe
|
||||
style="width: 100%; color-scheme: auto;"
|
||||
height="480"
|
||||
src=${blobUrl + parameters}
|
||||
loading="lazy"
|
||||
scrolling="no"
|
||||
frameborder="no"
|
||||
allowTransparency
|
||||
allowfullscreen
|
||||
type="application/pdf"
|
||||
></iframe>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'video',
|
||||
check: (model, maxFileSize) =>
|
||||
model.type.startsWith('video/') && model.size <= maxFileSize,
|
||||
template: (_, blobUrl) =>
|
||||
html`<video
|
||||
style="max-height: max-content;"
|
||||
width="100%;"
|
||||
height="480"
|
||||
controls
|
||||
src=${blobUrl}
|
||||
></video>`,
|
||||
},
|
||||
{
|
||||
name: 'audio',
|
||||
check: (model, maxFileSize) =>
|
||||
model.type.startsWith('audio/') && model.size <= maxFileSize,
|
||||
template: (_, blobUrl) =>
|
||||
html`<audio controls src=${blobUrl} style="margin: 4px;"></audio>`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Turn the attachment block into an image block.
|
||||
*/
|
||||
export async function turnIntoImageBlock(model: AttachmentBlockModel) {
|
||||
if (!model.doc.schema.flavourSchemaMap.has('affine:image')) {
|
||||
console.error('The image flavour is not supported!');
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceId = model.sourceId;
|
||||
if (!sourceId) return;
|
||||
|
||||
const { saveAttachmentData, getImageData } = withTempBlobData();
|
||||
saveAttachmentData(sourceId, { name: model.name });
|
||||
|
||||
let imageSize = model.sourceId ? getImageData(model.sourceId) : undefined;
|
||||
|
||||
const bounds = model.xywh
|
||||
? Bound.fromXYWH(model.deserializedXYWH)
|
||||
: undefined;
|
||||
|
||||
if (bounds) {
|
||||
if (!imageSize?.width || !imageSize?.height) {
|
||||
const blob = await getAttachmentBlob(model);
|
||||
if (blob) {
|
||||
imageSize = await readImageSize(blob);
|
||||
}
|
||||
}
|
||||
|
||||
if (imageSize?.width && imageSize?.height) {
|
||||
const p = imageSize.height / imageSize.width;
|
||||
imageSize.width = Math.min(imageSize.width, MAX_IMAGE_WIDTH);
|
||||
imageSize.height = imageSize.width * p;
|
||||
bounds.w = imageSize.width;
|
||||
bounds.h = imageSize.height;
|
||||
}
|
||||
}
|
||||
|
||||
const others = bounds ? { xywh: bounds.serialize() } : undefined;
|
||||
|
||||
const imageProp: Partial<ImageBlockProps> = {
|
||||
sourceId,
|
||||
caption: model.caption,
|
||||
size: model.size,
|
||||
...imageSize,
|
||||
...others,
|
||||
};
|
||||
transformModel(model, 'affine:image', imageProp);
|
||||
}
|
||||
11
blocksuite/affine/blocks/block-attachment/src/index.ts
Normal file
11
blocksuite/affine/blocks/block-attachment/src/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export * from './adapters/notion-html';
|
||||
export * from './attachment-block';
|
||||
export * from './attachment-service';
|
||||
export * from './attachment-spec';
|
||||
export { attachmentViewDropdownMenu } from './configs/toolbar';
|
||||
export {
|
||||
type AttachmentEmbedConfig,
|
||||
AttachmentEmbedConfigIdentifier,
|
||||
AttachmentEmbedProvider,
|
||||
} from './embed';
|
||||
export { addAttachments, addSiblingAttachmentBlocks } from './utils';
|
||||
151
blocksuite/affine/blocks/block-attachment/src/styles.ts
Normal file
151
blocksuite/affine/blocks/block-attachment/src/styles.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { css } from 'lit';
|
||||
|
||||
export const styles = css`
|
||||
.affine-attachment-card {
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--affine-background-tertiary-color);
|
||||
|
||||
opacity: var(--add, 1);
|
||||
background: var(--affine-background-primary-color);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.affine-attachment-content {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
flex: 1 0 0;
|
||||
|
||||
border-radius: var(--1, 0px);
|
||||
opacity: var(--add, 1);
|
||||
}
|
||||
|
||||
.affine-attachment-content-title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
align-self: stretch;
|
||||
padding: var(--1, 0px);
|
||||
border-radius: var(--1, 0px);
|
||||
opacity: var(--add, 1);
|
||||
}
|
||||
|
||||
.affine-attachment-content-title-icon {
|
||||
display: flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.affine-attachment-content-title-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: var(--affine-background-primary-color);
|
||||
}
|
||||
|
||||
.affine-attachment-content-title-text {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
|
||||
word-break: break-all;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--affine-text-primary-color);
|
||||
|
||||
font-family: var(--affine-font-family);
|
||||
font-size: var(--affine-font-sm);
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.affine-attachment-content-info {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
flex: 1 0 0;
|
||||
|
||||
word-break: break-all;
|
||||
overflow: hidden;
|
||||
color: var(--affine-text-secondary-color);
|
||||
text-overflow: ellipsis;
|
||||
|
||||
font-family: var(--affine-font-family);
|
||||
font-size: var(--affine-font-xs);
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.affine-attachment-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.affine-attachment-banner svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.affine-attachment-card.loading {
|
||||
background: var(--affine-background-secondary-color);
|
||||
|
||||
.affine-attachment-content-title-text {
|
||||
color: var(--affine-placeholder-color);
|
||||
}
|
||||
}
|
||||
|
||||
.affine-attachment-card.error,
|
||||
.affine-attachment-card.unsynced {
|
||||
background: var(--affine-background-secondary-color);
|
||||
}
|
||||
|
||||
.affine-attachment-card.cubeThick {
|
||||
flex-direction: column-reverse;
|
||||
|
||||
.affine-attachment-content {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.affine-attachment-banner {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.affine-attachment-embed-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.affine-attachment-iframe-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.affine-attachment-iframe-overlay.hide {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
343
blocksuite/affine/blocks/block-attachment/src/utils.ts
Normal file
343
blocksuite/affine/blocks/block-attachment/src/utils.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import type {
|
||||
AttachmentBlockModel,
|
||||
AttachmentBlockProps,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { defaultAttachmentProps } from '@blocksuite/affine-model';
|
||||
import {
|
||||
EMBED_CARD_HEIGHT,
|
||||
EMBED_CARD_WIDTH,
|
||||
} from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
FileSizeLimitService,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { humanFileSize } from '@blocksuite/affine-shared/utils';
|
||||
import type { BlockStdScope, EditorHost } from '@blocksuite/block-std';
|
||||
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
|
||||
import { Bound, type IVec, Point, Vec } from '@blocksuite/global/gfx';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
|
||||
import type { AttachmentBlockComponent } from './attachment-block.js';
|
||||
|
||||
export function cloneAttachmentProperties(model: AttachmentBlockModel) {
|
||||
const clonedProps = {} as AttachmentBlockProps;
|
||||
for (const cur in defaultAttachmentProps) {
|
||||
const key = cur as keyof AttachmentBlockProps;
|
||||
// @ts-expect-error it's safe because we just cloned the props simply
|
||||
clonedProps[key] = model[
|
||||
key
|
||||
] as AttachmentBlockProps[keyof AttachmentBlockProps];
|
||||
}
|
||||
return clonedProps;
|
||||
}
|
||||
|
||||
const attachmentUploads = new Set<string>();
|
||||
export function setAttachmentUploading(blockId: string) {
|
||||
attachmentUploads.add(blockId);
|
||||
}
|
||||
export function setAttachmentUploaded(blockId: string) {
|
||||
attachmentUploads.delete(blockId);
|
||||
}
|
||||
function isAttachmentUploading(blockId: string) {
|
||||
return attachmentUploads.has(blockId);
|
||||
}
|
||||
|
||||
/**
|
||||
* This function will not verify the size of the file.
|
||||
*/
|
||||
export async function uploadAttachmentBlob(
|
||||
editorHost: EditorHost,
|
||||
blockId: string,
|
||||
blob: Blob,
|
||||
filetype: string,
|
||||
isEdgeless?: boolean
|
||||
): Promise<void> {
|
||||
if (isAttachmentUploading(blockId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = editorHost.doc;
|
||||
let sourceId: string | undefined;
|
||||
|
||||
try {
|
||||
setAttachmentUploading(blockId);
|
||||
sourceId = await doc.blobSync.set(blob);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (error instanceof Error) {
|
||||
toast(
|
||||
editorHost,
|
||||
`Failed to upload attachment! ${error.message || error.toString()}`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setAttachmentUploaded(blockId);
|
||||
|
||||
const block = doc.getBlock(blockId);
|
||||
|
||||
doc.withoutTransact(() => {
|
||||
if (!block) return;
|
||||
|
||||
doc.updateBlock(block.model, {
|
||||
sourceId,
|
||||
} satisfies Partial<AttachmentBlockProps>);
|
||||
});
|
||||
|
||||
editorHost.std
|
||||
.getOptional(TelemetryProvider)
|
||||
?.track('AttachmentUploadedEvent', {
|
||||
page: `${isEdgeless ? 'whiteboard' : 'doc'} editor`,
|
||||
module: 'attachment',
|
||||
segment: 'attachment',
|
||||
control: 'uploader',
|
||||
type: filetype,
|
||||
category: block && sourceId ? 'success' : 'failure',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAttachmentBlob(model: AttachmentBlockModel) {
|
||||
const sourceId = model.sourceId;
|
||||
if (!sourceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = model.doc;
|
||||
let blob = await doc.blobSync.get(sourceId);
|
||||
|
||||
if (blob) {
|
||||
blob = new Blob([blob], { type: model.type });
|
||||
}
|
||||
|
||||
return blob;
|
||||
}
|
||||
|
||||
export async function checkAttachmentBlob(block: AttachmentBlockComponent) {
|
||||
const model = block.model;
|
||||
const { id, sourceId } = model;
|
||||
|
||||
if (isAttachmentUploading(id)) {
|
||||
block.loading = true;
|
||||
block.error = false;
|
||||
block.allowEmbed = false;
|
||||
if (block.blobUrl) {
|
||||
URL.revokeObjectURL(block.blobUrl);
|
||||
block.blobUrl = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!sourceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await getAttachmentBlob(model);
|
||||
if (!blob) {
|
||||
return;
|
||||
}
|
||||
|
||||
block.loading = false;
|
||||
block.error = false;
|
||||
block.allowEmbed = block.embedded();
|
||||
if (block.blobUrl) {
|
||||
URL.revokeObjectURL(block.blobUrl);
|
||||
}
|
||||
block.blobUrl = URL.createObjectURL(blob);
|
||||
} catch (error) {
|
||||
console.warn(error, model, sourceId);
|
||||
|
||||
block.loading = false;
|
||||
block.error = true;
|
||||
block.allowEmbed = false;
|
||||
if (block.blobUrl) {
|
||||
URL.revokeObjectURL(block.blobUrl);
|
||||
block.blobUrl = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Since the size of the attachment may be very large,
|
||||
* the download process may take a long time!
|
||||
*/
|
||||
export function downloadAttachmentBlob(block: AttachmentBlockComponent) {
|
||||
const { host, model, loading, error, downloading, blobUrl } = block;
|
||||
if (downloading) {
|
||||
toast(host, 'Download in progress...');
|
||||
return;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
toast(host, 'Please wait, file is loading...');
|
||||
return;
|
||||
}
|
||||
|
||||
const name = model.name;
|
||||
const shortName = name.length < 20 ? name : name.slice(0, 20) + '...';
|
||||
|
||||
if (error || !blobUrl) {
|
||||
toast(host, `Failed to download ${shortName}!`);
|
||||
return;
|
||||
}
|
||||
|
||||
block.downloading = true;
|
||||
|
||||
toast(host, `Downloading ${shortName}`);
|
||||
|
||||
const tmpLink = document.createElement('a');
|
||||
const event = new MouseEvent('click');
|
||||
tmpLink.download = name;
|
||||
tmpLink.href = blobUrl;
|
||||
tmpLink.dispatchEvent(event);
|
||||
tmpLink.remove();
|
||||
|
||||
block.downloading = false;
|
||||
}
|
||||
|
||||
export async function getFileType(file: File) {
|
||||
if (file.type) {
|
||||
return file.type;
|
||||
}
|
||||
// If the file type is not available, try to get it from the buffer.
|
||||
const buffer = await file.arrayBuffer();
|
||||
const FileType = await import('file-type');
|
||||
const fileType = await FileType.fileTypeFromBuffer(buffer);
|
||||
return fileType ? fileType.mime : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new attachment block before / after the specified block.
|
||||
*/
|
||||
export async function addSiblingAttachmentBlocks(
|
||||
editorHost: EditorHost,
|
||||
files: File[],
|
||||
maxFileSize: number,
|
||||
targetModel: BlockModel,
|
||||
place: 'before' | 'after' = 'after',
|
||||
isEmbed?: boolean
|
||||
) {
|
||||
if (!files.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSizeExceeded = files.some(file => file.size > maxFileSize);
|
||||
if (isSizeExceeded) {
|
||||
toast(
|
||||
editorHost,
|
||||
`You can only upload files less than ${humanFileSize(
|
||||
maxFileSize,
|
||||
true,
|
||||
0
|
||||
)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = targetModel.doc;
|
||||
|
||||
// Get the types of all files
|
||||
const types = await Promise.all(files.map(file => getFileType(file)));
|
||||
const attachmentBlockProps: (Partial<AttachmentBlockProps> & {
|
||||
flavour: 'affine:attachment';
|
||||
})[] = files.map((file, index) => ({
|
||||
flavour: 'affine:attachment',
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: types[index],
|
||||
embed: isEmbed,
|
||||
}));
|
||||
|
||||
const blockIds = doc.addSiblingBlocks(
|
||||
targetModel,
|
||||
attachmentBlockProps,
|
||||
place
|
||||
);
|
||||
|
||||
blockIds.forEach(
|
||||
(blockId, index) =>
|
||||
void uploadAttachmentBlob(editorHost, blockId, files[index], types[index])
|
||||
);
|
||||
|
||||
return blockIds;
|
||||
}
|
||||
|
||||
export async function addAttachments(
|
||||
std: BlockStdScope,
|
||||
files: File[],
|
||||
point?: IVec,
|
||||
transformPoint?: boolean // determines whether we should use `toModelCoord` to convert the point
|
||||
): Promise<string[]> {
|
||||
if (!files.length) return [];
|
||||
|
||||
const gfx = std.get(GfxControllerIdentifier);
|
||||
const maxFileSize = std.store.get(FileSizeLimitService).maxFileSize;
|
||||
const isSizeExceeded = files.some(file => file.size > maxFileSize);
|
||||
if (isSizeExceeded) {
|
||||
toast(
|
||||
std.host,
|
||||
`You can only upload files less than ${humanFileSize(
|
||||
maxFileSize,
|
||||
true,
|
||||
0
|
||||
)}`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
let { x, y } = gfx.viewport.center;
|
||||
if (point) {
|
||||
let transform = transformPoint ?? true;
|
||||
if (transform) {
|
||||
[x, y] = gfx.viewport.toModelCoord(...point);
|
||||
} else {
|
||||
[x, y] = point;
|
||||
}
|
||||
}
|
||||
|
||||
const CARD_STACK_GAP = 32;
|
||||
|
||||
const dropInfos: { blockId: string; file: File }[] = files.map(
|
||||
(file, index) => {
|
||||
const point = new Point(
|
||||
x + index * CARD_STACK_GAP,
|
||||
y + index * CARD_STACK_GAP
|
||||
);
|
||||
const center = Vec.toVec(point);
|
||||
const bound = Bound.fromCenter(
|
||||
center,
|
||||
EMBED_CARD_WIDTH.cubeThick,
|
||||
EMBED_CARD_HEIGHT.cubeThick
|
||||
);
|
||||
const blockId = std.store.addBlock(
|
||||
'affine:attachment',
|
||||
{
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
style: 'cubeThick',
|
||||
xywh: bound.serialize(),
|
||||
} satisfies Partial<AttachmentBlockProps>,
|
||||
gfx.surface
|
||||
);
|
||||
|
||||
return { blockId, file };
|
||||
}
|
||||
);
|
||||
|
||||
// upload file and update the attachment model
|
||||
const uploadPromises = dropInfos.map(async ({ blockId, file }) => {
|
||||
const filetype = await getFileType(file);
|
||||
await uploadAttachmentBlob(std.host, blockId, file, filetype, true);
|
||||
return blockId;
|
||||
});
|
||||
const blockIds = await Promise.all(uploadPromises);
|
||||
|
||||
gfx.selection.set({
|
||||
elements: blockIds,
|
||||
editing: false,
|
||||
});
|
||||
|
||||
return blockIds;
|
||||
}
|
||||
21
blocksuite/affine/blocks/block-attachment/tsconfig.json
Normal file
21
blocksuite/affine/blocks/block-attachment/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../block-embed" },
|
||||
{ "path": "../block-surface" },
|
||||
{ "path": "../../components" },
|
||||
{ "path": "../../model" },
|
||||
{ "path": "../../shared" },
|
||||
{ "path": "../../widgets/widget-slash-menu" },
|
||||
{ "path": "../../../framework/block-std" },
|
||||
{ "path": "../../../framework/global" },
|
||||
{ "path": "../../../framework/inline" },
|
||||
{ "path": "../../../framework/store" }
|
||||
]
|
||||
}
|
||||
45
blocksuite/affine/blocks/block-bookmark/package.json
Normal file
45
blocksuite/affine/blocks/block-bookmark/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@blocksuite/affine-block-bookmark",
|
||||
"description": "Bookmark block for BlockSuite.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test:unit": "nx vite:test --run --passWithNoTests",
|
||||
"test:unit:coverage": "nx vite:test --run --coverage",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-block-embed": "workspace:*",
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||
"@blocksuite/block-std": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.1",
|
||||
"@blocksuite/inline": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.12",
|
||||
"lit": "^3.2.0",
|
||||
"minimatch": "^10.0.1",
|
||||
"yjs": "^13.6.23",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./effects": "./src/effects.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.20.0"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { BookmarkBlockHtmlAdapterExtension } from './html.js';
|
||||
import { BookmarkBlockMarkdownAdapterExtension } from './markdown.js';
|
||||
import { BookmarkBlockNotionHtmlAdapterExtension } from './notion-html.js';
|
||||
import { BookmarkBlockPlainTextAdapterExtension } from './plain-text.js';
|
||||
|
||||
export const BookmarkBlockAdapterExtensions: ExtensionType[] = [
|
||||
BookmarkBlockHtmlAdapterExtension,
|
||||
BookmarkBlockMarkdownAdapterExtension,
|
||||
BookmarkBlockNotionHtmlAdapterExtension,
|
||||
BookmarkBlockPlainTextAdapterExtension,
|
||||
];
|
||||
10
blocksuite/affine/blocks/block-bookmark/src/adapters/html.ts
Normal file
10
blocksuite/affine/blocks/block-bookmark/src/adapters/html.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createEmbedBlockHtmlAdapterMatcher } from '@blocksuite/affine-block-embed';
|
||||
import { BookmarkBlockSchema } from '@blocksuite/affine-model';
|
||||
import { BlockHtmlAdapterExtension } from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
export const bookmarkBlockHtmlAdapterMatcher =
|
||||
createEmbedBlockHtmlAdapterMatcher(BookmarkBlockSchema.model.flavour);
|
||||
|
||||
export const BookmarkBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
|
||||
bookmarkBlockHtmlAdapterMatcher
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './html.js';
|
||||
export * from './markdown.js';
|
||||
export * from './notion-html.js';
|
||||
export * from './plain-text.js';
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createEmbedBlockMarkdownAdapterMatcher } from '@blocksuite/affine-block-embed';
|
||||
import { BookmarkBlockSchema } from '@blocksuite/affine-model';
|
||||
import { BlockMarkdownAdapterExtension } from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
export const bookmarkBlockMarkdownAdapterMatcher =
|
||||
createEmbedBlockMarkdownAdapterMatcher(BookmarkBlockSchema.model.flavour);
|
||||
|
||||
export const BookmarkBlockMarkdownAdapterExtension =
|
||||
BlockMarkdownAdapterExtension(bookmarkBlockMarkdownAdapterMatcher);
|
||||
@@ -0,0 +1,71 @@
|
||||
import { BookmarkBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockNotionHtmlAdapterExtension,
|
||||
type BlockNotionHtmlAdapterMatcher,
|
||||
HastUtils,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
|
||||
export const bookmarkBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher =
|
||||
{
|
||||
flavour: BookmarkBlockSchema.model.flavour,
|
||||
toMatch: o => {
|
||||
return (
|
||||
HastUtils.isElement(o.node) &&
|
||||
o.node.tagName === 'figure' &&
|
||||
!!HastUtils.querySelector(o.node, '.bookmark')
|
||||
);
|
||||
},
|
||||
fromMatch: () => false,
|
||||
toBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
if (!HastUtils.isElement(o.node)) {
|
||||
return;
|
||||
}
|
||||
const bookmark = HastUtils.querySelector(o.node, '.bookmark');
|
||||
if (!bookmark) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { walkerContext } = context;
|
||||
const bookmarkURL = bookmark.properties?.href;
|
||||
const bookmarkTitle = HastUtils.getTextContent(
|
||||
HastUtils.querySelector(bookmark, '.bookmark-title')
|
||||
);
|
||||
const bookmarkDescription = HastUtils.getTextContent(
|
||||
HastUtils.querySelector(bookmark, '.bookmark-description')
|
||||
);
|
||||
const bookmarkIcon = HastUtils.querySelector(
|
||||
bookmark,
|
||||
'.bookmark-icon'
|
||||
);
|
||||
const bookmarkIconURL =
|
||||
typeof bookmarkIcon?.properties?.src === 'string'
|
||||
? bookmarkIcon.properties.src
|
||||
: '';
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: BookmarkBlockSchema.model.flavour,
|
||||
props: {
|
||||
type: 'card',
|
||||
url: bookmarkURL ?? '',
|
||||
title: bookmarkTitle,
|
||||
description: bookmarkDescription,
|
||||
icon: bookmarkIconURL,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode();
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
fromBlockSnapshot: {},
|
||||
};
|
||||
|
||||
export const BookmarkBlockNotionHtmlAdapterExtension =
|
||||
BlockNotionHtmlAdapterExtension(bookmarkBlockNotionHtmlAdapterMatcher);
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createEmbedBlockPlainTextAdapterMatcher } from '@blocksuite/affine-block-embed';
|
||||
import { BookmarkBlockSchema } from '@blocksuite/affine-model';
|
||||
import { BlockPlainTextAdapterExtension } from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
export const bookmarkBlockPlainTextAdapterMatcher =
|
||||
createEmbedBlockPlainTextAdapterMatcher(BookmarkBlockSchema.model.flavour);
|
||||
|
||||
export const BookmarkBlockPlainTextAdapterExtension =
|
||||
BlockPlainTextAdapterExtension(bookmarkBlockPlainTextAdapterMatcher);
|
||||
121
blocksuite/affine/blocks/block-bookmark/src/bookmark-block.ts
Normal file
121
blocksuite/affine/blocks/block-bookmark/src/bookmark-block.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
CaptionedBlockComponent,
|
||||
SelectedStyle,
|
||||
} from '@blocksuite/affine-components/caption';
|
||||
import type { BookmarkBlockModel } from '@blocksuite/affine-model';
|
||||
import { DocModeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { computed, type ReadonlySignal } from '@preact/signals-core';
|
||||
import { html } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { type ClassInfo, classMap } from 'lit/directives/class-map.js';
|
||||
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { refreshBookmarkUrlData } from './utils.js';
|
||||
|
||||
export const BOOKMARK_MIN_WIDTH = 450;
|
||||
|
||||
export class BookmarkBlockComponent extends CaptionedBlockComponent<BookmarkBlockModel> {
|
||||
selectedStyle$: ReadonlySignal<ClassInfo> | null = computed<ClassInfo>(
|
||||
() => ({
|
||||
'selected-style': this.selected$.value,
|
||||
})
|
||||
);
|
||||
|
||||
private _fetchAbortController?: AbortController;
|
||||
|
||||
blockDraggable = true;
|
||||
|
||||
protected containerStyleMap!: ReturnType<typeof styleMap>;
|
||||
|
||||
open = () => {
|
||||
let link = this.model.url;
|
||||
if (!link.match(/^[a-zA-Z]+:\/\//)) {
|
||||
link = 'https://' + link;
|
||||
}
|
||||
window.open(link, '_blank');
|
||||
};
|
||||
|
||||
refreshData = () => {
|
||||
refreshBookmarkUrlData(this, this._fetchAbortController?.signal).catch(
|
||||
console.error
|
||||
);
|
||||
};
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
const mode = this.std.get(DocModeProvider).getEditorMode();
|
||||
const miniWidth = `${BOOKMARK_MIN_WIDTH}px`;
|
||||
|
||||
this.containerStyleMap = styleMap({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
...(mode === 'edgeless' ? { miniWidth } : {}),
|
||||
});
|
||||
|
||||
this._fetchAbortController = new AbortController();
|
||||
|
||||
this.contentEditable = 'false';
|
||||
|
||||
if (!this.model.description && !this.model.title) {
|
||||
this.refreshData();
|
||||
}
|
||||
|
||||
this.disposables.add(
|
||||
this.model.propsUpdated.on(({ key }) => {
|
||||
if (key === 'url') {
|
||||
this.refreshData();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._fetchAbortController?.abort();
|
||||
}
|
||||
|
||||
override renderBlock() {
|
||||
return html`
|
||||
<div
|
||||
draggable="${this.blockDraggable ? 'true' : 'false'}"
|
||||
class=${classMap({
|
||||
'affine-bookmark-container': true,
|
||||
...this.selectedStyle$?.value,
|
||||
})}
|
||||
style=${this.containerStyleMap}
|
||||
>
|
||||
<bookmark-card
|
||||
.bookmark=${this}
|
||||
.loading=${this.loading}
|
||||
.error=${this.error}
|
||||
></bookmark-card>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected override accessor blockContainerStyles: StyleInfo = {
|
||||
margin: '18px 0',
|
||||
};
|
||||
|
||||
@query('bookmark-card')
|
||||
accessor bookmarkCard!: HTMLElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor error = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor loading = false;
|
||||
|
||||
override accessor selectedStyle = SelectedStyle.Border;
|
||||
|
||||
override accessor useCaptionEditor = true;
|
||||
|
||||
override accessor useZeroWidth = true;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-bookmark': BookmarkBlockComponent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
EMBED_CARD_HEIGHT,
|
||||
EMBED_CARD_WIDTH,
|
||||
} from '@blocksuite/affine-shared/consts';
|
||||
import { toGfxBlockComponent } from '@blocksuite/block-std';
|
||||
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { BookmarkBlockComponent } from './bookmark-block.js';
|
||||
|
||||
export class BookmarkEdgelessBlockComponent extends toGfxBlockComponent(
|
||||
BookmarkBlockComponent
|
||||
) {
|
||||
override selectedStyle$ = null;
|
||||
|
||||
override blockDraggable = false;
|
||||
|
||||
override getRenderingRect() {
|
||||
const elementBound = this.model.elementBound;
|
||||
const style = this.model.style$.value;
|
||||
|
||||
return {
|
||||
x: elementBound.x,
|
||||
y: elementBound.y,
|
||||
w: EMBED_CARD_WIDTH[style],
|
||||
h: EMBED_CARD_HEIGHT[style],
|
||||
zIndex: this.toZIndex(),
|
||||
};
|
||||
}
|
||||
|
||||
override renderGfxBlock() {
|
||||
const style = this.model.style$.value;
|
||||
const width = EMBED_CARD_WIDTH[style];
|
||||
const height = EMBED_CARD_HEIGHT[style];
|
||||
const bound = this.model.elementBound;
|
||||
const scaleX = bound.w / width;
|
||||
const scaleY = bound.h / height;
|
||||
|
||||
this.containerStyleMap = styleMap({
|
||||
width: `100%`,
|
||||
height: `100%`,
|
||||
transform: `scale(${scaleX}, ${scaleY})`,
|
||||
transformOrigin: '0 0',
|
||||
});
|
||||
|
||||
return this.renderPageContent();
|
||||
}
|
||||
|
||||
protected override accessor blockContainerStyles: StyleInfo = {
|
||||
height: '100%',
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-edgeless-bookmark': BookmarkEdgelessBlockComponent;
|
||||
}
|
||||
}
|
||||
30
blocksuite/affine/blocks/block-bookmark/src/bookmark-spec.ts
Normal file
30
blocksuite/affine/blocks/block-bookmark/src/bookmark-spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { BookmarkBlockSchema } from '@blocksuite/affine-model';
|
||||
import { ToolbarModuleExtension } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
BlockFlavourIdentifier,
|
||||
BlockViewExtension,
|
||||
FlavourExtension,
|
||||
} from '@blocksuite/block-std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import { literal } from 'lit/static-html.js';
|
||||
|
||||
import { BookmarkBlockAdapterExtensions } from './adapters/extension';
|
||||
import { BookmarkSlashMenuConfigExtension } from './configs/slash-menu';
|
||||
import { builtinToolbarConfig } from './configs/toolbar';
|
||||
|
||||
const flavour = BookmarkBlockSchema.model.flavour;
|
||||
|
||||
export const BookmarkBlockSpec: ExtensionType[] = [
|
||||
FlavourExtension(flavour),
|
||||
BlockViewExtension(flavour, model => {
|
||||
return model.parent?.flavour === 'affine:surface'
|
||||
? literal`affine-edgeless-bookmark`
|
||||
: literal`affine-bookmark`;
|
||||
}),
|
||||
BookmarkBlockAdapterExtensions,
|
||||
ToolbarModuleExtension({
|
||||
id: BlockFlavourIdentifier(flavour),
|
||||
config: builtinToolbarConfig,
|
||||
}),
|
||||
BookmarkSlashMenuConfigExtension,
|
||||
].flat();
|
||||
@@ -0,0 +1,2 @@
|
||||
export { insertBookmarkCommand } from './insert-bookmark.js';
|
||||
export { insertLinkByQuickSearchCommand } from './insert-link-by-quick-search.js';
|
||||
@@ -0,0 +1,21 @@
|
||||
import '@blocksuite/affine-block-embed/effects';
|
||||
|
||||
import { insertEmbedCard } from '@blocksuite/affine-block-embed';
|
||||
import type { EmbedCardStyle } from '@blocksuite/affine-model';
|
||||
import { EmbedOptionProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { Command } from '@blocksuite/block-std';
|
||||
|
||||
export const insertBookmarkCommand: Command<{ url: string }> = (ctx, next) => {
|
||||
const { url, std } = ctx;
|
||||
const embedOptions = std.get(EmbedOptionProvider).getEmbedBlockOptions(url);
|
||||
|
||||
let flavour = 'affine:bookmark';
|
||||
let targetStyle: EmbedCardStyle = 'vertical';
|
||||
const props: Record<string, unknown> = { url };
|
||||
if (embedOptions) {
|
||||
flavour = embedOptions.flavour;
|
||||
targetStyle = embedOptions.styles[0];
|
||||
}
|
||||
insertEmbedCard(std, { flavour, targetStyle, props });
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
type InsertedLinkType,
|
||||
insertEmbedLinkedDocCommand,
|
||||
} from '@blocksuite/affine-block-embed';
|
||||
import { QuickSearchProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { Command } from '@blocksuite/block-std';
|
||||
|
||||
import { insertBookmarkCommand } from './insert-bookmark';
|
||||
|
||||
export const insertLinkByQuickSearchCommand: Command<
|
||||
{},
|
||||
{ insertedLinkType: Promise<InsertedLinkType> }
|
||||
> = (ctx, next) => {
|
||||
const { std } = ctx;
|
||||
const quickSearchService = std.getOptional(QuickSearchProvider);
|
||||
if (!quickSearchService) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const insertedLinkType: Promise<InsertedLinkType> = quickSearchService
|
||||
.openQuickSearch()
|
||||
.then(result => {
|
||||
if (!result) return null;
|
||||
|
||||
// add linked doc
|
||||
if ('docId' in result) {
|
||||
std.command.exec(insertEmbedLinkedDocCommand, {
|
||||
docId: result.docId,
|
||||
params: result.params,
|
||||
});
|
||||
return {
|
||||
flavour: 'affine:embed-linked-doc',
|
||||
};
|
||||
}
|
||||
|
||||
// add normal link;
|
||||
if ('externalUrl' in result) {
|
||||
std.command.exec(insertBookmarkCommand, { url: result.externalUrl });
|
||||
return {
|
||||
flavour: 'affine:bookmark',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
next({ insertedLinkType });
|
||||
};
|
||||
@@ -0,0 +1,153 @@
|
||||
import { getEmbedCardIcons } from '@blocksuite/affine-block-embed';
|
||||
import { WebIcon16 } from '@blocksuite/affine-components/icons';
|
||||
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { getHostName } from '@blocksuite/affine-shared/utils';
|
||||
import { BlockSelection, ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { OpenInNewIcon } from '@blocksuite/icons/lit';
|
||||
import { html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
|
||||
import type { BookmarkBlockComponent } from '../bookmark-block.js';
|
||||
import { styles } from '../styles.js';
|
||||
|
||||
export class BookmarkCard extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = styles;
|
||||
|
||||
private _handleClick(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
const model = this.bookmark.model;
|
||||
|
||||
if (model.parent?.flavour !== 'affine:surface') {
|
||||
this._selectBlock();
|
||||
}
|
||||
}
|
||||
|
||||
private _handleDoubleClick(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
this.bookmark.open();
|
||||
}
|
||||
|
||||
private _selectBlock() {
|
||||
const selectionManager = this.bookmark.host.selection;
|
||||
const blockSelection = selectionManager.create(BlockSelection, {
|
||||
blockId: this.bookmark.blockId,
|
||||
});
|
||||
selectionManager.setGroup('note', [blockSelection]);
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this.disposables.add(
|
||||
this.bookmark.model.propsUpdated.on(() => {
|
||||
this.requestUpdate();
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.add(
|
||||
this.bookmark.std
|
||||
.get(ThemeProvider)
|
||||
.theme$.subscribe(() => this.requestUpdate())
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const { icon, title, url, description, image, style } = this.bookmark.model;
|
||||
|
||||
const cardClassMap = classMap({
|
||||
loading: this.loading,
|
||||
error: this.error,
|
||||
[style]: true,
|
||||
selected: this.bookmark.selected$.value,
|
||||
});
|
||||
|
||||
const domainName = url.match(
|
||||
/^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:/\n]+)/im
|
||||
)?.[1];
|
||||
|
||||
const titleText = this.loading
|
||||
? 'Loading...'
|
||||
: !title
|
||||
? this.error
|
||||
? (domainName ?? 'Link card')
|
||||
: ''
|
||||
: title;
|
||||
|
||||
const theme = this.bookmark.std.get(ThemeProvider).theme;
|
||||
const { LoadingIcon, EmbedCardBannerIcon } = getEmbedCardIcons(theme);
|
||||
|
||||
const titleIconType =
|
||||
!icon?.split('.').pop() || icon?.split('.').pop() === 'svg'
|
||||
? 'svg+xml'
|
||||
: icon?.split('.').pop();
|
||||
|
||||
const titleIcon = this.loading
|
||||
? LoadingIcon
|
||||
: icon
|
||||
? html`<object
|
||||
type="image/${titleIconType}"
|
||||
data=${icon}
|
||||
draggable="false"
|
||||
>
|
||||
${WebIcon16}
|
||||
</object>`
|
||||
: WebIcon16;
|
||||
|
||||
const descriptionText = this.loading
|
||||
? ''
|
||||
: !description
|
||||
? this.error
|
||||
? 'Failed to retrieve link information.'
|
||||
: url
|
||||
: (description ?? '');
|
||||
|
||||
const bannerImage =
|
||||
!this.loading && image
|
||||
? html`<object type="image/webp" data=${image} draggable="false">
|
||||
${EmbedCardBannerIcon}
|
||||
</object>`
|
||||
: EmbedCardBannerIcon;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="affine-bookmark-card ${cardClassMap}"
|
||||
@click=${this._handleClick}
|
||||
@dblclick=${this._handleDoubleClick}
|
||||
>
|
||||
<div class="affine-bookmark-content">
|
||||
<div class="affine-bookmark-content-title">
|
||||
<div class="affine-bookmark-content-title-icon">${titleIcon}</div>
|
||||
<div class="affine-bookmark-content-title-text">${titleText}</div>
|
||||
</div>
|
||||
<div class="affine-bookmark-content-description">
|
||||
${descriptionText}
|
||||
</div>
|
||||
<div class="affine-bookmark-content-url" @click=${this.bookmark.open}>
|
||||
<span>${getHostName(url)}</span>
|
||||
<div class="affine-bookmark-content-url-icon">
|
||||
${OpenInNewIcon({ width: '12', height: '12' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="affine-bookmark-banner">${bannerImage}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor bookmark!: BookmarkBlockComponent;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor error!: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor loading!: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'bookmark-card': BookmarkCard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './bookmark-card';
|
||||
@@ -0,0 +1,57 @@
|
||||
import { toggleEmbedCardCreateModal } from '@blocksuite/affine-components/embed-card-modal';
|
||||
import { BookmarkBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
type SlashMenuConfig,
|
||||
SlashMenuConfigIdentifier,
|
||||
} from '@blocksuite/affine-widget-slash-menu';
|
||||
import { LinkIcon } from '@blocksuite/icons/lit';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { LinkTooltip } from './tooltips';
|
||||
|
||||
const bookmarkSlashMenuConfig: SlashMenuConfig = {
|
||||
items: [
|
||||
{
|
||||
name: 'Link',
|
||||
description: 'Add a bookmark for reference.',
|
||||
icon: LinkIcon(),
|
||||
tooltip: {
|
||||
figure: LinkTooltip,
|
||||
caption: 'Link',
|
||||
},
|
||||
group: '4_Content & Media@2',
|
||||
when: ({ model }) =>
|
||||
model.doc.schema.flavourSchemaMap.has('affine:bookmark'),
|
||||
action: ({ std, model }) => {
|
||||
const { host } = std;
|
||||
const parentModel = host.doc.getParent(model);
|
||||
if (!parentModel) {
|
||||
return;
|
||||
}
|
||||
const index = parentModel.children.indexOf(model) + 1;
|
||||
toggleEmbedCardCreateModal(
|
||||
host,
|
||||
'Links',
|
||||
'The added link will be displayed as a card view.',
|
||||
{ mode: 'page', parentModel, index }
|
||||
)
|
||||
.then(() => {
|
||||
if (model.text?.length === 0) {
|
||||
model.doc.deleteBlock(model);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const BookmarkSlashMenuConfigIdentifier = SlashMenuConfigIdentifier(
|
||||
BookmarkBlockSchema.model.flavour
|
||||
);
|
||||
|
||||
export const BookmarkSlashMenuConfigExtension: ExtensionType = {
|
||||
setup: di => {
|
||||
di.addImpl(BookmarkSlashMenuConfigIdentifier, bookmarkSlashMenuConfig);
|
||||
},
|
||||
};
|
||||
324
blocksuite/affine/blocks/block-bookmark/src/configs/toolbar.ts
Normal file
324
blocksuite/affine/blocks/block-bookmark/src/configs/toolbar.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import { BookmarkBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
ActionPlacement,
|
||||
EmbedOptionProvider,
|
||||
type ToolbarAction,
|
||||
type ToolbarActionGroup,
|
||||
type ToolbarModuleConfig,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { getBlockProps } from '@blocksuite/affine-shared/utils';
|
||||
import { BlockSelection } from '@blocksuite/block-std';
|
||||
import {
|
||||
CaptionIcon,
|
||||
CopyIcon,
|
||||
DeleteIcon,
|
||||
DuplicateIcon,
|
||||
ResetIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import { Slice, Text } from '@blocksuite/store';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { html } from 'lit';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { BookmarkBlockComponent } from '../bookmark-block';
|
||||
|
||||
const trackBaseProps = {
|
||||
segment: 'doc',
|
||||
page: 'doc editor',
|
||||
module: 'toolbar',
|
||||
category: 'bookmark',
|
||||
type: 'card view',
|
||||
};
|
||||
|
||||
export const builtinToolbarConfig = {
|
||||
actions: [
|
||||
{
|
||||
id: 'a.preview',
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentModelByType(
|
||||
BlockSelection,
|
||||
BookmarkBlockModel
|
||||
);
|
||||
if (!model) return null;
|
||||
|
||||
const { url } = model;
|
||||
|
||||
return html`<affine-link-preview .url=${url}></affine-link-preview>`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'b.conversions',
|
||||
actions: [
|
||||
{
|
||||
id: 'inline',
|
||||
label: 'Inline view',
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(
|
||||
BlockSelection,
|
||||
BookmarkBlockModel
|
||||
);
|
||||
if (!model) return;
|
||||
|
||||
const { title, caption, url, parent } = model;
|
||||
const index = parent?.children.indexOf(model);
|
||||
|
||||
const yText = new Y.Text();
|
||||
const insert = title || caption || url;
|
||||
yText.insert(0, insert);
|
||||
yText.format(0, insert.length, { link: url });
|
||||
|
||||
const text = new Text(yText);
|
||||
|
||||
ctx.store.addBlock('affine:paragraph', { text }, parent, index);
|
||||
|
||||
ctx.store.deleteBlock(model);
|
||||
|
||||
// Clears
|
||||
ctx.reset();
|
||||
ctx.select('note');
|
||||
|
||||
ctx.track('SelectedView', {
|
||||
...trackBaseProps,
|
||||
control: 'select view',
|
||||
type: 'inline view',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'card',
|
||||
label: 'Card view',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'embed',
|
||||
label: 'Embed view',
|
||||
disabled(ctx) {
|
||||
const model = ctx.getCurrentModelByType(
|
||||
BlockSelection,
|
||||
BookmarkBlockModel
|
||||
);
|
||||
if (!model) return true;
|
||||
|
||||
const options = ctx.std
|
||||
.get(EmbedOptionProvider)
|
||||
.getEmbedBlockOptions(model.url);
|
||||
|
||||
return options?.viewType !== 'embed';
|
||||
},
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentModelByType(
|
||||
BlockSelection,
|
||||
BookmarkBlockModel
|
||||
);
|
||||
if (!model) return;
|
||||
|
||||
const { caption, url, style, parent } = model;
|
||||
const index = parent?.children.indexOf(model);
|
||||
|
||||
const options = ctx.std
|
||||
.get(EmbedOptionProvider)
|
||||
.getEmbedBlockOptions(url);
|
||||
|
||||
if (!options) return;
|
||||
|
||||
const { flavour, styles } = options;
|
||||
|
||||
const newStyle = styles.includes(style)
|
||||
? style
|
||||
: styles.find(s => s !== 'vertical' && s !== 'cube');
|
||||
|
||||
const blockId = ctx.store.addBlock(
|
||||
flavour,
|
||||
{
|
||||
url,
|
||||
caption,
|
||||
style: newStyle,
|
||||
},
|
||||
parent,
|
||||
index
|
||||
);
|
||||
|
||||
ctx.store.deleteBlock(model);
|
||||
|
||||
// Selects new block
|
||||
ctx.select('note', [
|
||||
ctx.selection.create(BlockSelection, { blockId }),
|
||||
]);
|
||||
|
||||
ctx.track('SelectedView', {
|
||||
...trackBaseProps,
|
||||
control: 'select view',
|
||||
type: 'embed view',
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentModelByType(
|
||||
BlockSelection,
|
||||
BookmarkBlockModel
|
||||
);
|
||||
if (!model) return null;
|
||||
|
||||
const actions = this.actions.map(action => ({ ...action }));
|
||||
const toggle = (e: CustomEvent<boolean>) => {
|
||||
const opened = e.detail;
|
||||
if (!opened) return;
|
||||
|
||||
ctx.track('OpenedViewSelector', {
|
||||
...trackBaseProps,
|
||||
control: 'switch view',
|
||||
});
|
||||
};
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-view-dropdown-menu
|
||||
.actions=${actions}
|
||||
.context=${ctx}
|
||||
.toggle=${toggle}
|
||||
.viewType$=${signal(actions[1].label)}
|
||||
></affine-view-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
{
|
||||
id: 'c.style',
|
||||
actions: [
|
||||
{
|
||||
id: 'horizontal',
|
||||
label: 'Large horizontal style',
|
||||
},
|
||||
{
|
||||
id: 'list',
|
||||
label: 'Small horizontal style',
|
||||
},
|
||||
],
|
||||
content(ctx) {
|
||||
const model = ctx.getCurrentModelByType(
|
||||
BlockSelection,
|
||||
BookmarkBlockModel
|
||||
);
|
||||
if (!model) return null;
|
||||
|
||||
const actions = this.actions.map(action => ({
|
||||
...action,
|
||||
run: ({ store }) => {
|
||||
store.updateBlock(model, { style: action.id });
|
||||
|
||||
ctx.track('SelectedCardStyle', {
|
||||
...trackBaseProps,
|
||||
control: 'select card style',
|
||||
type: action.id,
|
||||
});
|
||||
},
|
||||
})) satisfies ToolbarAction[];
|
||||
|
||||
const toggle = (e: CustomEvent<boolean>) => {
|
||||
const opened = e.detail;
|
||||
if (!opened) return;
|
||||
|
||||
ctx.track('OpenedCardStyleSelector', {
|
||||
...trackBaseProps,
|
||||
control: 'switch card style',
|
||||
});
|
||||
};
|
||||
|
||||
return html`${keyed(
|
||||
model,
|
||||
html`<affine-card-style-dropdown-menu
|
||||
.actions=${actions}
|
||||
.context=${ctx}
|
||||
.toggle=${toggle}
|
||||
.style$=${model.style$}
|
||||
></affine-card-style-dropdown-menu>`
|
||||
)}`;
|
||||
},
|
||||
} satisfies ToolbarActionGroup<ToolbarAction>,
|
||||
{
|
||||
id: 'd.caption',
|
||||
tooltip: 'Caption',
|
||||
icon: CaptionIcon(),
|
||||
run(ctx) {
|
||||
const component = ctx.getCurrentBlockComponentBy(
|
||||
BlockSelection,
|
||||
BookmarkBlockComponent
|
||||
);
|
||||
component?.captionEditor?.show();
|
||||
|
||||
ctx.track('OpenedCaptionEditor', {
|
||||
...trackBaseProps,
|
||||
control: 'add caption',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'a.clipboard',
|
||||
actions: [
|
||||
{
|
||||
id: 'copy',
|
||||
label: 'Copy',
|
||||
icon: CopyIcon(),
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentBlockBy(BlockSelection)?.model;
|
||||
if (!model) return;
|
||||
|
||||
const slice = Slice.fromModels(ctx.store, [model]);
|
||||
ctx.clipboard
|
||||
.copySlice(slice)
|
||||
.then(() => toast(ctx.host, 'Copied to clipboard'))
|
||||
.catch(console.error);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'duplicate',
|
||||
label: 'Duplicate',
|
||||
icon: DuplicateIcon(),
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentBlockBy(BlockSelection)?.model;
|
||||
if (!model) return;
|
||||
|
||||
const { flavour, parent } = model;
|
||||
const props = getBlockProps(model);
|
||||
const index = parent?.children.indexOf(model);
|
||||
|
||||
ctx.store.addBlock(flavour, props, parent, index);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'b.refresh',
|
||||
label: 'Reload',
|
||||
icon: ResetIcon(),
|
||||
run(ctx) {
|
||||
const component = ctx.getCurrentBlockComponentBy(
|
||||
BlockSelection,
|
||||
BookmarkBlockComponent
|
||||
);
|
||||
component?.refreshData();
|
||||
},
|
||||
},
|
||||
{
|
||||
placement: ActionPlacement.More,
|
||||
id: 'c.delete',
|
||||
label: 'Delete',
|
||||
icon: DeleteIcon(),
|
||||
variant: 'destructive',
|
||||
run(ctx) {
|
||||
const model = ctx.getCurrentBlockBy(BlockSelection)?.model;
|
||||
if (!model) return;
|
||||
|
||||
ctx.store.deleteBlock(model);
|
||||
|
||||
// Clears
|
||||
ctx.select('note');
|
||||
ctx.reset();
|
||||
},
|
||||
},
|
||||
],
|
||||
} as const satisfies ToolbarModuleConfig;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { html } from 'lit';
|
||||
// prettier-ignore
|
||||
export const LinkTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="170" height="68" rx="2" fill="white"/>
|
||||
<mask id="mask0_16460_1007" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
|
||||
<rect width="170" height="68" rx="2" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_16460_1007)">
|
||||
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data. </tspan><tspan x="8" y="63.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="75.6364">either have, choose to share, or accept. </tspan><tspan x="8" y="111.636">For example, one user’s edits to a document might be on </tspan><tspan x="8" y="123.636">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="135.636">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="147.636">other users. </tspan><tspan x="8" y="183.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="195.636">those changes to their version of the document.</tspan></text>
|
||||
<text fill="#1E67AF" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="11" letter-spacing="0em"><tspan x="8" y="45.5">Learn about AFFiNE</tspan></text>
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
12
blocksuite/affine/blocks/block-bookmark/src/effects.ts
Normal file
12
blocksuite/affine/blocks/block-bookmark/src/effects.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { BookmarkBlockComponent } from './bookmark-block';
|
||||
import { BookmarkEdgelessBlockComponent } from './bookmark-edgeless-block';
|
||||
import { BookmarkCard } from './components/bookmark-card';
|
||||
|
||||
export function effects() {
|
||||
customElements.define(
|
||||
'affine-edgeless-bookmark',
|
||||
BookmarkEdgelessBlockComponent
|
||||
);
|
||||
customElements.define('affine-bookmark', BookmarkBlockComponent);
|
||||
customElements.define('bookmark-card', BookmarkCard);
|
||||
}
|
||||
6
blocksuite/affine/blocks/block-bookmark/src/index.ts
Normal file
6
blocksuite/affine/blocks/block-bookmark/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './adapters';
|
||||
export * from './bookmark-block';
|
||||
export * from './bookmark-spec';
|
||||
export * from './commands';
|
||||
export * from './components';
|
||||
export { BookmarkSlashMenuConfigIdentifier } from './configs/slash-menu';
|
||||
283
blocksuite/affine/blocks/block-bookmark/src/styles.ts
Normal file
283
blocksuite/affine/blocks/block-bookmark/src/styles.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { unsafeCSSVar } from '@blocksuite/affine-shared/theme';
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { css, unsafeCSS } from 'lit';
|
||||
|
||||
export const styles = css`
|
||||
bookmark-card {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.affine-bookmark-card {
|
||||
container: affine-bookmark-card / inline-size;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--affine-background-tertiary-color);
|
||||
|
||||
opacity: var(--add, 1);
|
||||
background: var(--affine-background-primary-color);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.affine-bookmark-content {
|
||||
width: calc(100% - 204px);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-self: stretch;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border-radius: var(--1, 0px);
|
||||
opacity: var(--add, 1);
|
||||
}
|
||||
|
||||
.affine-bookmark-content-title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
align-self: stretch;
|
||||
padding: var(--1, 0px);
|
||||
border-radius: var(--1, 0px);
|
||||
opacity: var(--add, 1);
|
||||
}
|
||||
|
||||
.affine-bookmark-content-title-icon {
|
||||
display: flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.affine-bookmark-content-title-icon img,
|
||||
.affine-bookmark-content-title-icon object,
|
||||
.affine-bookmark-content-title-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: var(--affine-background-primary-color);
|
||||
}
|
||||
|
||||
.affine-bookmark-content-title-text {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--affine-text-primary-color);
|
||||
|
||||
font-family: var(--affine-font-family);
|
||||
font-size: var(--affine-font-sm);
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.affine-bookmark-content-description {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
|
||||
flex-grow: 1;
|
||||
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--affine-text-primary-color);
|
||||
|
||||
font-family: var(--affine-font-family);
|
||||
font-size: var(--affine-font-xs);
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.affine-bookmark-content-url {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.affine-bookmark-content-url > span {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--affine-text-secondary-color);
|
||||
|
||||
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||
font-size: var(--affine-font-xs);
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
.affine-bookmark-content-url:hover > span {
|
||||
color: var(--affine-link-color);
|
||||
}
|
||||
.affine-bookmark-content-url:hover {
|
||||
fill: var(--affine-link-color);
|
||||
}
|
||||
|
||||
.affine-bookmark-content-url-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 12px;
|
||||
height: 20px;
|
||||
}
|
||||
.affine-bookmark-content-url-icon {
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
color: ${unsafeCSSVar('iconSecondary')};
|
||||
}
|
||||
|
||||
.affine-bookmark-banner {
|
||||
margin: 12px 12px 0px 0px;
|
||||
width: 204px;
|
||||
max-width: 100%;
|
||||
height: 102px;
|
||||
opacity: var(--add, 1);
|
||||
}
|
||||
|
||||
.affine-bookmark-banner img,
|
||||
.affine-bookmark-banner object,
|
||||
.affine-bookmark-banner svg {
|
||||
width: 204px;
|
||||
max-width: 100%;
|
||||
height: 102px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px 4px var(--1, 0px) var(--1, 0px);
|
||||
}
|
||||
|
||||
.affine-bookmark-card.loading {
|
||||
.affine-bookmark-content-title-text {
|
||||
color: var(--affine-placeholder-color);
|
||||
}
|
||||
}
|
||||
|
||||
.affine-bookmark-card.error {
|
||||
.affine-bookmark-content-description {
|
||||
color: var(--affine-placeholder-color);
|
||||
}
|
||||
}
|
||||
|
||||
.affine-bookmark-card.selected {
|
||||
.affine-bookmark-content-url > span {
|
||||
color: var(--affine-link-color);
|
||||
}
|
||||
.affine-bookmark-content-url .affine-bookmark-content-url-icon {
|
||||
color: var(--affine-link-color);
|
||||
}
|
||||
}
|
||||
|
||||
.affine-bookmark-card.list {
|
||||
.affine-bookmark-content {
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.affine-bookmark-content-title {
|
||||
width: calc(100% - 204px);
|
||||
}
|
||||
|
||||
.affine-bookmark-content-url {
|
||||
width: 204px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.affine-bookmark-content-description {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.affine-bookmark-banner {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.affine-bookmark-card.vertical {
|
||||
flex-direction: column-reverse;
|
||||
height: 100%;
|
||||
|
||||
.affine-bookmark-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.affine-bookmark-content-description {
|
||||
-webkit-line-clamp: 6;
|
||||
max-height: 120px;
|
||||
}
|
||||
|
||||
.affine-bookmark-content-url {
|
||||
flex-grow: 1;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.affine-bookmark-banner {
|
||||
width: 340px;
|
||||
height: 170px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.affine-bookmark-banner img,
|
||||
.affine-bookmark-banner object,
|
||||
.affine-bookmark-banner svg {
|
||||
width: 340px;
|
||||
height: 170px;
|
||||
}
|
||||
}
|
||||
|
||||
.affine-bookmark-card.cube {
|
||||
.affine-bookmark-content {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.affine-bookmark-content-title {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.affine-bookmark-content-title-text {
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.affine-bookmark-content-description {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.affine-bookmark-banner {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container affine-bookmark-card (width < 375px) {
|
||||
.affine-bookmark-content {
|
||||
width: 100%;
|
||||
}
|
||||
.affine-bookmark-banner {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
47
blocksuite/affine/blocks/block-bookmark/src/utils.ts
Normal file
47
blocksuite/affine/blocks/block-bookmark/src/utils.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { LinkPreviewerService } from '@blocksuite/affine-shared/services';
|
||||
import { isAbortError } from '@blocksuite/affine-shared/utils';
|
||||
|
||||
import type { BookmarkBlockComponent } from './bookmark-block.js';
|
||||
|
||||
export async function refreshBookmarkUrlData(
|
||||
bookmarkElement: BookmarkBlockComponent,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
let title = null,
|
||||
description = null,
|
||||
icon = null,
|
||||
image = null;
|
||||
|
||||
try {
|
||||
bookmarkElement.loading = true;
|
||||
|
||||
const linkPreviewer = bookmarkElement.doc.get(LinkPreviewerService);
|
||||
const bookmarkUrlData = await linkPreviewer.query(
|
||||
bookmarkElement.model.url,
|
||||
signal
|
||||
);
|
||||
|
||||
title = bookmarkUrlData.title ?? null;
|
||||
description = bookmarkUrlData.description ?? null;
|
||||
icon = bookmarkUrlData.icon ?? null;
|
||||
image = bookmarkUrlData.image ?? null;
|
||||
|
||||
if (!title && !description && !icon && !image) {
|
||||
bookmarkElement.error = true;
|
||||
}
|
||||
|
||||
if (signal?.aborted) return;
|
||||
|
||||
bookmarkElement.doc.updateBlock(bookmarkElement.model, {
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
image,
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted || isAbortError(error)) return;
|
||||
throw error;
|
||||
} finally {
|
||||
bookmarkElement.loading = false;
|
||||
}
|
||||
}
|
||||
20
blocksuite/affine/blocks/block-bookmark/tsconfig.json
Normal file
20
blocksuite/affine/blocks/block-bookmark/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../block-embed" },
|
||||
{ "path": "../../components" },
|
||||
{ "path": "../../model" },
|
||||
{ "path": "../../shared" },
|
||||
{ "path": "../../widgets/widget-slash-menu" },
|
||||
{ "path": "../../../framework/block-std" },
|
||||
{ "path": "../../../framework/global" },
|
||||
{ "path": "../../../framework/inline" },
|
||||
{ "path": "../../../framework/store" }
|
||||
]
|
||||
}
|
||||
48
blocksuite/affine/blocks/block-callout/package.json
Normal file
48
blocksuite/affine/blocks/block-callout/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@blocksuite/affine-block-callout",
|
||||
"description": "Callout block for BlockSuite.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test:unit": "nx vite:test --run --passWithNoTests",
|
||||
"test:unit:coverage": "nx vite:test --run --coverage",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||
"@blocksuite/block-std": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.1",
|
||||
"@blocksuite/inline": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@floating-ui/dom": "^1.6.10",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.12",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"lit": "^3.2.0",
|
||||
"minimatch": "^10.0.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./effects": "./src/effects.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.20.0"
|
||||
}
|
||||
120
blocksuite/affine/blocks/block-callout/src/callout-block.ts
Normal file
120
blocksuite/affine/blocks/block-callout/src/callout-block.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
|
||||
import { createLitPortal } from '@blocksuite/affine-components/portal';
|
||||
import { type CalloutBlockModel } from '@blocksuite/affine-model';
|
||||
import { DefaultInlineManagerExtension } from '@blocksuite/affine-rich-text';
|
||||
import { NOTE_SELECTOR } from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
DocModeProvider,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import type { BlockComponent } from '@blocksuite/block-std';
|
||||
import { flip, offset } from '@floating-ui/dom';
|
||||
import { css, html } from 'lit';
|
||||
import { query } from 'lit/decorators.js';
|
||||
|
||||
export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockModel> {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.affine-callout-block-container {
|
||||
display: flex;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
background-color: ${unsafeCSSVarV2('block/callout/background/grey')};
|
||||
}
|
||||
|
||||
.affine-callout-emoji-container {
|
||||
margin-right: 12px;
|
||||
margin-top: 10px;
|
||||
user-select: none;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
.affine-callout-emoji:hover {
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.affine-callout-children {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
private _emojiMenuAbortController: AbortController | null = null;
|
||||
private readonly _toggleEmojiMenu = () => {
|
||||
if (this._emojiMenuAbortController) {
|
||||
this._emojiMenuAbortController.abort();
|
||||
}
|
||||
this._emojiMenuAbortController = new AbortController();
|
||||
|
||||
const theme = this.std.get(ThemeProvider).theme$.value;
|
||||
|
||||
createLitPortal({
|
||||
template: html`<affine-emoji-menu
|
||||
.theme=${theme}
|
||||
.onEmojiSelect=${(data: any) => {
|
||||
this.model.emoji = data.native;
|
||||
}}
|
||||
></affine-emoji-menu>`,
|
||||
portalStyles: {
|
||||
zIndex: 'var(--affine-z-index-popover)',
|
||||
},
|
||||
container: this.host,
|
||||
computePosition: {
|
||||
referenceElement: this._emojiButton,
|
||||
placement: 'bottom-start',
|
||||
middleware: [flip(), offset(4)],
|
||||
autoUpdate: { animationFrame: true },
|
||||
},
|
||||
abortController: this._emojiMenuAbortController,
|
||||
closeOnClickAway: true,
|
||||
});
|
||||
};
|
||||
|
||||
get attributeRenderer() {
|
||||
return this.inlineManager.getRenderer();
|
||||
}
|
||||
|
||||
get attributesSchema() {
|
||||
return this.inlineManager.getSchema();
|
||||
}
|
||||
|
||||
get embedChecker() {
|
||||
return this.inlineManager.embedChecker;
|
||||
}
|
||||
|
||||
get inlineManager() {
|
||||
return this.std.get(DefaultInlineManagerExtension.identifier);
|
||||
}
|
||||
|
||||
@query('.affine-callout-emoji')
|
||||
private accessor _emojiButton!: HTMLElement;
|
||||
|
||||
override get topContenteditableElement() {
|
||||
if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') {
|
||||
return this.closest<BlockComponent>(NOTE_SELECTOR);
|
||||
}
|
||||
return this.rootComponent;
|
||||
}
|
||||
|
||||
override renderBlock() {
|
||||
return html`
|
||||
<div class="affine-callout-block-container">
|
||||
<div
|
||||
@click=${this._toggleEmojiMenu}
|
||||
contenteditable="false"
|
||||
class="affine-callout-emoji-container"
|
||||
>
|
||||
<span class="affine-callout-emoji">${this.model.emoji}</span>
|
||||
</div>
|
||||
<div class="affine-callout-children">
|
||||
${this.renderChildren(this.model)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
34
blocksuite/affine/blocks/block-callout/src/callout-keymap.ts
Normal file
34
blocksuite/affine/blocks/block-callout/src/callout-keymap.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { CalloutBlockModel } from '@blocksuite/affine-model';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
BlockSelection,
|
||||
KeymapExtension,
|
||||
TextSelection,
|
||||
} from '@blocksuite/block-std';
|
||||
|
||||
export const CalloutKeymapExtension = KeymapExtension(std => {
|
||||
return {
|
||||
Backspace: ctx => {
|
||||
const text = std.selection.find(TextSelection);
|
||||
if (text && text.isCollapsed() && text.from.index === 0) {
|
||||
const event = ctx.get('defaultState').event;
|
||||
event.preventDefault();
|
||||
|
||||
const block = std.store.getBlock(text.from.blockId);
|
||||
if (!block) return false;
|
||||
const parent = std.store.getParent(block.model);
|
||||
if (!parent) return false;
|
||||
if (!matchModels(parent, [CalloutBlockModel])) return false;
|
||||
|
||||
std.selection.setGroup('note', [
|
||||
std.selection.create(BlockSelection, {
|
||||
blockId: parent.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
});
|
||||
14
blocksuite/affine/blocks/block-callout/src/callout-spec.ts
Normal file
14
blocksuite/affine/blocks/block-callout/src/callout-spec.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { SlashMenuConfigExtension } from '@blocksuite/affine-widget-slash-menu';
|
||||
import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import { literal } from 'lit/static-html.js';
|
||||
|
||||
import { CalloutKeymapExtension } from './callout-keymap';
|
||||
import { calloutSlashMenuConfig } from './configs/slash-menu';
|
||||
|
||||
export const CalloutBlockSpec: ExtensionType[] = [
|
||||
FlavourExtension('affine:callout'),
|
||||
BlockViewExtension('affine:callout', literal`affine-callout`),
|
||||
CalloutKeymapExtension,
|
||||
SlashMenuConfigExtension('affine:callout', calloutSlashMenuConfig),
|
||||
];
|
||||
@@ -0,0 +1,56 @@
|
||||
import { CalloutBlockModel } from '@blocksuite/affine-model';
|
||||
import { focusBlockEnd } from '@blocksuite/affine-shared/commands';
|
||||
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
findAncestorModel,
|
||||
isInsideBlockByFlavour,
|
||||
matchModels,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import { type SlashMenuConfig } from '@blocksuite/affine-widget-slash-menu';
|
||||
import { FontIcon } from '@blocksuite/icons/lit';
|
||||
|
||||
export const calloutSlashMenuConfig: SlashMenuConfig = {
|
||||
disableWhen: ({ model }) => {
|
||||
return (
|
||||
findAncestorModel(model, ancestor =>
|
||||
matchModels(ancestor, [CalloutBlockModel])
|
||||
) !== null
|
||||
);
|
||||
},
|
||||
items: [
|
||||
{
|
||||
name: 'Callout',
|
||||
description: 'Let your words stand out.',
|
||||
icon: FontIcon(),
|
||||
searchAlias: ['callout'],
|
||||
group: '0_Basic@9',
|
||||
when: ({ std, model }) => {
|
||||
return (
|
||||
std.get(FeatureFlagService).getFlag('enable_callout') &&
|
||||
!isInsideBlockByFlavour(model.doc, model, 'affine:edgeless-text')
|
||||
);
|
||||
},
|
||||
action: ({ model, std }) => {
|
||||
const { doc } = model;
|
||||
const parent = doc.getParent(model);
|
||||
if (!parent) return;
|
||||
|
||||
const index = parent.children.indexOf(model);
|
||||
if (index === -1) return;
|
||||
const calloutId = doc.addBlock('affine:callout', {}, parent, index + 1);
|
||||
if (!calloutId) return;
|
||||
const paragraphId = doc.addBlock('affine:paragraph', {}, calloutId);
|
||||
if (!paragraphId) return;
|
||||
std.host.updateComplete
|
||||
.then(() => {
|
||||
const paragraph = std.view.getBlock(paragraphId);
|
||||
if (!paragraph) return;
|
||||
std.command.exec(focusBlockEnd, {
|
||||
focusBlock: paragraph,
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
14
blocksuite/affine/blocks/block-callout/src/effects.ts
Normal file
14
blocksuite/affine/blocks/block-callout/src/effects.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { CalloutBlockComponent } from './callout-block';
|
||||
import { EmojiMenu } from './emoji-menu';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('affine-callout', CalloutBlockComponent);
|
||||
customElements.define('affine-emoji-menu', EmojiMenu);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-callout': CalloutBlockComponent;
|
||||
'affine-emoji-menu': EmojiMenu;
|
||||
}
|
||||
}
|
||||
34
blocksuite/affine/blocks/block-callout/src/emoji-menu.ts
Normal file
34
blocksuite/affine/blocks/block-callout/src/emoji-menu.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import data from '@emoji-mart/data';
|
||||
import { Picker } from 'emoji-mart';
|
||||
import { html, LitElement, type PropertyValues } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
|
||||
export class EmojiMenu extends WithDisposable(LitElement) {
|
||||
override firstUpdated(props: PropertyValues) {
|
||||
const result = super.firstUpdated(props);
|
||||
|
||||
const picker = new Picker({
|
||||
data,
|
||||
onEmojiSelect: this.onEmojiSelect,
|
||||
autoFocus: true,
|
||||
theme: this.theme,
|
||||
});
|
||||
this.emojiMenu.append(picker as unknown as Node);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onEmojiSelect: (data: any) => void = () => {};
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor theme: 'light' | 'dark' = 'light';
|
||||
|
||||
@query('.affine-emoji-menu')
|
||||
accessor emojiMenu!: HTMLElement;
|
||||
|
||||
override render() {
|
||||
return html`<div class="affine-emoji-menu"></div>`;
|
||||
}
|
||||
}
|
||||
3
blocksuite/affine/blocks/block-callout/src/index.ts
Normal file
3
blocksuite/affine/blocks/block-callout/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './callout-block.js';
|
||||
export * from './callout-spec.js';
|
||||
export * from './effects.js';
|
||||
20
blocksuite/affine/blocks/block-callout/tsconfig.json
Normal file
20
blocksuite/affine/blocks/block-callout/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../../components" },
|
||||
{ "path": "../../model" },
|
||||
{ "path": "../../rich-text" },
|
||||
{ "path": "../../shared" },
|
||||
{ "path": "../../widgets/widget-slash-menu" },
|
||||
{ "path": "../../../framework/block-std" },
|
||||
{ "path": "../../../framework/global" },
|
||||
{ "path": "../../../framework/inline" },
|
||||
{ "path": "../../../framework/store" }
|
||||
]
|
||||
}
|
||||
47
blocksuite/affine/blocks/block-code/package.json
Normal file
47
blocksuite/affine/blocks/block-code/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@blocksuite/affine-block-code",
|
||||
"description": "Code block for BlockSuite.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test:unit": "nx vite:test --run --passWithNoTests",
|
||||
"test:unit:coverage": "nx vite:test --run --coverage",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||
"@blocksuite/block-std": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.3",
|
||||
"@blocksuite/inline": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.12",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"lit": "^3.2.0",
|
||||
"minimatch": "^10.0.1",
|
||||
"shiki": "^3.0.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./effects": "./src/effects.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.20.0"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { CodeBlockHtmlAdapterExtension } from './html.js';
|
||||
import { CodeBlockMarkdownAdapterExtension } from './markdown.js';
|
||||
import { CodeBlockNotionHtmlAdapterExtension } from './notion-html.js';
|
||||
import { CodeBlockPlainTextAdapterExtension } from './plain-text.js';
|
||||
|
||||
export const CodeBlockAdapterExtensions: ExtensionType[] = [
|
||||
CodeBlockHtmlAdapterExtension,
|
||||
CodeBlockMarkdownAdapterExtension,
|
||||
CodeBlockPlainTextAdapterExtension,
|
||||
CodeBlockNotionHtmlAdapterExtension,
|
||||
];
|
||||
96
blocksuite/affine/blocks/block-code/src/adapters/html.ts
Normal file
96
blocksuite/affine/blocks/block-code/src/adapters/html.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { CodeBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockHtmlAdapterExtension,
|
||||
type BlockHtmlAdapterMatcher,
|
||||
CODE_BLOCK_WRAP_KEY,
|
||||
HastUtils,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { DeltaInsert } from '@blocksuite/inline';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
import { bundledLanguagesInfo, codeToHast } from 'shiki';
|
||||
|
||||
export const codeBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
|
||||
flavour: CodeBlockSchema.model.flavour,
|
||||
toMatch: o => HastUtils.isElement(o.node) && o.node.tagName === 'pre',
|
||||
fromMatch: o => o.node.flavour === 'affine:code',
|
||||
toBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
if (!HastUtils.isElement(o.node)) {
|
||||
return;
|
||||
}
|
||||
const code = HastUtils.querySelector(o.node, 'code');
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
|
||||
const codeText =
|
||||
code.children.length === 1 && code.children[0].type === 'text'
|
||||
? code.children[0]
|
||||
: { ...code, tagName: 'div' };
|
||||
let codeLang = Array.isArray(code.properties?.className)
|
||||
? code.properties.className.find(
|
||||
className =>
|
||||
typeof className === 'string' && className.startsWith('code-')
|
||||
)
|
||||
: undefined;
|
||||
codeLang =
|
||||
typeof codeLang === 'string'
|
||||
? codeLang.replace('code-', '')
|
||||
: undefined;
|
||||
|
||||
const { walkerContext, deltaConverter, configs } = context;
|
||||
const wrap = configs.get(CODE_BLOCK_WRAP_KEY) === 'true';
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: 'affine:code',
|
||||
props: {
|
||||
language: codeLang ?? 'Plain Text',
|
||||
wrap,
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: deltaConverter.astToDelta(codeText, {
|
||||
trim: false,
|
||||
pre: true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode();
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
fromBlockSnapshot: {
|
||||
enter: async (o, context) => {
|
||||
const { walkerContext } = context;
|
||||
const rawLang = o.node.props.language as string | null;
|
||||
const matchedLang = rawLang
|
||||
? (bundledLanguagesInfo.find(
|
||||
info =>
|
||||
info.id === rawLang ||
|
||||
info.name === rawLang ||
|
||||
info.aliases?.includes(rawLang)
|
||||
)?.id ?? 'text')
|
||||
: 'text';
|
||||
|
||||
const text = (o.node.props.text as Record<string, unknown>)
|
||||
.delta as DeltaInsert[];
|
||||
const code = text.map(delta => delta.insert).join('');
|
||||
const hast = await codeToHast(code, {
|
||||
lang: matchedLang,
|
||||
theme: 'light-plus',
|
||||
});
|
||||
|
||||
walkerContext.openNode(hast as never, 'children').closeNode();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CodeBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
|
||||
codeBlockHtmlAdapterMatcher
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './html.js';
|
||||
export * from './markdown.js';
|
||||
export * from './notion-html.js';
|
||||
export * from './plain-text.js';
|
||||
73
blocksuite/affine/blocks/block-code/src/adapters/markdown.ts
Normal file
73
blocksuite/affine/blocks/block-code/src/adapters/markdown.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { CodeBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockMarkdownAdapterExtension,
|
||||
type BlockMarkdownAdapterMatcher,
|
||||
CODE_BLOCK_WRAP_KEY,
|
||||
type MarkdownAST,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { DeltaInsert } from '@blocksuite/inline';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
import type { Code } from 'mdast';
|
||||
|
||||
const isCodeNode = (node: MarkdownAST): node is Code => node.type === 'code';
|
||||
|
||||
export const codeBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher = {
|
||||
flavour: CodeBlockSchema.model.flavour,
|
||||
toMatch: o => isCodeNode(o.node),
|
||||
fromMatch: o => o.node.flavour === 'affine:code',
|
||||
toBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
if (!isCodeNode(o.node)) {
|
||||
return;
|
||||
}
|
||||
const { walkerContext, configs } = context;
|
||||
const wrap = configs.get(CODE_BLOCK_WRAP_KEY) === 'true';
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: 'affine:code',
|
||||
props: {
|
||||
language: o.node.lang ?? 'Plain Text',
|
||||
wrap,
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: [
|
||||
{
|
||||
insert: o.node.value,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode();
|
||||
},
|
||||
},
|
||||
fromBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const text = (o.node.props.text ?? { delta: [] }) as {
|
||||
delta: DeltaInsert[];
|
||||
};
|
||||
const { walkerContext } = context;
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'code',
|
||||
lang: (o.node.props.language as string) ?? null,
|
||||
meta: null,
|
||||
value: text.delta.map(delta => delta.insert).join(''),
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CodeBlockMarkdownAdapterExtension = BlockMarkdownAdapterExtension(
|
||||
codeBlockMarkdownAdapterMatcher
|
||||
);
|
||||
@@ -0,0 +1,59 @@
|
||||
import { CodeBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockNotionHtmlAdapterExtension,
|
||||
type BlockNotionHtmlAdapterMatcher,
|
||||
CODE_BLOCK_WRAP_KEY,
|
||||
HastUtils,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
|
||||
export const codeBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher =
|
||||
{
|
||||
flavour: CodeBlockSchema.model.flavour,
|
||||
toMatch: o => HastUtils.isElement(o.node) && o.node.tagName === 'pre',
|
||||
fromMatch: () => false,
|
||||
toBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
if (!HastUtils.isElement(o.node)) {
|
||||
return;
|
||||
}
|
||||
const code = HastUtils.querySelector(o.node, 'code');
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
const { walkerContext, deltaConverter, configs } = context;
|
||||
const wrap = configs.get(CODE_BLOCK_WRAP_KEY) === 'true';
|
||||
const codeText =
|
||||
code.children.length === 1 && code.children[0].type === 'text'
|
||||
? code.children[0]
|
||||
: { ...code, tag: 'div' };
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: CodeBlockSchema.model.flavour,
|
||||
props: {
|
||||
language: 'Plain Text',
|
||||
wrap,
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: deltaConverter.astToDelta(codeText, {
|
||||
trim: false,
|
||||
pre: true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode();
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
fromBlockSnapshot: {},
|
||||
};
|
||||
|
||||
export const CodeBlockNotionHtmlAdapterExtension =
|
||||
BlockNotionHtmlAdapterExtension(codeBlockNotionHtmlAdapterMatcher);
|
||||
@@ -0,0 +1,26 @@
|
||||
import { CodeBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockPlainTextAdapterExtension,
|
||||
type BlockPlainTextAdapterMatcher,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { DeltaInsert } from '@blocksuite/inline';
|
||||
|
||||
export const codeBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher = {
|
||||
flavour: CodeBlockSchema.model.flavour,
|
||||
toMatch: () => false,
|
||||
fromMatch: o => o.node.flavour === CodeBlockSchema.model.flavour,
|
||||
toBlockSnapshot: {},
|
||||
fromBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const text = (o.node.props.text ?? { delta: [] }) as {
|
||||
delta: DeltaInsert[];
|
||||
};
|
||||
const buffer = text.delta.map(delta => delta.insert).join('');
|
||||
context.textBuffer.content += buffer;
|
||||
context.textBuffer.content += '\n';
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CodeBlockPlainTextAdapterExtension =
|
||||
BlockPlainTextAdapterExtension(codeBlockPlainTextAdapterMatcher);
|
||||
106
blocksuite/affine/blocks/block-code/src/clipboard/index.ts
Normal file
106
blocksuite/affine/blocks/block-code/src/clipboard/index.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { deleteTextCommand } from '@blocksuite/affine-rich-text';
|
||||
import {
|
||||
HtmlAdapter,
|
||||
pasteMiddleware,
|
||||
PlainTextAdapter,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import {
|
||||
getBlockIndexCommand,
|
||||
getBlockSelectionsCommand,
|
||||
getTextSelectionCommand,
|
||||
} from '@blocksuite/affine-shared/commands';
|
||||
import {
|
||||
type BlockComponent,
|
||||
Clipboard,
|
||||
type UIEventHandler,
|
||||
} from '@blocksuite/block-std';
|
||||
import { DisposableGroup } from '@blocksuite/global/slot';
|
||||
|
||||
export class CodeClipboardController {
|
||||
private _clipboard!: Clipboard;
|
||||
|
||||
protected _disposables = new DisposableGroup();
|
||||
|
||||
protected _init = () => {
|
||||
this._clipboard.registerAdapter('text/plain', PlainTextAdapter, 90);
|
||||
this._clipboard.registerAdapter('text/html', HtmlAdapter, 80);
|
||||
const paste = pasteMiddleware(this._std);
|
||||
this._clipboard.use(paste);
|
||||
|
||||
this._disposables.add({
|
||||
dispose: () => {
|
||||
this._clipboard.unregisterAdapter('text/plain');
|
||||
this._clipboard.unregisterAdapter('text/html');
|
||||
this._clipboard.unuse(paste);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
host: BlockComponent;
|
||||
|
||||
onPagePaste: UIEventHandler = ctx => {
|
||||
const e = ctx.get('clipboardState').raw;
|
||||
e.preventDefault();
|
||||
|
||||
this._std.store.captureSync();
|
||||
this._std.command
|
||||
.chain()
|
||||
.try(cmd => [
|
||||
cmd.pipe(getTextSelectionCommand).pipe((ctx, next) => {
|
||||
const textSelection = ctx.currentTextSelection;
|
||||
if (!textSelection) return;
|
||||
const end = textSelection.to ?? textSelection.from;
|
||||
next({ currentSelectionPath: end.blockId });
|
||||
}),
|
||||
cmd.pipe(getBlockSelectionsCommand).pipe((ctx, next) => {
|
||||
const currentBlockSelections = ctx.currentBlockSelections;
|
||||
if (!currentBlockSelections) return;
|
||||
const blockSelection = currentBlockSelections.at(-1);
|
||||
if (!blockSelection) return;
|
||||
next({ currentSelectionPath: blockSelection.blockId });
|
||||
}),
|
||||
])
|
||||
.pipe(getBlockIndexCommand)
|
||||
.try(cmd => [cmd.pipe(getTextSelectionCommand).pipe(deleteTextCommand)])
|
||||
.pipe((ctx, next) => {
|
||||
if (!ctx.parentBlock) {
|
||||
return;
|
||||
}
|
||||
this._clipboard
|
||||
.paste(
|
||||
e,
|
||||
this._std.store,
|
||||
ctx.parentBlock.model.id,
|
||||
ctx.blockIndex ? ctx.blockIndex + 1 : 1
|
||||
)
|
||||
.catch(console.error);
|
||||
|
||||
return next();
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
};
|
||||
|
||||
private get _std() {
|
||||
return this.host.std;
|
||||
}
|
||||
|
||||
constructor(host: BlockComponent) {
|
||||
this.host = host;
|
||||
}
|
||||
|
||||
hostConnected() {
|
||||
if (this._disposables.disposed) {
|
||||
this._disposables = new DisposableGroup();
|
||||
}
|
||||
if (navigator.clipboard) {
|
||||
this._clipboard = new Clipboard(this._std);
|
||||
this.host.handleEvent('paste', this.onPagePaste);
|
||||
this._init();
|
||||
}
|
||||
}
|
||||
|
||||
hostDisconnected() {
|
||||
this._disposables.dispose();
|
||||
}
|
||||
}
|
||||
19
blocksuite/affine/blocks/block-code/src/code-block-config.ts
Normal file
19
blocksuite/affine/blocks/block-code/src/code-block-config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ConfigExtensionFactory } from '@blocksuite/block-std';
|
||||
import type { BundledLanguageInfo, ThemeInput } from 'shiki';
|
||||
|
||||
export interface CodeBlockConfig {
|
||||
theme?: {
|
||||
dark?: ThemeInput;
|
||||
light?: ThemeInput;
|
||||
};
|
||||
langs?: BundledLanguageInfo[];
|
||||
|
||||
/**
|
||||
* Whether to show line numbers in the code block.
|
||||
* @default true
|
||||
*/
|
||||
showLineNumbers?: boolean;
|
||||
}
|
||||
|
||||
export const CodeBlockConfigExtension =
|
||||
ConfigExtensionFactory<CodeBlockConfig>('affine:code');
|
||||
41
blocksuite/affine/blocks/block-code/src/code-block-inline.ts
Normal file
41
blocksuite/affine/blocks/block-code/src/code-block-inline.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
BackgroundInlineSpecExtension,
|
||||
BoldInlineSpecExtension,
|
||||
CodeInlineSpecExtension,
|
||||
ColorInlineSpecExtension,
|
||||
InlineManagerExtension,
|
||||
InlineSpecExtension,
|
||||
ItalicInlineSpecExtension,
|
||||
LatexInlineSpecExtension,
|
||||
LinkInlineSpecExtension,
|
||||
StrikeInlineSpecExtension,
|
||||
UnderlineInlineSpecExtension,
|
||||
} from '@blocksuite/affine-rich-text';
|
||||
import { html } from 'lit';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CodeBlockUnitSpecExtension = InlineSpecExtension({
|
||||
name: 'code-block-unit',
|
||||
schema: z.undefined(),
|
||||
match: () => true,
|
||||
renderer: ({ delta }) => {
|
||||
return html`<affine-code-unit .delta=${delta}></affine-code-unit>`;
|
||||
},
|
||||
});
|
||||
|
||||
export const CodeBlockInlineManagerExtension = InlineManagerExtension({
|
||||
id: 'CodeBlockInlineManager',
|
||||
enableMarkdown: false,
|
||||
specs: [
|
||||
BoldInlineSpecExtension.identifier,
|
||||
ItalicInlineSpecExtension.identifier,
|
||||
UnderlineInlineSpecExtension.identifier,
|
||||
StrikeInlineSpecExtension.identifier,
|
||||
CodeInlineSpecExtension.identifier,
|
||||
BackgroundInlineSpecExtension.identifier,
|
||||
ColorInlineSpecExtension.identifier,
|
||||
LatexInlineSpecExtension.identifier,
|
||||
LinkInlineSpecExtension.identifier,
|
||||
CodeBlockUnitSpecExtension.identifier,
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { CodeBlockSchema, ColorScheme } from '@blocksuite/affine-model';
|
||||
import { textKeymap } from '@blocksuite/affine-rich-text';
|
||||
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { BlockService } from '@blocksuite/block-std';
|
||||
import { type Signal, signal } from '@preact/signals-core';
|
||||
import {
|
||||
bundledLanguagesInfo,
|
||||
createHighlighterCore,
|
||||
createOnigurumaEngine,
|
||||
type HighlighterCore,
|
||||
type MaybeGetter,
|
||||
} from 'shiki';
|
||||
import getWasm from 'shiki/wasm';
|
||||
|
||||
import { CodeBlockConfigExtension } from './code-block-config.js';
|
||||
import {
|
||||
CODE_BLOCK_DEFAULT_DARK_THEME,
|
||||
CODE_BLOCK_DEFAULT_LIGHT_THEME,
|
||||
} from './highlight/const.js';
|
||||
|
||||
export class CodeBlockService extends BlockService {
|
||||
static override readonly flavour = CodeBlockSchema.model.flavour;
|
||||
|
||||
private _darkThemeKey: string | undefined;
|
||||
|
||||
private _lightThemeKey: string | undefined;
|
||||
|
||||
highlighter$: Signal<HighlighterCore | null> = signal(null);
|
||||
|
||||
get langs() {
|
||||
return (
|
||||
this.std.getOptional(CodeBlockConfigExtension.identifier)?.langs ??
|
||||
bundledLanguagesInfo
|
||||
);
|
||||
}
|
||||
|
||||
get themeKey() {
|
||||
const theme = this.std.get(ThemeProvider).theme$.value;
|
||||
return theme === ColorScheme.Dark
|
||||
? this._darkThemeKey
|
||||
: this._lightThemeKey;
|
||||
}
|
||||
|
||||
override mounted(): void {
|
||||
super.mounted();
|
||||
|
||||
this.bindHotKey(textKeymap(this.std));
|
||||
|
||||
createHighlighterCore({
|
||||
engine: createOnigurumaEngine(() => getWasm),
|
||||
})
|
||||
.then(async highlighter => {
|
||||
const config = this.std.getOptional(
|
||||
CodeBlockConfigExtension.identifier
|
||||
);
|
||||
const darkTheme = config?.theme?.dark ?? CODE_BLOCK_DEFAULT_DARK_THEME;
|
||||
const lightTheme =
|
||||
config?.theme?.light ?? CODE_BLOCK_DEFAULT_LIGHT_THEME;
|
||||
|
||||
this._darkThemeKey = (await normalizeGetter(darkTheme)).name;
|
||||
this._lightThemeKey = (await normalizeGetter(lightTheme)).name;
|
||||
|
||||
await highlighter.loadTheme(darkTheme, lightTheme);
|
||||
|
||||
this.highlighter$.value = highlighter;
|
||||
|
||||
this.disposables.add(() => {
|
||||
highlighter.dispose();
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* https://github.com/shikijs/shiki/blob/933415cdc154fe74ccfb6bbb3eb6a7b7bf183e60/packages/core/src/internal.ts#L31
|
||||
*/
|
||||
export async function normalizeGetter<T>(p: MaybeGetter<T>): Promise<T> {
|
||||
return Promise.resolve(typeof p === 'function' ? (p as any)() : p).then(
|
||||
r => r.default || r
|
||||
);
|
||||
}
|
||||
34
blocksuite/affine/blocks/block-code/src/code-block-spec.ts
Normal file
34
blocksuite/affine/blocks/block-code/src/code-block-spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { SlashMenuConfigExtension } from '@blocksuite/affine-widget-slash-menu';
|
||||
import {
|
||||
BlockViewExtension,
|
||||
FlavourExtension,
|
||||
WidgetViewExtension,
|
||||
} from '@blocksuite/block-std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import { literal, unsafeStatic } from 'lit/static-html.js';
|
||||
|
||||
import { CodeBlockAdapterExtensions } from './adapters/extension.js';
|
||||
import {
|
||||
CodeBlockInlineManagerExtension,
|
||||
CodeBlockUnitSpecExtension,
|
||||
} from './code-block-inline.js';
|
||||
import { CodeBlockService } from './code-block-service.js';
|
||||
import { AFFINE_CODE_TOOLBAR_WIDGET } from './code-toolbar/index.js';
|
||||
import { codeSlashMenuConfig } from './configs/slash-menu.js';
|
||||
|
||||
export const codeToolbarWidget = WidgetViewExtension(
|
||||
'affine:code',
|
||||
AFFINE_CODE_TOOLBAR_WIDGET,
|
||||
literal`${unsafeStatic(AFFINE_CODE_TOOLBAR_WIDGET)}`
|
||||
);
|
||||
|
||||
export const CodeBlockSpec: ExtensionType[] = [
|
||||
FlavourExtension('affine:code'),
|
||||
CodeBlockService,
|
||||
BlockViewExtension('affine:code', literal`affine-code`),
|
||||
codeToolbarWidget,
|
||||
CodeBlockInlineManagerExtension,
|
||||
CodeBlockUnitSpecExtension,
|
||||
CodeBlockAdapterExtensions,
|
||||
SlashMenuConfigExtension('affine:code', codeSlashMenuConfig),
|
||||
].flat();
|
||||
445
blocksuite/affine/blocks/block-code/src/code-block.ts
Normal file
445
blocksuite/affine/blocks/block-code/src/code-block.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
|
||||
import type { CodeBlockModel } from '@blocksuite/affine-model';
|
||||
import { focusTextModel, type RichText } from '@blocksuite/affine-rich-text';
|
||||
import { BRACKET_PAIRS, NOTE_SELECTOR } from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
DocModeProvider,
|
||||
NotificationProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { getViewportElement } from '@blocksuite/affine-shared/utils';
|
||||
import type { BlockComponent } from '@blocksuite/block-std';
|
||||
import {
|
||||
BlockSelection,
|
||||
getInlineRangeProvider,
|
||||
TextSelection,
|
||||
} from '@blocksuite/block-std';
|
||||
import { IS_MAC, IS_MOBILE } from '@blocksuite/global/env';
|
||||
import { noop } from '@blocksuite/global/utils';
|
||||
import {
|
||||
INLINE_ROOT_ATTR,
|
||||
type InlineRangeProvider,
|
||||
type InlineRootElement,
|
||||
type VLine,
|
||||
} from '@blocksuite/inline';
|
||||
import { Slice } from '@blocksuite/store';
|
||||
import { computed, effect, type Signal, signal } from '@preact/signals-core';
|
||||
import { html, nothing, type TemplateResult } from 'lit';
|
||||
import { query } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import type { ThemedToken } from 'shiki';
|
||||
|
||||
import { CodeClipboardController } from './clipboard/index.js';
|
||||
import { CodeBlockConfigExtension } from './code-block-config.js';
|
||||
import { CodeBlockInlineManagerExtension } from './code-block-inline.js';
|
||||
import type { CodeBlockService } from './code-block-service.js';
|
||||
import { codeBlockStyles } from './styles.js';
|
||||
|
||||
export class CodeBlockComponent extends CaptionedBlockComponent<
|
||||
CodeBlockModel,
|
||||
CodeBlockService
|
||||
> {
|
||||
static override styles = codeBlockStyles;
|
||||
|
||||
private _inlineRangeProvider: InlineRangeProvider | null = null;
|
||||
|
||||
clipboardController = new CodeClipboardController(this);
|
||||
|
||||
highlightTokens$: Signal<ThemedToken[][]> = signal([]);
|
||||
|
||||
languageName$: Signal<string> = computed(() => {
|
||||
const lang = this.model.language$.value;
|
||||
if (lang === null) {
|
||||
return 'Plain Text';
|
||||
}
|
||||
|
||||
const matchedInfo = this.service.langs.find(info => info.id === lang);
|
||||
return matchedInfo ? matchedInfo.name : 'Plain Text';
|
||||
});
|
||||
|
||||
get inlineEditor() {
|
||||
const inlineRoot = this.querySelector<InlineRootElement>(
|
||||
`[${INLINE_ROOT_ATTR}]`
|
||||
);
|
||||
return inlineRoot?.inlineEditor;
|
||||
}
|
||||
|
||||
get inlineManager() {
|
||||
return this.std.get(CodeBlockInlineManagerExtension.identifier);
|
||||
}
|
||||
|
||||
get notificationService() {
|
||||
return this.std.getOptional(NotificationProvider);
|
||||
}
|
||||
|
||||
get readonly() {
|
||||
return this.doc.readonly;
|
||||
}
|
||||
|
||||
override get topContenteditableElement() {
|
||||
if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') {
|
||||
return this.closest<BlockComponent>(NOTE_SELECTOR);
|
||||
}
|
||||
return this.rootComponent;
|
||||
}
|
||||
|
||||
private _updateHighlightTokens() {
|
||||
const modelLang = this.model.language$.value;
|
||||
if (modelLang === null) {
|
||||
this.highlightTokens$.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedInfo = this.service.langs.find(
|
||||
info =>
|
||||
info.id === modelLang ||
|
||||
info.name === modelLang ||
|
||||
info.aliases?.includes(modelLang)
|
||||
);
|
||||
|
||||
if (matchedInfo) {
|
||||
this.model.language$.value = matchedInfo.id;
|
||||
const langImport = matchedInfo.import;
|
||||
const lang = matchedInfo.id;
|
||||
|
||||
const highlighter = this.service.highlighter$.value;
|
||||
const theme = this.service.themeKey;
|
||||
if (!theme || !highlighter) {
|
||||
this.highlightTokens$.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
noop(this.model.text.deltas$.value);
|
||||
const code = this.model.text.toString();
|
||||
|
||||
const loadedLanguages = highlighter.getLoadedLanguages();
|
||||
if (!loadedLanguages.includes(lang)) {
|
||||
highlighter
|
||||
.loadLanguage(langImport)
|
||||
.then(() => {
|
||||
this.highlightTokens$.value = highlighter.codeToTokensBase(code, {
|
||||
lang,
|
||||
theme,
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
} else {
|
||||
this.highlightTokens$.value = highlighter.codeToTokensBase(code, {
|
||||
lang,
|
||||
theme,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.highlightTokens$.value = [];
|
||||
// clear language if not found
|
||||
this.model.language$.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
// set highlight options getter used by "exportToHtml"
|
||||
this.clipboardController.hostConnected();
|
||||
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
this._updateHighlightTokens();
|
||||
})
|
||||
);
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
noop(this.highlightTokens$.value);
|
||||
this._richTextElement?.inlineEditor?.render();
|
||||
})
|
||||
);
|
||||
|
||||
const selectionManager = this.host.selection;
|
||||
const INDENT_SYMBOL = ' ';
|
||||
const LINE_BREAK_SYMBOL = '\n';
|
||||
const allIndexOf = (
|
||||
text: string,
|
||||
symbol: string,
|
||||
start = 0,
|
||||
end = text.length
|
||||
) => {
|
||||
const indexArr: number[] = [];
|
||||
let i = start;
|
||||
|
||||
while (i < end) {
|
||||
const index = text.indexOf(symbol, i);
|
||||
if (index === -1 || index > end) {
|
||||
break;
|
||||
}
|
||||
indexArr.push(index);
|
||||
i = index + 1;
|
||||
}
|
||||
return indexArr;
|
||||
};
|
||||
|
||||
// TODO: move to service for better performance
|
||||
this.bindHotKey({
|
||||
Backspace: ctx => {
|
||||
const event = ctx.get('defaultState').event;
|
||||
const textSelection = selectionManager.find(TextSelection);
|
||||
if (!textSelection) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const from = textSelection.from;
|
||||
|
||||
if (from.index === 0 && from.length === 0) {
|
||||
event.preventDefault();
|
||||
selectionManager.setGroup('note', [
|
||||
selectionManager.create(BlockSelection, { blockId: this.blockId }),
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
const inlineEditor = this.inlineEditor;
|
||||
const inlineRange = inlineEditor?.getInlineRange();
|
||||
if (!inlineRange || !inlineEditor) return;
|
||||
const left = inlineEditor.yText.toString()[inlineRange.index - 1];
|
||||
const right = inlineEditor.yText.toString()[inlineRange.index];
|
||||
const leftBrackets = BRACKET_PAIRS.map(pair => pair.left);
|
||||
if (BRACKET_PAIRS[leftBrackets.indexOf(left)]?.right === right) {
|
||||
const index = inlineRange.index - 1;
|
||||
inlineEditor.deleteText({
|
||||
index: index,
|
||||
length: 2,
|
||||
});
|
||||
inlineEditor.setInlineRange({
|
||||
index: index,
|
||||
length: 0,
|
||||
});
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
|
||||
return;
|
||||
},
|
||||
Tab: ctx => {
|
||||
if (this.doc.readonly) return;
|
||||
const state = ctx.get('keyboardState');
|
||||
const event = state.raw;
|
||||
const inlineEditor = this.inlineEditor;
|
||||
if (!inlineEditor) return;
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
if (inlineRange) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
const text = this.inlineEditor.yText.toString();
|
||||
const index = text.lastIndexOf(
|
||||
LINE_BREAK_SYMBOL,
|
||||
inlineRange.index - 1
|
||||
);
|
||||
const indexArr = allIndexOf(
|
||||
text,
|
||||
LINE_BREAK_SYMBOL,
|
||||
inlineRange.index,
|
||||
inlineRange.index + inlineRange.length
|
||||
)
|
||||
.map(i => i + 1)
|
||||
.reverse();
|
||||
if (index !== -1) {
|
||||
indexArr.push(index + 1);
|
||||
} else {
|
||||
indexArr.push(0);
|
||||
}
|
||||
indexArr.forEach(i => {
|
||||
if (!this.inlineEditor) return;
|
||||
this.inlineEditor.insertText(
|
||||
{
|
||||
index: i,
|
||||
length: 0,
|
||||
},
|
||||
INDENT_SYMBOL
|
||||
);
|
||||
});
|
||||
this.inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + 2,
|
||||
length:
|
||||
inlineRange.length + (indexArr.length - 1) * INDENT_SYMBOL.length,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return;
|
||||
},
|
||||
'Shift-Tab': ctx => {
|
||||
const state = ctx.get('keyboardState');
|
||||
const event = state.raw;
|
||||
const inlineEditor = this.inlineEditor;
|
||||
if (!inlineEditor) return;
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
if (inlineRange) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
const text = this.inlineEditor.yText.toString();
|
||||
const index = text.lastIndexOf(
|
||||
LINE_BREAK_SYMBOL,
|
||||
inlineRange.index - 1
|
||||
);
|
||||
let indexArr = allIndexOf(
|
||||
text,
|
||||
LINE_BREAK_SYMBOL,
|
||||
inlineRange.index,
|
||||
inlineRange.index + inlineRange.length
|
||||
)
|
||||
.map(i => i + 1)
|
||||
.reverse();
|
||||
if (index !== -1) {
|
||||
indexArr.push(index + 1);
|
||||
} else {
|
||||
indexArr.push(0);
|
||||
}
|
||||
indexArr = indexArr.filter(
|
||||
i => text.slice(i, i + 2) === INDENT_SYMBOL
|
||||
);
|
||||
indexArr.forEach(i => {
|
||||
if (!this.inlineEditor) return;
|
||||
this.inlineEditor.deleteText({
|
||||
index: i,
|
||||
length: 2,
|
||||
});
|
||||
});
|
||||
if (indexArr.length > 0) {
|
||||
this.inlineEditor.setInlineRange({
|
||||
index:
|
||||
inlineRange.index -
|
||||
(indexArr[indexArr.length - 1] < inlineRange.index ? 2 : 0),
|
||||
length:
|
||||
inlineRange.length -
|
||||
(indexArr.length - 1) * INDENT_SYMBOL.length,
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return;
|
||||
},
|
||||
'Control-d': () => {
|
||||
if (!IS_MAC) return;
|
||||
return true;
|
||||
},
|
||||
Delete: () => {
|
||||
return true;
|
||||
},
|
||||
Enter: () => {
|
||||
this.doc.captureSync();
|
||||
return true;
|
||||
},
|
||||
'Mod-Enter': () => {
|
||||
const { model, std } = this;
|
||||
if (!model || !std) return;
|
||||
const inlineEditor = this.inlineEditor;
|
||||
const inlineRange = inlineEditor?.getInlineRange();
|
||||
if (!inlineRange || !inlineEditor) return;
|
||||
const isEnd = model.text.length === inlineRange.index;
|
||||
if (!isEnd) return;
|
||||
const parent = this.doc.getParent(model);
|
||||
if (!parent) return;
|
||||
const index = parent.children.indexOf(model);
|
||||
if (index === -1) return;
|
||||
const id = this.doc.addBlock('affine:paragraph', {}, parent, index + 1);
|
||||
focusTextModel(std, id);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
this._inlineRangeProvider = getInlineRangeProvider(this);
|
||||
}
|
||||
|
||||
copyCode() {
|
||||
const model = this.model;
|
||||
const slice = Slice.fromModels(model.doc, [model]);
|
||||
this.std.clipboard
|
||||
.copySlice(slice)
|
||||
.then(() => {
|
||||
this.notificationService?.toast('Copied to clipboard');
|
||||
})
|
||||
.catch(e => {
|
||||
this.notificationService?.toast('Copied failed, something went wrong');
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.clipboardController.hostDisconnected();
|
||||
}
|
||||
|
||||
override async getUpdateComplete() {
|
||||
const result = await super.getUpdateComplete();
|
||||
await this._richTextElement?.updateComplete;
|
||||
return result;
|
||||
}
|
||||
|
||||
override renderBlock(): TemplateResult<1> {
|
||||
const showLineNumbers =
|
||||
this.std.getOptional(CodeBlockConfigExtension.identifier)
|
||||
?.showLineNumbers ?? true;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class=${classMap({
|
||||
'affine-code-block-container': true,
|
||||
mobile: IS_MOBILE,
|
||||
wrap: this.model.wrap,
|
||||
})}
|
||||
>
|
||||
<rich-text
|
||||
.yText=${this.model.text.yText}
|
||||
.inlineEventSource=${this.topContenteditableElement ?? nothing}
|
||||
.undoManager=${this.doc.history}
|
||||
.attributesSchema=${this.inlineManager.getSchema()}
|
||||
.attributeRenderer=${this.inlineManager.getRenderer()}
|
||||
.readonly=${this.doc.readonly}
|
||||
.inlineRangeProvider=${this._inlineRangeProvider}
|
||||
.enableClipboard=${false}
|
||||
.enableUndoRedo=${false}
|
||||
.wrapText=${this.model.wrap}
|
||||
.verticalScrollContainerGetter=${() => getViewportElement(this.host)}
|
||||
.vLineRenderer=${showLineNumbers
|
||||
? (vLine: VLine) => {
|
||||
return html`
|
||||
<span contenteditable="false" class="line-number"
|
||||
>${vLine.index + 1}</span
|
||||
>
|
||||
${vLine.renderVElements()}
|
||||
`;
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
</rich-text>
|
||||
|
||||
${this.renderChildren(this.model)} ${Object.values(this.widgets)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setWrap(wrap: boolean) {
|
||||
this.doc.updateBlock(this.model, { wrap });
|
||||
}
|
||||
|
||||
@query('rich-text')
|
||||
private accessor _richTextElement: RichText | null = null;
|
||||
|
||||
override accessor blockContainerStyles = {
|
||||
margin: '18px 0',
|
||||
};
|
||||
|
||||
override accessor useCaptionEditor = true;
|
||||
|
||||
override accessor useZeroWidth = true;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-code': CodeBlockComponent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { createLitPortal } from '@blocksuite/affine-components/portal';
|
||||
import type {
|
||||
EditorIconButton,
|
||||
MenuItemGroup,
|
||||
} from '@blocksuite/affine-components/toolbar';
|
||||
import { renderGroups } from '@blocksuite/affine-components/toolbar';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import { noop } from '@blocksuite/global/utils';
|
||||
import { MoreVerticalIcon } from '@blocksuite/icons/lit';
|
||||
import { flip, offset } from '@floating-ui/dom';
|
||||
import { css, html, LitElement } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { CodeBlockToolbarContext } from '../context.js';
|
||||
|
||||
export class AffineCodeToolbar extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.code-toolbar-container {
|
||||
width: auto;
|
||||
height: 24px;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.code-toolbar-button {
|
||||
color: var(--affine-icon-color);
|
||||
background-color: var(--affine-background-primary-color);
|
||||
box-shadow: var(--affine-shadow-1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
private _currentOpenMenu: AbortController | null = null;
|
||||
|
||||
private _popMenuAbortController: AbortController | null = null;
|
||||
|
||||
closeCurrentMenu = () => {
|
||||
if (this._currentOpenMenu && !this._currentOpenMenu.signal.aborted) {
|
||||
this._currentOpenMenu.abort();
|
||||
this._currentOpenMenu = null;
|
||||
}
|
||||
};
|
||||
|
||||
private _toggleMoreMenu() {
|
||||
if (
|
||||
this._currentOpenMenu &&
|
||||
!this._currentOpenMenu.signal.aborted &&
|
||||
this._currentOpenMenu === this._popMenuAbortController
|
||||
) {
|
||||
this.closeCurrentMenu();
|
||||
this._moreMenuOpen = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.closeCurrentMenu();
|
||||
this._popMenuAbortController = new AbortController();
|
||||
this._popMenuAbortController.signal.addEventListener('abort', () => {
|
||||
this._moreMenuOpen = false;
|
||||
this.onActiveStatusChange(false);
|
||||
});
|
||||
this.onActiveStatusChange(true);
|
||||
|
||||
this._currentOpenMenu = this._popMenuAbortController;
|
||||
|
||||
if (!this._moreButton) {
|
||||
console.error(
|
||||
'Failed to open more menu in code toolbar! Unexpected missing more button'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
createLitPortal({
|
||||
template: html`
|
||||
<editor-menu-content
|
||||
data-show
|
||||
class="more-popup-menu"
|
||||
style=${styleMap({
|
||||
'--content-padding': '8px',
|
||||
'--packed-height': '4px',
|
||||
})}
|
||||
>
|
||||
<div data-size="large" data-orientation="vertical">
|
||||
${renderGroups(this.moreGroups, this.context)}
|
||||
</div>
|
||||
</editor-menu-content>
|
||||
`,
|
||||
// should be greater than block-selection z-index as selection and popover wil share the same stacking context(editor-host)
|
||||
portalStyles: {
|
||||
zIndex: 'var(--affine-z-index-popover)',
|
||||
},
|
||||
container: this.context.host,
|
||||
computePosition: {
|
||||
referenceElement: this._moreButton,
|
||||
placement: 'bottom-start',
|
||||
middleware: [flip(), offset(4)],
|
||||
autoUpdate: { animationFrame: true },
|
||||
},
|
||||
abortController: this._popMenuAbortController,
|
||||
closeOnClickAway: true,
|
||||
});
|
||||
this._moreMenuOpen = true;
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.closeCurrentMenu();
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<editor-toolbar class="code-toolbar-container" data-without-bg>
|
||||
${renderGroups(this.primaryGroups, this.context)}
|
||||
<editor-icon-button
|
||||
class="code-toolbar-button more"
|
||||
data-testid="more"
|
||||
aria-label="More"
|
||||
.tooltip=${'More'}
|
||||
.tooltipOffset=${4}
|
||||
.iconSize=${'16px'}
|
||||
.iconContainerPadding=${4}
|
||||
.showTooltip=${!this._moreMenuOpen}
|
||||
?disabled=${this.context.doc.readonly}
|
||||
@click=${() => this._toggleMoreMenu()}
|
||||
>
|
||||
${MoreVerticalIcon()}
|
||||
</editor-icon-button>
|
||||
</editor-toolbar>
|
||||
`;
|
||||
}
|
||||
|
||||
@query('.code-toolbar-button.more')
|
||||
private accessor _moreButton!: EditorIconButton;
|
||||
|
||||
@state()
|
||||
private accessor _moreMenuOpen = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor context!: CodeBlockToolbarContext;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor moreGroups!: MenuItemGroup<CodeBlockToolbarContext>[];
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onActiveStatusChange: (active: boolean) => void = noop;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor primaryGroups!: MenuItemGroup<CodeBlockToolbarContext>[];
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
type FilterableListItem,
|
||||
type FilterableListOptions,
|
||||
showPopFilterableList,
|
||||
} from '@blocksuite/affine-components/filterable-list';
|
||||
import { ArrowDownIcon } from '@blocksuite/affine-components/icons';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import { noop } from '@blocksuite/global/utils';
|
||||
import { css, LitElement, nothing } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import { html } from 'lit/static-html.js';
|
||||
|
||||
import type { CodeBlockComponent } from '../..';
|
||||
|
||||
export class LanguageListButton extends WithDisposable(
|
||||
SignalWatcher(LitElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.lang-button {
|
||||
background-color: var(--affine-background-primary-color);
|
||||
box-shadow: var(--affine-shadow-1);
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.lang-button:hover {
|
||||
background: var(--affine-hover-color-filled);
|
||||
}
|
||||
|
||||
.lang-button[hover] {
|
||||
background: var(--affine-hover-color-filled);
|
||||
}
|
||||
|
||||
.lang-button-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: ${unsafeCSSVarV2('icon/primary')};
|
||||
|
||||
svg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
private _abortController?: AbortController;
|
||||
|
||||
private readonly _clickLangBtn = () => {
|
||||
if (this.blockComponent.doc.readonly) return;
|
||||
if (this._abortController) {
|
||||
// Close the language list if it's already opened.
|
||||
this._abortController.abort();
|
||||
return;
|
||||
}
|
||||
this._abortController = new AbortController();
|
||||
this._abortController.signal.addEventListener('abort', () => {
|
||||
this.onActiveStatusChange(false);
|
||||
this._abortController = undefined;
|
||||
});
|
||||
this.onActiveStatusChange(true);
|
||||
|
||||
const options: FilterableListOptions = {
|
||||
placeholder: 'Search for a language',
|
||||
onSelect: item => {
|
||||
const sortedBundledLanguages = this._sortedBundledLanguages;
|
||||
const index = sortedBundledLanguages.indexOf(item);
|
||||
if (index !== -1) {
|
||||
sortedBundledLanguages.splice(index, 1);
|
||||
sortedBundledLanguages.unshift(item);
|
||||
}
|
||||
this.blockComponent.doc.transact(() => {
|
||||
this.blockComponent.model.language$.value = item.name;
|
||||
});
|
||||
},
|
||||
active: item => item.name === this.blockComponent.model.language,
|
||||
items: this._sortedBundledLanguages,
|
||||
};
|
||||
|
||||
showPopFilterableList({
|
||||
options,
|
||||
referenceElement: this._langButton,
|
||||
container: this.blockComponent.host,
|
||||
abortController: this._abortController,
|
||||
// stacking-context(editor-host)
|
||||
portalStyles: {
|
||||
zIndex: 'var(--affine-z-index-popover)',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private _sortedBundledLanguages: FilterableListItem[] = [];
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
const langList = localStorage.getItem('blocksuite:code-block:lang-list');
|
||||
if (langList) {
|
||||
this._sortedBundledLanguages = JSON.parse(langList);
|
||||
} else {
|
||||
this._sortedBundledLanguages = this.blockComponent.service.langs.map(
|
||||
lang => ({
|
||||
label: lang.name,
|
||||
name: lang.id,
|
||||
aliases: lang.aliases,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.disposables.add(() => {
|
||||
localStorage.setItem(
|
||||
'blocksuite:code-block:lang-list',
|
||||
JSON.stringify(this._sortedBundledLanguages)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
override render() {
|
||||
const textStyles = styleMap({
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
fontStyle: 'normal',
|
||||
fontWeight: '500',
|
||||
lineHeight: '20px',
|
||||
padding: '0 4px',
|
||||
});
|
||||
|
||||
return html`<icon-button
|
||||
class="lang-button"
|
||||
data-testid="lang-button"
|
||||
width="auto"
|
||||
.text=${html`<div style=${textStyles}>
|
||||
${this.blockComponent.languageName$.value}
|
||||
</div>`}
|
||||
height="24px"
|
||||
@click=${this._clickLangBtn}
|
||||
?disabled=${this.blockComponent.doc.readonly}
|
||||
>
|
||||
<span class="lang-button-icon" slot="suffix">
|
||||
${!this.blockComponent.doc.readonly ? ArrowDownIcon : nothing}
|
||||
</span>
|
||||
</icon-button> `;
|
||||
}
|
||||
|
||||
@query('.lang-button')
|
||||
private accessor _langButton!: HTMLElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor blockComponent!: CodeBlockComponent;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onActiveStatusChange: (active: boolean) => void = noop;
|
||||
}
|
||||
178
blocksuite/affine/blocks/block-code/src/code-toolbar/config.ts
Normal file
178
blocksuite/affine/blocks/block-code/src/code-toolbar/config.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
CancelWrapIcon,
|
||||
CaptionIcon,
|
||||
CopyIcon,
|
||||
DeleteIcon,
|
||||
DuplicateIcon,
|
||||
WrapIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
|
||||
import { isInsidePageEditor } from '@blocksuite/affine-shared/utils';
|
||||
import { BlockSelection } from '@blocksuite/block-std';
|
||||
import { noop, sleep } from '@blocksuite/global/utils';
|
||||
import { html } from 'lit';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
|
||||
import type { CodeBlockToolbarContext } from './context.js';
|
||||
import { duplicateCodeBlock } from './utils.js';
|
||||
|
||||
export const PRIMARY_GROUPS: MenuItemGroup<CodeBlockToolbarContext>[] = [
|
||||
{
|
||||
type: 'primary',
|
||||
items: [
|
||||
{
|
||||
type: 'change-lang',
|
||||
generate: ({ blockComponent, setActive }) => {
|
||||
const state = { active: false };
|
||||
return {
|
||||
action: noop,
|
||||
render: () =>
|
||||
html`<language-list-button
|
||||
.blockComponent=${blockComponent}
|
||||
.onActiveStatusChange=${async (active: boolean) => {
|
||||
state.active = active;
|
||||
if (!active) {
|
||||
await sleep(1000);
|
||||
if (state.active) return;
|
||||
}
|
||||
setActive(active);
|
||||
}}
|
||||
>
|
||||
</language-list-button>`,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'copy-code',
|
||||
label: 'Copy code',
|
||||
icon: CopyIcon,
|
||||
generate: ({ blockComponent }) => {
|
||||
return {
|
||||
action: () => {
|
||||
blockComponent.copyCode();
|
||||
},
|
||||
render: item => html`
|
||||
<editor-icon-button
|
||||
class="code-toolbar-button copy-code"
|
||||
aria-label=${ifDefined(item.label)}
|
||||
.tooltip=${item.label}
|
||||
.tooltipOffset=${4}
|
||||
.iconSize=${'16px'}
|
||||
.iconContainerPadding=${4}
|
||||
@click=${(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
item.action();
|
||||
}}
|
||||
>
|
||||
${item.icon}
|
||||
</editor-icon-button>
|
||||
`,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'caption',
|
||||
label: 'Caption',
|
||||
icon: CaptionIcon,
|
||||
when: ({ doc }) => !doc.readonly,
|
||||
generate: ({ blockComponent }) => {
|
||||
return {
|
||||
action: () => {
|
||||
blockComponent.captionEditor?.show();
|
||||
},
|
||||
render: item => html`
|
||||
<editor-icon-button
|
||||
class="code-toolbar-button caption"
|
||||
aria-label=${ifDefined(item.label)}
|
||||
.tooltip=${item.label}
|
||||
.tooltipOffset=${4}
|
||||
.iconSize=${'16px'}
|
||||
.iconContainerPadding=${4}
|
||||
@click=${(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
item.action();
|
||||
}}
|
||||
>
|
||||
${item.icon}
|
||||
</editor-icon-button>
|
||||
`,
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Clipboard Group
|
||||
export const clipboardGroup: MenuItemGroup<CodeBlockToolbarContext> = {
|
||||
type: 'clipboard',
|
||||
items: [
|
||||
{
|
||||
type: 'wrap',
|
||||
generate: ({ blockComponent, close }) => {
|
||||
const wrapped = blockComponent.model.wrap;
|
||||
const label = wrapped ? 'Cancel wrap' : 'Wrap';
|
||||
const icon = wrapped ? CancelWrapIcon : WrapIcon;
|
||||
|
||||
return {
|
||||
label,
|
||||
icon,
|
||||
action: () => {
|
||||
blockComponent.setWrap(!wrapped);
|
||||
close();
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'duplicate',
|
||||
label: 'Duplicate',
|
||||
icon: DuplicateIcon,
|
||||
when: ({ doc }) => !doc.readonly,
|
||||
action: ({ host, blockComponent, close }) => {
|
||||
const codeId = duplicateCodeBlock(blockComponent.model);
|
||||
|
||||
host.updateComplete
|
||||
.then(() => {
|
||||
host.selection.setGroup('note', [
|
||||
host.selection.create(BlockSelection, {
|
||||
blockId: codeId,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (isInsidePageEditor(host)) {
|
||||
const duplicateElement = host.view.getBlock(codeId);
|
||||
if (duplicateElement) {
|
||||
duplicateElement.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
close();
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Delete Group
|
||||
export const deleteGroup: MenuItemGroup<CodeBlockToolbarContext> = {
|
||||
type: 'delete',
|
||||
items: [
|
||||
{
|
||||
type: 'delete',
|
||||
label: 'Delete',
|
||||
icon: DeleteIcon,
|
||||
when: ({ doc }) => !doc.readonly,
|
||||
action: ({ doc, blockComponent, close }) => {
|
||||
doc.deleteBlock(blockComponent.model);
|
||||
close();
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const MORE_GROUPS: MenuItemGroup<CodeBlockToolbarContext>[] = [
|
||||
clipboardGroup,
|
||||
deleteGroup,
|
||||
];
|
||||
@@ -0,0 +1,46 @@
|
||||
import { MenuContext } from '@blocksuite/affine-components/toolbar';
|
||||
|
||||
import type { CodeBlockComponent } from '../code-block';
|
||||
|
||||
export class CodeBlockToolbarContext extends MenuContext {
|
||||
override close = () => {
|
||||
this.abortController.abort();
|
||||
};
|
||||
|
||||
get doc() {
|
||||
return this.blockComponent.doc;
|
||||
}
|
||||
|
||||
get host() {
|
||||
return this.blockComponent.host;
|
||||
}
|
||||
|
||||
get selectedBlockModels() {
|
||||
if (this.blockComponent.model) return [this.blockComponent.model];
|
||||
return [];
|
||||
}
|
||||
|
||||
get std() {
|
||||
return this.blockComponent.std;
|
||||
}
|
||||
|
||||
constructor(
|
||||
public blockComponent: CodeBlockComponent,
|
||||
public abortController: AbortController,
|
||||
public setActive: (active: boolean) => void
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
isMultiple() {
|
||||
return false;
|
||||
}
|
||||
|
||||
isSingle() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
166
blocksuite/affine/blocks/block-code/src/code-toolbar/index.ts
Normal file
166
blocksuite/affine/blocks/block-code/src/code-toolbar/index.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { HoverController } from '@blocksuite/affine-components/hover';
|
||||
import type {
|
||||
AdvancedMenuItem,
|
||||
MenuItemGroup,
|
||||
} from '@blocksuite/affine-components/toolbar';
|
||||
import {
|
||||
cloneGroups,
|
||||
getMoreMenuConfig,
|
||||
} from '@blocksuite/affine-components/toolbar';
|
||||
import type { CodeBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockSelection,
|
||||
TextSelection,
|
||||
WidgetComponent,
|
||||
} from '@blocksuite/block-std';
|
||||
import { limitShift, shift, size } from '@floating-ui/dom';
|
||||
import { html } from 'lit';
|
||||
|
||||
import type { CodeBlockComponent } from '../code-block.js';
|
||||
import { MORE_GROUPS, PRIMARY_GROUPS } from './config.js';
|
||||
import { CodeBlockToolbarContext } from './context.js';
|
||||
|
||||
export const AFFINE_CODE_TOOLBAR_WIDGET = 'affine-code-toolbar-widget';
|
||||
export class AffineCodeToolbarWidget extends WidgetComponent<
|
||||
CodeBlockModel,
|
||||
CodeBlockComponent
|
||||
> {
|
||||
private _hoverController: HoverController | null = null;
|
||||
|
||||
private _isActivated = false;
|
||||
|
||||
private readonly _setHoverController = () => {
|
||||
this._hoverController = null;
|
||||
this._hoverController = new HoverController(
|
||||
this,
|
||||
({ abortController }) => {
|
||||
const codeBlock = this.block;
|
||||
const selection = this.host.selection;
|
||||
|
||||
const textSelection = selection.find(TextSelection);
|
||||
if (
|
||||
!!textSelection &&
|
||||
(!!textSelection.to || !!textSelection.from.length)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const blockSelections = selection.filter(BlockSelection);
|
||||
if (
|
||||
blockSelections.length > 1 ||
|
||||
(blockSelections.length === 1 &&
|
||||
blockSelections[0].blockId !== codeBlock.blockId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const setActive = (active: boolean) => {
|
||||
this._isActivated = active;
|
||||
if (!active && !this._hoverController?.isHovering) {
|
||||
this._hoverController?.abort();
|
||||
}
|
||||
};
|
||||
|
||||
const context = new CodeBlockToolbarContext(
|
||||
codeBlock,
|
||||
abortController,
|
||||
setActive
|
||||
);
|
||||
|
||||
return {
|
||||
template: html`<affine-code-toolbar
|
||||
.context=${context}
|
||||
.primaryGroups=${this.primaryGroups}
|
||||
.moreGroups=${this.moreGroups}
|
||||
.onActiveStatusChange=${setActive}
|
||||
></affine-code-toolbar>`,
|
||||
container: this.block,
|
||||
// stacking-context(editor-host)
|
||||
portalStyles: {
|
||||
zIndex: 'var(--affine-z-index-popover)',
|
||||
},
|
||||
computePosition: {
|
||||
referenceElement: codeBlock,
|
||||
placement: 'top',
|
||||
middleware: [
|
||||
size({
|
||||
apply({ rects, elements }) {
|
||||
elements.floating.style.width = `${rects.reference.width}px`;
|
||||
},
|
||||
}),
|
||||
shift({
|
||||
crossAxis: true,
|
||||
padding: {
|
||||
bottom: 12,
|
||||
right: 12,
|
||||
},
|
||||
limiter: limitShift(),
|
||||
}),
|
||||
],
|
||||
autoUpdate: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
{ allowMultiple: true }
|
||||
);
|
||||
|
||||
const codeBlock = this.block;
|
||||
this._hoverController.setReference(codeBlock);
|
||||
this._hoverController.onAbort = () => {
|
||||
// If the more menu is opened, don't close it.
|
||||
if (this._isActivated) return;
|
||||
this._hoverController?.abort();
|
||||
return;
|
||||
};
|
||||
};
|
||||
|
||||
addMoretems = (
|
||||
items: AdvancedMenuItem<CodeBlockToolbarContext>[],
|
||||
index?: number,
|
||||
type?: string
|
||||
) => {
|
||||
let group;
|
||||
if (type) {
|
||||
group = this.moreGroups.find(g => g.type === type);
|
||||
}
|
||||
if (!group) {
|
||||
group = this.moreGroups[0];
|
||||
}
|
||||
|
||||
if (index === undefined) {
|
||||
group.items.push(...items);
|
||||
return this;
|
||||
}
|
||||
|
||||
group.items.splice(index, 0, ...items);
|
||||
return this;
|
||||
};
|
||||
|
||||
addPrimaryItems = (
|
||||
items: AdvancedMenuItem<CodeBlockToolbarContext>[],
|
||||
index?: number
|
||||
) => {
|
||||
if (index === undefined) {
|
||||
this.primaryGroups[0].items.push(...items);
|
||||
return this;
|
||||
}
|
||||
|
||||
this.primaryGroups[0].items.splice(index, 0, ...items);
|
||||
return this;
|
||||
};
|
||||
|
||||
/*
|
||||
* Caches the more menu items.
|
||||
* Currently only supports configuring more menu.
|
||||
*/
|
||||
protected moreGroups: MenuItemGroup<CodeBlockToolbarContext>[] =
|
||||
cloneGroups(MORE_GROUPS);
|
||||
|
||||
protected primaryGroups: MenuItemGroup<CodeBlockToolbarContext>[] =
|
||||
cloneGroups(PRIMARY_GROUPS);
|
||||
|
||||
override firstUpdated() {
|
||||
this.moreGroups = getMoreMenuConfig(this.std).configure(this.moreGroups);
|
||||
this._setHoverController();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { CodeBlockModel } from '@blocksuite/affine-model';
|
||||
|
||||
export const duplicateCodeBlock = (model: CodeBlockModel) => {
|
||||
const keys = model.keys as (keyof typeof model)[];
|
||||
const values = keys.map(key => model[key]);
|
||||
const blockProps = Object.fromEntries(keys.map((key, i) => [key, values[i]]));
|
||||
const { text: _text, ...duplicateProps } = blockProps;
|
||||
|
||||
const newProps = {
|
||||
flavour: model.flavour,
|
||||
text: model.text.clone(),
|
||||
...duplicateProps,
|
||||
};
|
||||
|
||||
return model.doc.addSiblingBlocks(model, [newProps])[0];
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { type SlashMenuConfig } from '@blocksuite/affine-widget-slash-menu';
|
||||
|
||||
export const codeSlashMenuConfig: SlashMenuConfig = {
|
||||
disableWhen: ({ model }) => {
|
||||
return model.flavour === 'affine:code';
|
||||
},
|
||||
items: [],
|
||||
};
|
||||
24
blocksuite/affine/blocks/block-code/src/effects.ts
Normal file
24
blocksuite/affine/blocks/block-code/src/effects.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { CodeBlockComponent } from './code-block';
|
||||
import {
|
||||
AFFINE_CODE_TOOLBAR_WIDGET,
|
||||
AffineCodeToolbarWidget,
|
||||
} from './code-toolbar';
|
||||
import { AffineCodeToolbar } from './code-toolbar/components/code-toolbar';
|
||||
import { LanguageListButton } from './code-toolbar/components/lang-button';
|
||||
import { AffineCodeUnit } from './highlight/affine-code-unit';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('language-list-button', LanguageListButton);
|
||||
customElements.define('affine-code-toolbar', AffineCodeToolbar);
|
||||
customElements.define(AFFINE_CODE_TOOLBAR_WIDGET, AffineCodeToolbarWidget);
|
||||
customElements.define('affine-code-unit', AffineCodeUnit);
|
||||
customElements.define('affine-code', CodeBlockComponent);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'language-list-button': LanguageListButton;
|
||||
'affine-code-toolbar': AffineCodeToolbar;
|
||||
[AFFINE_CODE_TOOLBAR_WIDGET]: AffineCodeToolbarWidget;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { affineTextStyles } from '@blocksuite/affine-rich-text';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import { ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { type DeltaInsert, ZERO_WIDTH_SPACE } from '@blocksuite/inline';
|
||||
import { html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
import type { ThemedToken } from 'shiki';
|
||||
|
||||
export class AffineCodeUnit extends ShadowlessElement {
|
||||
get codeBlock() {
|
||||
return this.closest('affine-code');
|
||||
}
|
||||
|
||||
get vElement() {
|
||||
return this.closest('v-element');
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.delta.attributes?.link && this.codeBlock) {
|
||||
return html`<affine-link
|
||||
.std=${this.codeBlock.std}
|
||||
.delta=${this.delta}
|
||||
></affine-link>`;
|
||||
}
|
||||
|
||||
let style = this.delta.attributes
|
||||
? affineTextStyles(this.delta.attributes)
|
||||
: {};
|
||||
if (this.delta.attributes?.code) {
|
||||
style = {
|
||||
...style,
|
||||
'font-size': 'calc(var(--affine-font-base) - 3px)',
|
||||
padding: '0px 4px 2px',
|
||||
};
|
||||
}
|
||||
|
||||
const plainContent = html`<span style=${styleMap(style)}
|
||||
><v-text .str=${this.delta.insert}></v-text
|
||||
></span>`;
|
||||
|
||||
const codeBlock = this.codeBlock;
|
||||
const vElement = this.vElement;
|
||||
if (!codeBlock || !vElement) return plainContent;
|
||||
const tokens = codeBlock.highlightTokens$.value;
|
||||
if (tokens.length === 0) return plainContent;
|
||||
// copy the tokens to avoid modifying the original tokens
|
||||
const lineTokens = structuredClone(tokens[vElement.lineIndex]);
|
||||
if (lineTokens.length === 0) return plainContent;
|
||||
|
||||
const startOffset = vElement.startOffset;
|
||||
const endOffset = vElement.endOffset;
|
||||
const includedTokens: ThemedToken[] = [];
|
||||
lineTokens.forEach(token => {
|
||||
if (
|
||||
(token.offset <= startOffset &&
|
||||
token.offset + token.content.length >= startOffset) ||
|
||||
(token.offset >= startOffset &&
|
||||
token.offset + token.content.length <= endOffset) ||
|
||||
(token.offset <= endOffset &&
|
||||
token.offset + token.content.length >= endOffset)
|
||||
) {
|
||||
includedTokens.push(token);
|
||||
}
|
||||
});
|
||||
if (includedTokens.length === 0) return plainContent;
|
||||
|
||||
if (includedTokens.length === 1) {
|
||||
const token = includedTokens[0];
|
||||
const content = token.content.slice(
|
||||
startOffset - token.offset,
|
||||
endOffset - token.offset
|
||||
);
|
||||
|
||||
return html`<span
|
||||
style=${styleMap({
|
||||
color: token.color,
|
||||
...style,
|
||||
})}
|
||||
><v-text .str=${content}></v-text
|
||||
></span>`;
|
||||
} else {
|
||||
const firstToken = includedTokens[0];
|
||||
const lastToken = includedTokens[includedTokens.length - 1];
|
||||
|
||||
const firstContent = firstToken.content.slice(
|
||||
startOffset - firstToken.offset,
|
||||
firstToken.content.length
|
||||
);
|
||||
const lastContent = lastToken.content.slice(
|
||||
0,
|
||||
endOffset - lastToken.offset
|
||||
);
|
||||
firstToken.content = firstContent;
|
||||
lastToken.content = lastContent;
|
||||
|
||||
const vTexts = includedTokens.map(token => {
|
||||
return html`<v-text
|
||||
.str=${token.content}
|
||||
style=${styleMap({
|
||||
color: token.color,
|
||||
...style,
|
||||
})}
|
||||
></v-text>`;
|
||||
});
|
||||
|
||||
return html`<span>${vTexts}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
@property({ type: Object })
|
||||
accessor delta: DeltaInsert<AffineTextAttributes> = {
|
||||
insert: ZERO_WIDTH_SPACE,
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-code-unit': AffineCodeUnit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const CODE_BLOCK_DEFAULT_DARK_THEME = import(
|
||||
'shiki/themes/dark-plus.mjs'
|
||||
);
|
||||
export const CODE_BLOCK_DEFAULT_LIGHT_THEME = import(
|
||||
'shiki/themes/light-plus.mjs'
|
||||
);
|
||||
5
blocksuite/affine/blocks/block-code/src/index.ts
Normal file
5
blocksuite/affine/blocks/block-code/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './adapters';
|
||||
export * from './code-block';
|
||||
export * from './code-block-config';
|
||||
export * from './code-block-spec';
|
||||
export * from './code-toolbar';
|
||||
53
blocksuite/affine/blocks/block-code/src/styles.ts
Normal file
53
blocksuite/affine/blocks/block-code/src/styles.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { css } from 'lit';
|
||||
|
||||
export const codeBlockStyles = css`
|
||||
.affine-code-block-container {
|
||||
font-size: var(--affine-font-xs);
|
||||
line-height: var(--affine-line-height);
|
||||
position: relative;
|
||||
padding: 28px 24px;
|
||||
background: var(--affine-background-code-block);
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.affine-code-block-container.mobile {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.affine-code-block-container rich-text {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.affine-code-block-container .inline-editor {
|
||||
font-family: var(--affine-font-code-family);
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
.affine-code-block-container v-line {
|
||||
position: relative;
|
||||
display: inline-grid !important;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.affine-code-block-container div:has(> v-line) {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.affine-code-block-container .line-number {
|
||||
position: sticky;
|
||||
text-align: left;
|
||||
padding-right: 4px;
|
||||
width: 24px;
|
||||
word-break: break-word;
|
||||
white-space: nowrap;
|
||||
left: -0.5px;
|
||||
z-index: 1;
|
||||
background: var(--affine-background-code-block);
|
||||
font-size: var(--affine-font-xs);
|
||||
line-height: var(--affine-line-height);
|
||||
color: var(--affine-text-secondary);
|
||||
box-sizing: border-box;
|
||||
user-select: none;
|
||||
}
|
||||
`;
|
||||
20
blocksuite/affine/blocks/block-code/tsconfig.json
Normal file
20
blocksuite/affine/blocks/block-code/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../../components" },
|
||||
{ "path": "../../model" },
|
||||
{ "path": "../../rich-text" },
|
||||
{ "path": "../../shared" },
|
||||
{ "path": "../../widgets/widget-slash-menu" },
|
||||
{ "path": "../../../framework/block-std" },
|
||||
{ "path": "../../../framework/global" },
|
||||
{ "path": "../../../framework/inline" },
|
||||
{ "path": "../../../framework/store" }
|
||||
]
|
||||
}
|
||||
47
blocksuite/affine/blocks/block-data-view/package.json
Normal file
47
blocksuite/affine/blocks/block-data-view/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "@blocksuite/affine-block-data-view",
|
||||
"description": "Data view block for BlockSuite.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test:unit": "nx vite:test --run --passWithNoTests",
|
||||
"test:unit:coverage": "nx vite:test --run --coverage",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-block-database": "workspace:*",
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||
"@blocksuite/block-std": "workspace:*",
|
||||
"@blocksuite/data-view": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.3",
|
||||
"@blocksuite/inline": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.12",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"lit": "^3.2.0",
|
||||
"minimatch": "^10.0.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./effects": "./src/effects.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.20.0"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { PropertyMetaConfig } from '@blocksuite/data-view';
|
||||
import type { Disposable } from '@blocksuite/global/slot';
|
||||
import type { Block, BlockModel } from '@blocksuite/store';
|
||||
|
||||
type PropertyMeta<
|
||||
T extends BlockModel = BlockModel,
|
||||
Value = unknown,
|
||||
ColumnData extends NonNullable<unknown> = NonNullable<unknown>,
|
||||
> = {
|
||||
name: string;
|
||||
key: string;
|
||||
metaConfig: PropertyMetaConfig<string, ColumnData, Value>;
|
||||
getColumnData?: (block: T) => ColumnData;
|
||||
setColumnData?: (block: T, data: ColumnData) => void;
|
||||
get: (block: T) => Value;
|
||||
set?: (block: T, value: Value) => void;
|
||||
updated: (block: T, callback: () => void) => Disposable;
|
||||
};
|
||||
export type BlockMeta<T extends BlockModel = BlockModel> = {
|
||||
selector: (block: Block) => boolean;
|
||||
properties: PropertyMeta<T>[];
|
||||
};
|
||||
export const createBlockMeta = <T extends BlockModel>(
|
||||
options: Omit<BlockMeta<T>, 'properties'>
|
||||
) => {
|
||||
const meta: BlockMeta = {
|
||||
...options,
|
||||
properties: [],
|
||||
};
|
||||
return {
|
||||
...meta,
|
||||
addProperty: <Value>(property: PropertyMeta<T, Value>) => {
|
||||
meta.properties.push(property as PropertyMeta);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { BlockMeta } from './base.js';
|
||||
import { todoMeta } from './todo.js';
|
||||
|
||||
export const blockMetaMap = {
|
||||
todo: todoMeta,
|
||||
} satisfies Record<string, BlockMeta>;
|
||||
@@ -0,0 +1,60 @@
|
||||
import { richTextColumnConfig } from '@blocksuite/affine-block-database';
|
||||
import { type ListBlockModel, ListBlockSchema } from '@blocksuite/affine-model';
|
||||
import { propertyPresets } from '@blocksuite/data-view/property-presets';
|
||||
|
||||
import { createBlockMeta } from './base.js';
|
||||
|
||||
export const todoMeta = createBlockMeta<ListBlockModel>({
|
||||
selector: block => {
|
||||
if (block.flavour !== ListBlockSchema.model.flavour) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (block.model as ListBlockModel).type === 'todo';
|
||||
},
|
||||
});
|
||||
todoMeta.addProperty({
|
||||
name: 'Content',
|
||||
key: 'todo-title',
|
||||
metaConfig: richTextColumnConfig,
|
||||
get: block => block.text.yText,
|
||||
set: (_block, _value) => {
|
||||
//
|
||||
},
|
||||
updated: (block, callback) => {
|
||||
block.text?.yText.observe(callback);
|
||||
return {
|
||||
dispose: () => {
|
||||
block.text?.yText.unobserve(callback);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
todoMeta.addProperty({
|
||||
name: 'Checked',
|
||||
key: 'todo-checked',
|
||||
metaConfig: propertyPresets.checkboxPropertyConfig,
|
||||
get: block => block.checked,
|
||||
set: (block, value) => {
|
||||
block.checked = value;
|
||||
},
|
||||
updated: (block, callback) => {
|
||||
return block.propsUpdated.on(({ key }) => {
|
||||
if (key === 'checked') {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
todoMeta.addProperty({
|
||||
name: 'Source',
|
||||
key: 'todo-source',
|
||||
metaConfig: propertyPresets.textPropertyConfig,
|
||||
get: block => block.doc.meta?.title ?? '',
|
||||
updated: (block, callback) => {
|
||||
return block.doc.workspace.slots.docListUpdated.on(() => {
|
||||
callback();
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { richTextColumnConfig } from '@blocksuite/affine-block-database';
|
||||
import type { PropertyMetaConfig } from '@blocksuite/data-view';
|
||||
import { propertyPresets } from '@blocksuite/data-view/property-presets';
|
||||
|
||||
export const queryBlockColumns = [
|
||||
propertyPresets.datePropertyConfig,
|
||||
propertyPresets.numberPropertyConfig,
|
||||
propertyPresets.progressPropertyConfig,
|
||||
propertyPresets.selectPropertyConfig,
|
||||
propertyPresets.multiSelectPropertyConfig,
|
||||
propertyPresets.checkboxPropertyConfig,
|
||||
];
|
||||
export const queryBlockHiddenColumns = [richTextColumnConfig];
|
||||
const queryBlockAllColumns = [...queryBlockColumns, ...queryBlockHiddenColumns];
|
||||
export const queryBlockAllColumnMap = Object.fromEntries(
|
||||
queryBlockAllColumns.map(v => [v.type, v as PropertyMetaConfig])
|
||||
);
|
||||
@@ -0,0 +1,51 @@
|
||||
import { FeatureFlagService } from '@blocksuite/affine-shared/services';
|
||||
import { isInsideBlockByFlavour } from '@blocksuite/affine-shared/utils';
|
||||
import { type SlashMenuConfig } from '@blocksuite/affine-widget-slash-menu';
|
||||
import { DatabaseTableViewIcon } from '@blocksuite/icons/lit';
|
||||
|
||||
import type { DataViewBlockComponent } from '../data-view-block';
|
||||
import { ToDoListTooltip } from './tooltips';
|
||||
|
||||
export const dataViewSlashMenuConfig: SlashMenuConfig = {
|
||||
disableWhen: ({ model }) => {
|
||||
return model.flavour === 'affine:data-view';
|
||||
},
|
||||
items: [
|
||||
{
|
||||
name: 'Todo',
|
||||
searchAlias: ['todo view'],
|
||||
icon: DatabaseTableViewIcon(),
|
||||
tooltip: {
|
||||
figure: ToDoListTooltip,
|
||||
caption: 'To-do List',
|
||||
},
|
||||
group: '7_Database@1',
|
||||
when: ({ model, std }) =>
|
||||
!isInsideBlockByFlavour(model.doc, model, 'affine:edgeless-text') &&
|
||||
!!std.get(FeatureFlagService).getFlag('enable_block_query'),
|
||||
|
||||
action: ({ model, std }) => {
|
||||
const { host } = std;
|
||||
const parent = host.doc.getParent(model);
|
||||
if (!parent) return;
|
||||
const index = parent.children.indexOf(model);
|
||||
const id = host.doc.addBlock(
|
||||
'affine:data-view',
|
||||
{},
|
||||
host.doc.getParent(model),
|
||||
index + 1
|
||||
);
|
||||
const dataViewModel = host.doc.getBlock(id)!;
|
||||
|
||||
const dataView = std.view.getBlock(
|
||||
dataViewModel.id
|
||||
) as DataViewBlockComponent | null;
|
||||
dataView?.dataSource.viewManager.viewAdd('table');
|
||||
|
||||
if (model.text?.length === 0) {
|
||||
model.doc.deleteBlock(model);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { html } from 'lit';
|
||||
|
||||
// prettier-ignore
|
||||
export const ToDoListTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="170" height="68" rx="2" fill="white"/>
|
||||
<mask id="mask0_16460_960" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
|
||||
<rect width="170" height="68" rx="2" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_16460_960)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.6667 19C12.7462 19 12 19.7462 12 20.6667V27.3333C12 28.2538 12.7462 29 13.6667 29H20.3333C21.2538 29 22 28.2538 22 27.3333V20.6667C22 19.7462 21.2538 19 20.3333 19H13.6667ZM12.9091 20.6667C12.9091 20.2483 13.2483 19.9091 13.6667 19.9091H20.3333C20.7517 19.9091 21.0909 20.2483 21.0909 20.6667V27.3333C21.0909 27.7517 20.7517 28.0909 20.3333 28.0909H13.6667C13.2483 28.0909 12.9091 27.7517 12.9091 27.3333V20.6667Z" fill="#77757D"/>
|
||||
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="28" y="27.6364">Here is an example of todo list.</tspan></text>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 40.6667C12 39.7462 12.7462 39 13.6667 39H20.3333C21.2538 39 22 39.7462 22 40.6667V47.3333C22 48.2538 21.2538 49 20.3333 49H13.6667C12.7462 49 12 48.2538 12 47.3333V40.6667ZM19.7457 42.5032C19.9232 42.3257 19.9232 42.0379 19.7457 41.8604C19.5681 41.6829 19.2803 41.6829 19.1028 41.8604L16.0909 44.8723L15.2002 43.9816C15.0227 43.8041 14.7349 43.8041 14.5574 43.9816C14.3799 44.1591 14.3799 44.4469 14.5574 44.6244L15.7695 45.8366C15.947 46.0141 16.2348 46.0141 16.4123 45.8366L19.7457 42.5032Z" fill="#1E96EB"/>
|
||||
<text fill="#8E8D91" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="28" y="47.6364">Make a list for building preview.</tspan></text>
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
313
blocksuite/affine/blocks/block-data-view/src/data-source.ts
Normal file
313
blocksuite/affine/blocks/block-data-view/src/data-source.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import {
|
||||
databaseBlockAllPropertyMap,
|
||||
databasePropertyConverts,
|
||||
} from '@blocksuite/affine-block-database';
|
||||
import type { Column } from '@blocksuite/affine-model';
|
||||
import {
|
||||
insertPositionToIndex,
|
||||
type InsertToPosition,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import type { EditorHost } from '@blocksuite/block-std';
|
||||
import { DataSourceBase, type PropertyMetaConfig } from '@blocksuite/data-view';
|
||||
import { propertyPresets } from '@blocksuite/data-view/property-presets';
|
||||
import { BlockSuiteError } from '@blocksuite/global/exceptions';
|
||||
import { Slot } from '@blocksuite/global/slot';
|
||||
import type { Block, Store } from '@blocksuite/store';
|
||||
|
||||
import type { BlockMeta } from './block-meta/base.js';
|
||||
import { blockMetaMap } from './block-meta/index.js';
|
||||
import { queryBlockAllColumnMap, queryBlockColumns } from './columns/index.js';
|
||||
import type { DataViewBlockModel } from './data-view-model.js';
|
||||
|
||||
export type BlockQueryDataSourceConfig = {
|
||||
type: keyof typeof blockMetaMap;
|
||||
};
|
||||
|
||||
// @ts-expect-error FIXME: ts error
|
||||
export class BlockQueryDataSource extends DataSourceBase {
|
||||
private readonly columnMetaMap = new Map<
|
||||
string,
|
||||
PropertyMetaConfig<any, any, any>
|
||||
>();
|
||||
|
||||
private readonly meta: BlockMeta;
|
||||
|
||||
blockMap = new Map<string, Block>();
|
||||
|
||||
docDisposeMap = new Map<string, () => void>();
|
||||
|
||||
slots = {
|
||||
update: new Slot(),
|
||||
};
|
||||
|
||||
private get blocks() {
|
||||
return [...this.blockMap.values()];
|
||||
}
|
||||
|
||||
get properties(): string[] {
|
||||
return [
|
||||
...this.meta.properties.map(v => v.key),
|
||||
...this.block.columns.map(v => v.id),
|
||||
];
|
||||
}
|
||||
|
||||
get propertyMetas(): PropertyMetaConfig[] {
|
||||
return queryBlockColumns as PropertyMetaConfig[];
|
||||
}
|
||||
|
||||
get rows(): string[] {
|
||||
return this.blocks.map(v => v.id);
|
||||
}
|
||||
|
||||
get workspace() {
|
||||
return this.host.doc.workspace;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly host: EditorHost,
|
||||
private readonly block: DataViewBlockModel,
|
||||
config: BlockQueryDataSourceConfig
|
||||
) {
|
||||
super();
|
||||
this.meta = blockMetaMap[config.type];
|
||||
for (const property of this.meta.properties) {
|
||||
this.columnMetaMap.set(property.metaConfig.type, property.metaConfig);
|
||||
}
|
||||
for (const collection of this.workspace.docs.values()) {
|
||||
for (const block of Object.values(collection.getStore().blocks.peek())) {
|
||||
if (this.meta.selector(block)) {
|
||||
this.blockMap.set(block.id, block);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.workspace.docs.forEach(doc => {
|
||||
this.listenToDoc(doc.getStore());
|
||||
});
|
||||
this.workspace.slots.docCreated.on(id => {
|
||||
const doc = this.workspace.getDoc(id);
|
||||
if (doc) {
|
||||
this.listenToDoc(doc);
|
||||
}
|
||||
});
|
||||
this.workspace.slots.docRemoved.on(id => {
|
||||
this.docDisposeMap.get(id)?.();
|
||||
});
|
||||
}
|
||||
|
||||
private getProperty(propertyId: string) {
|
||||
const property = this.meta.properties.find(v => v.key === propertyId);
|
||||
if (!property) {
|
||||
throw new BlockSuiteError(
|
||||
BlockSuiteError.ErrorCode.ValueNotExists,
|
||||
`property ${propertyId} not found`
|
||||
);
|
||||
}
|
||||
return property;
|
||||
}
|
||||
|
||||
private newColumnName() {
|
||||
let i = 1;
|
||||
while (this.block.columns.some(column => column.name === `Column ${i}`)) {
|
||||
i++;
|
||||
}
|
||||
return `Column ${i}`;
|
||||
}
|
||||
|
||||
cellValueChange(rowId: string, propertyId: string, value: unknown): void {
|
||||
const viewColumn = this.getViewColumn(propertyId);
|
||||
if (viewColumn) {
|
||||
this.block.cells[rowId] = {
|
||||
...this.block.cells[rowId],
|
||||
[propertyId]: value,
|
||||
};
|
||||
return;
|
||||
}
|
||||
const block = this.blockMap.get(rowId);
|
||||
if (block) {
|
||||
this.meta.properties
|
||||
.find(v => v.key === propertyId)
|
||||
?.set?.(block.model, value);
|
||||
}
|
||||
}
|
||||
|
||||
cellValueGet(rowId: string, propertyId: string): unknown {
|
||||
const viewColumn = this.getViewColumn(propertyId);
|
||||
if (viewColumn) {
|
||||
return this.block.cells[rowId]?.[propertyId];
|
||||
}
|
||||
const block = this.blockMap.get(rowId);
|
||||
if (block) {
|
||||
return this.getProperty(propertyId)?.get(block.model);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
getViewColumn(id: string) {
|
||||
return this.block.columns.find(v => v.id === id);
|
||||
}
|
||||
|
||||
listenToDoc(doc: Store) {
|
||||
this.docDisposeMap.set(
|
||||
doc.id,
|
||||
doc.slots.blockUpdated.on(v => {
|
||||
if (v.type === 'add') {
|
||||
const blockById = doc.getBlock(v.id);
|
||||
if (blockById && this.meta.selector(blockById)) {
|
||||
this.blockMap.set(v.id, blockById);
|
||||
}
|
||||
} else if (v.type === 'delete') {
|
||||
this.blockMap.delete(v.id);
|
||||
}
|
||||
this.slots.update.emit();
|
||||
}).dispose
|
||||
);
|
||||
}
|
||||
|
||||
propertyAdd(
|
||||
insertToPosition: InsertToPosition,
|
||||
type: string | undefined
|
||||
): string {
|
||||
const doc = this.block.doc;
|
||||
doc.captureSync();
|
||||
const column = databaseBlockAllPropertyMap[
|
||||
type ?? propertyPresets.multiSelectPropertyConfig.type
|
||||
].create(this.newColumnName());
|
||||
|
||||
const id = doc.workspace.idGenerator();
|
||||
if (this.block.columns.some(v => v.id === id)) {
|
||||
return id;
|
||||
}
|
||||
doc.transact(() => {
|
||||
const col: Column = {
|
||||
...column,
|
||||
id,
|
||||
};
|
||||
this.block.columns.splice(
|
||||
insertPositionToIndex(insertToPosition, this.block.columns),
|
||||
0,
|
||||
col
|
||||
);
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
propertyDataGet(propertyId: string): Record<string, unknown> {
|
||||
const viewColumn = this.getViewColumn(propertyId);
|
||||
if (viewColumn) {
|
||||
return viewColumn.data;
|
||||
}
|
||||
const property = this.getProperty(propertyId);
|
||||
return (
|
||||
property.getColumnData?.(this.blocks[0].model) ??
|
||||
property.metaConfig.config.defaultData()
|
||||
);
|
||||
}
|
||||
|
||||
propertyDataSet(propertyId: string, data: Record<string, unknown>): void {
|
||||
const viewColumn = this.getViewColumn(propertyId);
|
||||
if (viewColumn) {
|
||||
viewColumn.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
propertyDelete(_id: string): void {
|
||||
const index = this.block.columns.findIndex(v => v.id === _id);
|
||||
if (index >= 0) {
|
||||
this.block.columns.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
propertyDuplicate(_columnId: string): string | undefined {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
propertyMetaGet(type: string): PropertyMetaConfig {
|
||||
const meta = this.columnMetaMap.get(type);
|
||||
if (meta) {
|
||||
return meta;
|
||||
}
|
||||
return queryBlockAllColumnMap[type];
|
||||
}
|
||||
|
||||
propertyNameGet(propertyId: string): string {
|
||||
const viewColumn = this.getViewColumn(propertyId);
|
||||
if (viewColumn) {
|
||||
return viewColumn.name;
|
||||
}
|
||||
if (propertyId === 'type') {
|
||||
return 'Block Type';
|
||||
}
|
||||
return this.getProperty(propertyId)?.name ?? '';
|
||||
}
|
||||
|
||||
propertyNameSet(propertyId: string, name: string): void {
|
||||
const viewColumn = this.getViewColumn(propertyId);
|
||||
if (viewColumn) {
|
||||
viewColumn.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
override propertyReadonlyGet(propertyId: string): boolean {
|
||||
const viewColumn = this.getViewColumn(propertyId);
|
||||
if (viewColumn) {
|
||||
return false;
|
||||
}
|
||||
if (propertyId === 'type') return true;
|
||||
return this.getProperty(propertyId)?.set == null;
|
||||
}
|
||||
|
||||
propertyTypeGet(propertyId: string): string {
|
||||
const viewColumn = this.getViewColumn(propertyId);
|
||||
if (viewColumn) {
|
||||
return viewColumn.type;
|
||||
}
|
||||
if (propertyId === 'type') {
|
||||
return 'image';
|
||||
}
|
||||
return this.getProperty(propertyId).metaConfig.type;
|
||||
}
|
||||
|
||||
propertyTypeSet(propertyId: string, toType: string): void {
|
||||
const viewColumn = this.getViewColumn(propertyId);
|
||||
if (viewColumn) {
|
||||
const currentType = viewColumn.type;
|
||||
const currentData = viewColumn.data;
|
||||
const rows = this.rows$.value;
|
||||
const currentCells = rows.map(rowId =>
|
||||
this.cellValueGet(rowId, propertyId)
|
||||
);
|
||||
const convertFunction = databasePropertyConverts.find(
|
||||
v => v.from === currentType && v.to === toType
|
||||
)?.convert;
|
||||
const result = convertFunction?.(
|
||||
currentData as any,
|
||||
|
||||
currentCells as any
|
||||
) ?? {
|
||||
property: databaseBlockAllPropertyMap[toType].config.defaultData(),
|
||||
cells: currentCells.map(() => undefined),
|
||||
};
|
||||
this.block.doc.captureSync();
|
||||
viewColumn.type = toType;
|
||||
viewColumn.data = result.property;
|
||||
currentCells.forEach((value, i) => {
|
||||
if (value != null || result.cells[i] != null) {
|
||||
this.block.cells[rows[i]] = {
|
||||
...this.block.cells[rows[i]],
|
||||
[propertyId]: result.cells[i],
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
rowAdd(_insertPosition: InsertToPosition | number): string {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
rowDelete(_ids: string[]): void {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
rowMove(_rowId: string, _position: InsertToPosition): void {}
|
||||
}
|
||||
324
blocksuite/affine/blocks/block-data-view/src/data-view-block.ts
Normal file
324
blocksuite/affine/blocks/block-data-view/src/data-view-block.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import {
|
||||
BlockRenderer,
|
||||
DatabaseSelection,
|
||||
NoteRenderer,
|
||||
} from '@blocksuite/affine-block-database';
|
||||
import { CaptionedBlockComponent } from '@blocksuite/affine-components/caption';
|
||||
import {
|
||||
menu,
|
||||
popMenu,
|
||||
popupTargetFromElement,
|
||||
} from '@blocksuite/affine-components/context-menu';
|
||||
import { CopyIcon, DeleteIcon } from '@blocksuite/affine-components/icons';
|
||||
import { PeekViewProvider } from '@blocksuite/affine-components/peek';
|
||||
import { toast } from '@blocksuite/affine-components/toast';
|
||||
import { NOTE_SELECTOR } from '@blocksuite/affine-shared/consts';
|
||||
import {
|
||||
DocModeProvider,
|
||||
NotificationProvider,
|
||||
type TelemetryEventMap,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
type BlockComponent,
|
||||
RANGE_SYNC_EXCLUDE_ATTR,
|
||||
} from '@blocksuite/block-std';
|
||||
import {
|
||||
createRecordDetail,
|
||||
createUniComponentFromWebComponent,
|
||||
type DataSource,
|
||||
DataView,
|
||||
dataViewCommonStyle,
|
||||
type DataViewProps,
|
||||
type DataViewSelection,
|
||||
type DataViewWidget,
|
||||
type DataViewWidgetProps,
|
||||
defineUniComponent,
|
||||
renderUniLit,
|
||||
uniMap,
|
||||
} from '@blocksuite/data-view';
|
||||
import { widgetPresets } from '@blocksuite/data-view/widget-presets';
|
||||
import { MoreHorizontalIcon } from '@blocksuite/icons/lit';
|
||||
import { Slice } from '@blocksuite/store';
|
||||
import { computed, signal } from '@preact/signals-core';
|
||||
import { css, nothing, unsafeCSS } from 'lit';
|
||||
import { html } from 'lit/static-html.js';
|
||||
|
||||
import { BlockQueryDataSource } from './data-source.js';
|
||||
import type { DataViewBlockModel } from './data-view-model.js';
|
||||
|
||||
export class DataViewBlockComponent extends CaptionedBlockComponent<DataViewBlockModel> {
|
||||
static override styles = css`
|
||||
${unsafeCSS(dataViewCommonStyle('affine-database'))}
|
||||
affine-database {
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
background-color: var(--affine-background-primary-color);
|
||||
padding: 8px;
|
||||
margin: 8px -8px -8px;
|
||||
}
|
||||
|
||||
.database-block-selected {
|
||||
background-color: var(--affine-hover-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.database-ops {
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.database-ops svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--affine-icon-color);
|
||||
}
|
||||
|
||||
.database-ops:hover {
|
||||
background-color: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
@media print {
|
||||
.database-ops {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.database-header-bar {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _clickDatabaseOps = (e: MouseEvent) => {
|
||||
popMenu(popupTargetFromElement(e.currentTarget as HTMLElement), {
|
||||
options: {
|
||||
items: [
|
||||
menu.input({
|
||||
initialValue: this.model.title,
|
||||
placeholder: 'Untitled',
|
||||
onChange: text => {
|
||||
this.model.title = text;
|
||||
},
|
||||
}),
|
||||
menu.action({
|
||||
prefix: CopyIcon,
|
||||
name: 'Copy',
|
||||
select: () => {
|
||||
const slice = Slice.fromModels(this.doc, [this.model]);
|
||||
this.std.clipboard.copySlice(slice).catch(console.error);
|
||||
},
|
||||
}),
|
||||
menu.group({
|
||||
name: '',
|
||||
items: [
|
||||
menu.action({
|
||||
prefix: DeleteIcon,
|
||||
class: {
|
||||
'delete-item': true,
|
||||
},
|
||||
name: 'Delete Database',
|
||||
select: () => {
|
||||
this.model.children.slice().forEach(block => {
|
||||
this.doc.deleteBlock(block);
|
||||
});
|
||||
this.doc.deleteBlock(this.model);
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
private _dataSource?: DataSource;
|
||||
|
||||
private readonly dataView = new DataView();
|
||||
|
||||
_bindHotkey: DataViewProps['bindHotkey'] = hotkeys => {
|
||||
return {
|
||||
dispose: this.host.event.bindHotkey(hotkeys, {
|
||||
blockId: this.topContenteditableElement?.blockId ?? this.blockId,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
_handleEvent: DataViewProps['handleEvent'] = (name, handler) => {
|
||||
return {
|
||||
dispose: this.host.event.add(name, handler, {
|
||||
blockId: this.blockId,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
headerWidget: DataViewWidget = defineUniComponent(
|
||||
(props: DataViewWidgetProps) => {
|
||||
return html`
|
||||
<div style="margin-bottom: 16px;display:flex;flex-direction: column">
|
||||
<div style="display:flex;gap:8px;padding: 0 6px;margin-bottom: 8px;">
|
||||
<div>${this.model.title}</div>
|
||||
${this.renderDatabaseOps()}
|
||||
</div>
|
||||
<div
|
||||
style="display:flex;align-items:center;justify-content: space-between;gap: 12px"
|
||||
class="database-header-bar"
|
||||
>
|
||||
<div style="flex:1">
|
||||
${renderUniLit(widgetPresets.viewBar, props)}
|
||||
</div>
|
||||
${renderUniLit(this.toolsWidget, props)}
|
||||
</div>
|
||||
${renderUniLit(widgetPresets.quickSettingBar, props)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
);
|
||||
|
||||
selection$ = computed(() => {
|
||||
const databaseSelection = this.selection.value.find(
|
||||
(selection): selection is DatabaseSelection => {
|
||||
if (selection.blockId !== this.blockId) {
|
||||
return false;
|
||||
}
|
||||
return selection instanceof DatabaseSelection;
|
||||
}
|
||||
);
|
||||
return databaseSelection?.viewSelection;
|
||||
});
|
||||
|
||||
setSelection = (selection: DataViewSelection | undefined) => {
|
||||
this.selection.setGroup(
|
||||
'note',
|
||||
selection
|
||||
? [
|
||||
new DatabaseSelection({
|
||||
blockId: this.blockId,
|
||||
viewSelection: selection,
|
||||
}),
|
||||
]
|
||||
: []
|
||||
);
|
||||
};
|
||||
|
||||
toolsWidget: DataViewWidget = widgetPresets.createTools({
|
||||
table: [
|
||||
widgetPresets.tools.filter,
|
||||
widgetPresets.tools.search,
|
||||
widgetPresets.tools.viewOptions,
|
||||
widgetPresets.tools.tableAddRow,
|
||||
],
|
||||
kanban: [
|
||||
widgetPresets.tools.filter,
|
||||
widgetPresets.tools.search,
|
||||
widgetPresets.tools.viewOptions,
|
||||
],
|
||||
});
|
||||
|
||||
get dataSource(): DataSource {
|
||||
if (!this._dataSource) {
|
||||
this._dataSource = new BlockQueryDataSource(this.host, this.model, {
|
||||
type: 'todo',
|
||||
});
|
||||
}
|
||||
return this._dataSource;
|
||||
}
|
||||
|
||||
override get topContenteditableElement() {
|
||||
if (this.std.get(DocModeProvider).getEditorMode() === 'edgeless') {
|
||||
return this.closest<BlockComponent>(NOTE_SELECTOR);
|
||||
}
|
||||
return this.rootComponent;
|
||||
}
|
||||
|
||||
get view() {
|
||||
return this.dataView.expose;
|
||||
}
|
||||
|
||||
private renderDatabaseOps() {
|
||||
if (this.doc.readonly) {
|
||||
return nothing;
|
||||
}
|
||||
return html` <div class="database-ops" @click="${this._clickDatabaseOps}">
|
||||
${MoreHorizontalIcon()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.setAttribute(RANGE_SYNC_EXCLUDE_ATTR, 'true');
|
||||
}
|
||||
|
||||
override renderBlock() {
|
||||
const peekViewService = this.std.getOptional(PeekViewProvider);
|
||||
const telemetryService = this.std.getOptional(TelemetryProvider);
|
||||
return html`
|
||||
<div contenteditable="false" style="position: relative">
|
||||
${this.dataView.render({
|
||||
virtualPadding$: signal(0),
|
||||
bindHotkey: this._bindHotkey,
|
||||
handleEvent: this._handleEvent,
|
||||
selection$: this.selection$,
|
||||
setSelection: this.setSelection,
|
||||
dataSource: this.dataSource,
|
||||
headerWidget: this.headerWidget,
|
||||
clipboard: this.std.clipboard,
|
||||
notification: {
|
||||
toast: message => {
|
||||
const notification = this.std.getOptional(NotificationProvider);
|
||||
if (notification) {
|
||||
notification.toast(message);
|
||||
} else {
|
||||
toast(this.host, message);
|
||||
}
|
||||
},
|
||||
},
|
||||
eventTrace: (key, params) => {
|
||||
telemetryService?.track(key, {
|
||||
...(params as TelemetryEventMap[typeof key]),
|
||||
blockId: this.blockId,
|
||||
});
|
||||
},
|
||||
detailPanelConfig: {
|
||||
openDetailPanel: (target, data) => {
|
||||
if (peekViewService) {
|
||||
const template = createRecordDetail({
|
||||
...data,
|
||||
openDoc: () => {},
|
||||
detail: {
|
||||
header: uniMap(
|
||||
createUniComponentFromWebComponent(BlockRenderer),
|
||||
props => ({
|
||||
...props,
|
||||
host: this.host,
|
||||
})
|
||||
),
|
||||
note: uniMap(
|
||||
createUniComponentFromWebComponent(NoteRenderer),
|
||||
props => ({
|
||||
...props,
|
||||
model: this.model,
|
||||
host: this.host,
|
||||
})
|
||||
),
|
||||
},
|
||||
});
|
||||
return peekViewService.peek({ target, template });
|
||||
} else {
|
||||
return Promise.resolve();
|
||||
}
|
||||
},
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-data-view': DataViewBlockComponent;
|
||||
}
|
||||
}
|
||||
102
blocksuite/affine/blocks/block-data-view/src/data-view-model.ts
Normal file
102
blocksuite/affine/blocks/block-data-view/src/data-view-model.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { Column } from '@blocksuite/affine-model';
|
||||
import {
|
||||
arrayMove,
|
||||
insertPositionToIndex,
|
||||
type InsertToPosition,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import type { DataViewDataType } from '@blocksuite/data-view';
|
||||
import {
|
||||
BlockModel,
|
||||
BlockSchemaExtension,
|
||||
defineBlockSchema,
|
||||
} from '@blocksuite/store';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
views: DataViewDataType[];
|
||||
columns: Column[];
|
||||
cells: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export class DataViewBlockModel extends BlockModel<Props> {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
applyViewsUpdate() {
|
||||
this.doc.updateBlock(this, {
|
||||
views: this.views,
|
||||
});
|
||||
}
|
||||
|
||||
deleteView(id: string) {
|
||||
this.doc.captureSync();
|
||||
this.doc.transact(() => {
|
||||
this.views = this.views.filter(v => v.id !== id);
|
||||
});
|
||||
}
|
||||
|
||||
duplicateView(id: string): string {
|
||||
const newId = this.doc.workspace.idGenerator();
|
||||
this.doc.transact(() => {
|
||||
const index = this.views.findIndex(v => v.id === id);
|
||||
const view = this.views[index];
|
||||
if (view) {
|
||||
this.views.splice(
|
||||
index + 1,
|
||||
0,
|
||||
JSON.parse(JSON.stringify({ ...view, id: newId }))
|
||||
);
|
||||
}
|
||||
});
|
||||
return newId;
|
||||
}
|
||||
|
||||
moveViewTo(id: string, position: InsertToPosition) {
|
||||
this.doc.transact(() => {
|
||||
this.views = arrayMove(
|
||||
this.views,
|
||||
v => v.id === id,
|
||||
arr => insertPositionToIndex(position, arr)
|
||||
);
|
||||
});
|
||||
this.applyViewsUpdate();
|
||||
}
|
||||
|
||||
updateView(
|
||||
id: string,
|
||||
update: (data: DataViewDataType) => Partial<DataViewDataType>
|
||||
) {
|
||||
this.doc.transact(() => {
|
||||
this.views = this.views.map(v => {
|
||||
if (v.id !== id) {
|
||||
return v;
|
||||
}
|
||||
return { ...v, ...(update(v) as DataViewDataType) };
|
||||
});
|
||||
});
|
||||
this.applyViewsUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
export const DataViewBlockSchema = defineBlockSchema({
|
||||
flavour: 'affine:data-view',
|
||||
props: (): Props => ({
|
||||
views: [],
|
||||
title: '',
|
||||
columns: [],
|
||||
cells: {},
|
||||
}),
|
||||
metadata: {
|
||||
role: 'hub',
|
||||
version: 1,
|
||||
parent: ['affine:note'],
|
||||
children: ['affine:paragraph', 'affine:list'],
|
||||
},
|
||||
toModel: () => {
|
||||
return new DataViewBlockModel();
|
||||
},
|
||||
});
|
||||
|
||||
export const DataViewBlockSchemaExtension =
|
||||
BlockSchemaExtension(DataViewBlockSchema);
|
||||
@@ -0,0 +1,8 @@
|
||||
import { BlockViewExtension, FlavourExtension } from '@blocksuite/block-std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import { literal } from 'lit/static-html.js';
|
||||
|
||||
export const DataViewBlockSpec: ExtensionType[] = [
|
||||
FlavourExtension('affine:data-view'),
|
||||
BlockViewExtension('affine:data-view', literal`affine-data-view`),
|
||||
];
|
||||
5
blocksuite/affine/blocks/block-data-view/src/effects.ts
Normal file
5
blocksuite/affine/blocks/block-data-view/src/effects.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { DataViewBlockComponent } from './data-view-block';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('affine-data-view', DataViewBlockComponent);
|
||||
}
|
||||
3
blocksuite/affine/blocks/block-data-view/src/index.ts
Normal file
3
blocksuite/affine/blocks/block-data-view/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './data-view-block.js';
|
||||
export * from './data-view-model.js';
|
||||
export * from './data-view-spec.js';
|
||||
11
blocksuite/affine/blocks/block-data-view/src/views/index.ts
Normal file
11
blocksuite/affine/blocks/block-data-view/src/views/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { ViewMeta } from '@blocksuite/data-view';
|
||||
import { viewPresets } from '@blocksuite/data-view/view-presets';
|
||||
|
||||
export const blockQueryViews: ViewMeta[] = [
|
||||
viewPresets.tableViewMeta,
|
||||
viewPresets.kanbanViewMeta,
|
||||
];
|
||||
|
||||
export const blockQueryViewMap = Object.fromEntries(
|
||||
blockQueryViews.map(view => [view.type, view])
|
||||
);
|
||||
21
blocksuite/affine/blocks/block-data-view/tsconfig.json
Normal file
21
blocksuite/affine/blocks/block-data-view/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../block-database" },
|
||||
{ "path": "../../components" },
|
||||
{ "path": "../../model" },
|
||||
{ "path": "../../shared" },
|
||||
{ "path": "../../widgets/widget-slash-menu" },
|
||||
{ "path": "../../../framework/block-std" },
|
||||
{ "path": "../../data-view" },
|
||||
{ "path": "../../../framework/global" },
|
||||
{ "path": "../../../framework/inline" },
|
||||
{ "path": "../../../framework/store" }
|
||||
]
|
||||
}
|
||||
50
blocksuite/affine/blocks/block-database/package.json
Normal file
50
blocksuite/affine/blocks/block-database/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@blocksuite/affine-block-database",
|
||||
"description": "Database block for BlockSuite.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test:unit": "nx vite:test --run --passWithNoTests",
|
||||
"test:unit:coverage": "nx vite:test --run --coverage",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-rich-text": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/affine-widget-drag-handle": "workspace:*",
|
||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||
"@blocksuite/block-std": "workspace:*",
|
||||
"@blocksuite/data-view": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/icons": "^2.2.1",
|
||||
"@blocksuite/inline": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.12",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"date-fns": "^4.0.0",
|
||||
"lit": "^3.2.0",
|
||||
"minimatch": "^10.0.1",
|
||||
"yjs": "^13.6.21",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./effects": "./src/effects.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.20.0"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
|
||||
import { DatabaseBlockHtmlAdapterExtension } from './html.js';
|
||||
import { DatabaseBlockMarkdownAdapterExtension } from './markdown.js';
|
||||
import { DatabaseBlockNotionHtmlAdapterExtension } from './notion-html.js';
|
||||
import { DatabaseBlockPlainTextAdapterExtension } from './plain-text.js';
|
||||
|
||||
export const DatabaseBlockAdapterExtensions: ExtensionType[] = [
|
||||
DatabaseBlockHtmlAdapterExtension,
|
||||
DatabaseBlockMarkdownAdapterExtension,
|
||||
DatabaseBlockNotionHtmlAdapterExtension,
|
||||
DatabaseBlockPlainTextAdapterExtension,
|
||||
];
|
||||
105
blocksuite/affine/blocks/block-database/src/adapters/html.ts
Normal file
105
blocksuite/affine/blocks/block-database/src/adapters/html.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
type Column,
|
||||
DatabaseBlockSchema,
|
||||
type SerializedCells,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockHtmlAdapterExtension,
|
||||
type BlockHtmlAdapterMatcher,
|
||||
type InlineHtmlAST,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { Element } from 'hast';
|
||||
|
||||
import { processTable } from './utils';
|
||||
|
||||
export const databaseBlockHtmlAdapterMatcher: BlockHtmlAdapterMatcher = {
|
||||
flavour: DatabaseBlockSchema.model.flavour,
|
||||
toMatch: () => false,
|
||||
fromMatch: o => o.node.flavour === DatabaseBlockSchema.model.flavour,
|
||||
toBlockSnapshot: {},
|
||||
fromBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext } = context;
|
||||
const columns = o.node.props.columns as Array<Column>;
|
||||
const children = o.node.children;
|
||||
const cells = o.node.props.cells as SerializedCells;
|
||||
const table = processTable(columns, children, cells);
|
||||
const createAstTableCell = (
|
||||
children: InlineHtmlAST[]
|
||||
): InlineHtmlAST => ({
|
||||
type: 'element',
|
||||
tagName: 'td',
|
||||
properties: Object.create(null),
|
||||
children,
|
||||
});
|
||||
|
||||
const createAstTableHeaderCell = (
|
||||
children: InlineHtmlAST[]
|
||||
): InlineHtmlAST => ({
|
||||
type: 'element',
|
||||
tagName: 'th',
|
||||
properties: Object.create(null),
|
||||
children,
|
||||
});
|
||||
|
||||
const createAstTableRow = (cells: InlineHtmlAST[]): Element => ({
|
||||
type: 'element',
|
||||
tagName: 'tr',
|
||||
properties: Object.create(null),
|
||||
children: cells,
|
||||
});
|
||||
|
||||
const { deltaConverter } = context;
|
||||
|
||||
const tableHeaderAst: Element = {
|
||||
type: 'element',
|
||||
tagName: 'thead',
|
||||
properties: Object.create(null),
|
||||
children: [
|
||||
createAstTableRow(
|
||||
table.headers.map(v =>
|
||||
createAstTableHeaderCell([
|
||||
{
|
||||
type: 'text',
|
||||
value: v.name ?? '',
|
||||
},
|
||||
])
|
||||
)
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
const tableBodyAst: Element = {
|
||||
type: 'element',
|
||||
tagName: 'tbody',
|
||||
properties: Object.create(null),
|
||||
children: table.rows.map(v => {
|
||||
return createAstTableRow(
|
||||
v.cells.map(cell => {
|
||||
return createAstTableCell(
|
||||
typeof cell.value === 'string'
|
||||
? [{ type: 'text', value: cell.value }]
|
||||
: deltaConverter.deltaToAST(cell.value.delta)
|
||||
);
|
||||
})
|
||||
);
|
||||
}),
|
||||
};
|
||||
|
||||
walkerContext
|
||||
.openNode({
|
||||
type: 'element',
|
||||
tagName: 'table',
|
||||
properties: Object.create(null),
|
||||
children: [tableHeaderAst, tableBodyAst],
|
||||
})
|
||||
.closeNode();
|
||||
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const DatabaseBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
|
||||
databaseBlockHtmlAdapterMatcher
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './html';
|
||||
export * from './markdown';
|
||||
export * from './notion-html';
|
||||
export * from './plain-text';
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
type Column,
|
||||
DatabaseBlockSchema,
|
||||
type SerializedCells,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockMarkdownAdapterExtension,
|
||||
type BlockMarkdownAdapterMatcher,
|
||||
type MarkdownAST,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { TableRow } from 'mdast';
|
||||
|
||||
import { processTable } from './utils';
|
||||
|
||||
const DATABASE_NODE_TYPES = new Set(['table', 'tableRow']);
|
||||
|
||||
const isDatabaseNode = (node: MarkdownAST) =>
|
||||
DATABASE_NODE_TYPES.has(node.type);
|
||||
|
||||
export const databaseBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
|
||||
{
|
||||
flavour: DatabaseBlockSchema.model.flavour,
|
||||
toMatch: o => isDatabaseNode(o.node),
|
||||
fromMatch: o => o.node.flavour === DatabaseBlockSchema.model.flavour,
|
||||
toBlockSnapshot: {},
|
||||
fromBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext, deltaConverter } = context;
|
||||
const rows: TableRow[] = [];
|
||||
const columns = o.node.props.columns as Array<Column>;
|
||||
const children = o.node.children;
|
||||
const cells = o.node.props.cells as SerializedCells;
|
||||
const table = processTable(columns, children, cells);
|
||||
rows.push({
|
||||
type: 'tableRow',
|
||||
children: table.headers.map(v => ({
|
||||
type: 'tableCell',
|
||||
children: [{ type: 'text', value: v.name }],
|
||||
})),
|
||||
});
|
||||
table.rows.forEach(v => {
|
||||
rows.push({
|
||||
type: 'tableRow',
|
||||
children: v.cells.map(v => ({
|
||||
type: 'tableCell',
|
||||
children:
|
||||
typeof v.value === 'string'
|
||||
? [{ type: 'text', value: v.value }]
|
||||
: deltaConverter.deltaToAST(v.value.delta),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
walkerContext
|
||||
.openNode({
|
||||
type: 'table',
|
||||
children: rows,
|
||||
})
|
||||
.closeNode();
|
||||
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const DatabaseBlockMarkdownAdapterExtension =
|
||||
BlockMarkdownAdapterExtension(databaseBlockMarkdownAdapterMatcher);
|
||||
@@ -0,0 +1,358 @@
|
||||
import { DatabaseBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
AdapterTextUtils,
|
||||
BlockNotionHtmlAdapterExtension,
|
||||
type BlockNotionHtmlAdapterMatcher,
|
||||
HastUtils,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { getTagColor } from '@blocksuite/data-view';
|
||||
import { type BlockSnapshot, nanoid } from '@blocksuite/store';
|
||||
|
||||
const ColumnClassMap: Record<string, string> = {
|
||||
typesSelect: 'select',
|
||||
typesMultipleSelect: 'multi-select',
|
||||
typesNumber: 'number',
|
||||
typesCheckbox: 'checkbox',
|
||||
typesText: 'rich-text',
|
||||
typesTitle: 'title',
|
||||
};
|
||||
|
||||
const NotionDatabaseToken = '.collection-content';
|
||||
const NotionDatabaseTitleToken = '.collection-title';
|
||||
|
||||
type BlocksuiteTableColumn = {
|
||||
type: string;
|
||||
name: string;
|
||||
data: {
|
||||
options?: {
|
||||
id: string;
|
||||
value: string;
|
||||
color: string;
|
||||
}[];
|
||||
};
|
||||
id: string;
|
||||
};
|
||||
|
||||
type BlocksuiteTableRow = Record<
|
||||
string,
|
||||
{
|
||||
columnId: string;
|
||||
value: unknown;
|
||||
}
|
||||
>;
|
||||
|
||||
const DATABASE_NODE_TYPES = new Set(['table', 'th', 'tr']);
|
||||
|
||||
export const databaseBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher =
|
||||
{
|
||||
flavour: DatabaseBlockSchema.model.flavour,
|
||||
toMatch: o =>
|
||||
HastUtils.isElement(o.node) && DATABASE_NODE_TYPES.has(o.node.tagName),
|
||||
fromMatch: () => false,
|
||||
toBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
if (!HastUtils.isElement(o.node)) {
|
||||
return;
|
||||
}
|
||||
const { walkerContext, deltaConverter, pageMap } = context;
|
||||
switch (o.node.tagName) {
|
||||
case 'th': {
|
||||
const columnId = nanoid();
|
||||
const columnTypeClass = HastUtils.querySelector(o.node, 'svg')
|
||||
?.properties?.className;
|
||||
const columnType = Array.isArray(columnTypeClass)
|
||||
? (ColumnClassMap[columnTypeClass[0] ?? ''] ?? 'rich-text')
|
||||
: 'rich-text';
|
||||
walkerContext.pushGlobalContextStack<BlocksuiteTableColumn>(
|
||||
'hast:table:column',
|
||||
{
|
||||
type: columnType,
|
||||
name: HastUtils.getTextContent(
|
||||
HastUtils.getTextChildrenOnlyAst(o.node)
|
||||
),
|
||||
data: Object.create(null),
|
||||
id: columnId,
|
||||
}
|
||||
);
|
||||
// disable icon img in th
|
||||
walkerContext.setGlobalContext('hast:disableimg', true);
|
||||
break;
|
||||
}
|
||||
case 'tr': {
|
||||
if (
|
||||
o.parent?.node.type === 'element' &&
|
||||
o.parent.node.tagName === 'tbody'
|
||||
) {
|
||||
const columns =
|
||||
walkerContext.getGlobalContextStack<BlocksuiteTableColumn>(
|
||||
'hast:table:column'
|
||||
);
|
||||
const row = Object.create(null);
|
||||
let plainTable = false;
|
||||
HastUtils.getElementChildren(o.node).forEach((child, index) => {
|
||||
if (plainTable || columns[index] === undefined) {
|
||||
plainTable = true;
|
||||
if (columns[index] === undefined) {
|
||||
columns.push({
|
||||
type: 'rich-text',
|
||||
name: '',
|
||||
data: Object.create(null),
|
||||
id: nanoid(),
|
||||
});
|
||||
walkerContext.pushGlobalContextStack<BlockSnapshot>(
|
||||
'hast:table:children',
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: deltaConverter.astToDelta(child),
|
||||
},
|
||||
type: 'text',
|
||||
},
|
||||
children: [],
|
||||
}
|
||||
);
|
||||
}
|
||||
walkerContext.pushGlobalContextStack<BlockSnapshot>(
|
||||
'hast:table:children',
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: deltaConverter.astToDelta(child),
|
||||
},
|
||||
type: 'text',
|
||||
},
|
||||
children: [],
|
||||
}
|
||||
);
|
||||
const column = columns[index];
|
||||
if (!column) {
|
||||
return;
|
||||
}
|
||||
row[column.id] = {
|
||||
columnId: column.id,
|
||||
value: HastUtils.getTextContent(child),
|
||||
};
|
||||
} else if (HastUtils.querySelector(child, '.cell-title')) {
|
||||
walkerContext.pushGlobalContextStack<BlockSnapshot>(
|
||||
'hast:table:children',
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: 'affine:paragraph',
|
||||
props: {
|
||||
text: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: deltaConverter.astToDelta(child, { pageMap }),
|
||||
},
|
||||
type: 'text',
|
||||
},
|
||||
children: [],
|
||||
}
|
||||
);
|
||||
columns[index].type = 'title';
|
||||
return;
|
||||
}
|
||||
const optionIds: string[] = [];
|
||||
const column = columns[index];
|
||||
if (!column) {
|
||||
return;
|
||||
}
|
||||
if (HastUtils.querySelector(child, '.selected-value')) {
|
||||
if (!('options' in column.data)) {
|
||||
column.data.options = [];
|
||||
}
|
||||
if (!['multi-select', 'select'].includes(column.type)) {
|
||||
column.type = 'select';
|
||||
}
|
||||
if (
|
||||
column.type === 'select' &&
|
||||
child.type === 'element' &&
|
||||
child.children.length > 1
|
||||
) {
|
||||
column.type = 'multi-select';
|
||||
}
|
||||
child.type === 'element' &&
|
||||
child.children.forEach(span => {
|
||||
const filteredArray = column.data.options?.filter(
|
||||
option =>
|
||||
option.value === HastUtils.getTextContent(span)
|
||||
);
|
||||
const id = filteredArray?.length
|
||||
? (filteredArray[0]?.id ?? nanoid())
|
||||
: nanoid();
|
||||
if (!filteredArray?.length) {
|
||||
column.data.options?.push({
|
||||
id,
|
||||
value: HastUtils.getTextContent(span),
|
||||
color: getTagColor(),
|
||||
});
|
||||
}
|
||||
optionIds.push(id);
|
||||
});
|
||||
// Expand will be done when leaving the table
|
||||
row[column.id] = {
|
||||
columnId: column.id,
|
||||
value: optionIds,
|
||||
};
|
||||
} else if (HastUtils.querySelector(child, '.checkbox')) {
|
||||
if (column.type !== 'checkbox') {
|
||||
column.type = 'checkbox';
|
||||
}
|
||||
row[column.id] = {
|
||||
columnId: column.id,
|
||||
value: HastUtils.querySelector(child, '.checkbox-on')
|
||||
? true
|
||||
: false,
|
||||
};
|
||||
} else if (column.type === 'number') {
|
||||
const text = HastUtils.getTextContent(child);
|
||||
const number = Number(text);
|
||||
if (Number.isNaN(number)) {
|
||||
column.type = 'rich-text';
|
||||
row[column.id] = {
|
||||
columnId: column.id,
|
||||
value: AdapterTextUtils.createText(text),
|
||||
};
|
||||
} else {
|
||||
row[column.id] = {
|
||||
columnId: column.id,
|
||||
value: number,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
row[column.id] = {
|
||||
columnId: column.id,
|
||||
value: HastUtils.getTextContent(child),
|
||||
};
|
||||
}
|
||||
if (
|
||||
column.type === 'rich-text' &&
|
||||
!AdapterTextUtils.isText(row[column.id].value)
|
||||
) {
|
||||
row[column.id] = {
|
||||
columnId: column.id,
|
||||
value: AdapterTextUtils.createText(row[column.id].value),
|
||||
};
|
||||
}
|
||||
});
|
||||
walkerContext.setGlobalContextStack('hast:table:column', columns);
|
||||
walkerContext.pushGlobalContextStack('hast:table:rows', row);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
leave: (o, context) => {
|
||||
if (!HastUtils.isElement(o.node)) {
|
||||
return;
|
||||
}
|
||||
const { walkerContext } = context;
|
||||
switch (o.node.tagName) {
|
||||
case 'table': {
|
||||
const columns =
|
||||
walkerContext.getGlobalContextStack<BlocksuiteTableColumn>(
|
||||
'hast:table:column'
|
||||
);
|
||||
walkerContext.setGlobalContextStack('hast:table:column', []);
|
||||
const children = walkerContext.getGlobalContextStack<BlockSnapshot>(
|
||||
'hast:table:children'
|
||||
);
|
||||
walkerContext.setGlobalContextStack('hast:table:children', []);
|
||||
const cells = Object.create(null);
|
||||
walkerContext
|
||||
.getGlobalContextStack<BlocksuiteTableRow>('hast:table:rows')
|
||||
.forEach((row, i) => {
|
||||
Object.keys(row).forEach(columnId => {
|
||||
const cell = row[columnId];
|
||||
if (!cell) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
columns.find(column => column.id === columnId)?.type ===
|
||||
'select'
|
||||
) {
|
||||
cell.value = (cell.value as string[])[0];
|
||||
}
|
||||
});
|
||||
cells[children.at(i)?.id ?? nanoid()] = row;
|
||||
});
|
||||
walkerContext.setGlobalContextStack('hast:table:cells', []);
|
||||
let databaseTitle = '';
|
||||
if (
|
||||
o.parent?.node.type === 'element' &&
|
||||
HastUtils.querySelector(o.parent.node, NotionDatabaseToken)
|
||||
) {
|
||||
databaseTitle = HastUtils.getTextContent(
|
||||
HastUtils.querySelector(o.parent.node, NotionDatabaseTitleToken)
|
||||
);
|
||||
}
|
||||
walkerContext.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: DatabaseBlockSchema.model.flavour,
|
||||
props: {
|
||||
views: [
|
||||
{
|
||||
id: nanoid(),
|
||||
name: 'Table View',
|
||||
mode: 'table',
|
||||
columns: [],
|
||||
filter: {
|
||||
type: 'group',
|
||||
op: 'and',
|
||||
conditions: [],
|
||||
},
|
||||
header: {
|
||||
titleColumn:
|
||||
columns.find(column => column.type === 'title')?.id ??
|
||||
'',
|
||||
iconColumn: 'type',
|
||||
},
|
||||
},
|
||||
],
|
||||
title: {
|
||||
'$blocksuite:internal:text$': true,
|
||||
delta: databaseTitle
|
||||
? [
|
||||
{
|
||||
insert: databaseTitle,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
columns,
|
||||
cells,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
);
|
||||
children.forEach(child => {
|
||||
walkerContext.openNode(child, 'children').closeNode();
|
||||
});
|
||||
walkerContext.closeNode();
|
||||
walkerContext.cleanGlobalContextStack('hast:table:column');
|
||||
walkerContext.cleanGlobalContextStack('hast:table:rows');
|
||||
walkerContext.cleanGlobalContextStack('hast:table:children');
|
||||
break;
|
||||
}
|
||||
case 'th': {
|
||||
walkerContext.setGlobalContext('hast:disableimg', false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
fromBlockSnapshot: {},
|
||||
};
|
||||
|
||||
export const DatabaseBlockNotionHtmlAdapterExtension =
|
||||
BlockNotionHtmlAdapterExtension(databaseBlockNotionHtmlAdapterMatcher);
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
type Column,
|
||||
DatabaseBlockSchema,
|
||||
type SerializedCells,
|
||||
} from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockPlainTextAdapterExtension,
|
||||
type BlockPlainTextAdapterMatcher,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
import { formatTable, processTable } from './utils.js';
|
||||
|
||||
export const databaseBlockPlainTextAdapterMatcher: BlockPlainTextAdapterMatcher =
|
||||
{
|
||||
flavour: DatabaseBlockSchema.model.flavour,
|
||||
toMatch: () => false,
|
||||
fromMatch: o => o.node.flavour === DatabaseBlockSchema.model.flavour,
|
||||
toBlockSnapshot: {},
|
||||
fromBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
const { walkerContext, deltaConverter } = context;
|
||||
const rows: string[][] = [];
|
||||
const columns = o.node.props.columns as Array<Column>;
|
||||
const children = o.node.children;
|
||||
const cells = o.node.props.cells as SerializedCells;
|
||||
const table = processTable(columns, children, cells);
|
||||
rows.push(table.headers.map(v => v.name));
|
||||
table.rows.forEach(v => {
|
||||
rows.push(
|
||||
v.cells.map(v =>
|
||||
typeof v.value === 'string'
|
||||
? v.value
|
||||
: deltaConverter.deltaToAST(v.value.delta).join('')
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
const tableString = formatTable(rows);
|
||||
|
||||
context.textBuffer.content += tableString;
|
||||
context.textBuffer.content += '\n';
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const DatabaseBlockPlainTextAdapterExtension =
|
||||
BlockPlainTextAdapterExtension(databaseBlockPlainTextAdapterMatcher);
|
||||
110
blocksuite/affine/blocks/block-database/src/adapters/utils.ts
Normal file
110
blocksuite/affine/blocks/block-database/src/adapters/utils.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { Column, SerializedCells } from '@blocksuite/affine-model';
|
||||
import type { DeltaInsert } from '@blocksuite/inline';
|
||||
import type { BlockSnapshot } from '@blocksuite/store';
|
||||
|
||||
import { databaseBlockModels } from '../properties/model';
|
||||
|
||||
function calculateColumnWidths(rows: string[][]): number[] {
|
||||
return (
|
||||
rows[0]?.map((_, colIndex) =>
|
||||
Math.max(...rows.map(row => (row[colIndex] || '').length))
|
||||
) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
function formatRow(
|
||||
row: string[],
|
||||
columnWidths: number[],
|
||||
isHeader: boolean
|
||||
): string {
|
||||
const cells = row.map((cell, colIndex) =>
|
||||
cell?.padEnd(columnWidths[colIndex] ?? 0, ' ')
|
||||
);
|
||||
const rowString = `| ${cells.join(' | ')} |`;
|
||||
return isHeader
|
||||
? `${rowString}\n${formatSeparator(columnWidths)}`
|
||||
: rowString;
|
||||
}
|
||||
|
||||
function formatSeparator(columnWidths: number[]): string {
|
||||
const separator = columnWidths.map(width => '-'.repeat(width)).join(' | ');
|
||||
return `| ${separator} |`;
|
||||
}
|
||||
|
||||
export function formatTable(rows: string[][]): string {
|
||||
const columnWidths = calculateColumnWidths(rows);
|
||||
const formattedRows = rows.map((row, index) =>
|
||||
formatRow(row, columnWidths, index === 0)
|
||||
);
|
||||
return formattedRows.join('\n');
|
||||
}
|
||||
export const isDelta = (value: unknown): value is { delta: DeltaInsert[] } => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return '$blocksuite:internal:text$' in value;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
type Table = {
|
||||
headers: Column[];
|
||||
rows: Row[];
|
||||
};
|
||||
type Row = {
|
||||
cells: Cell[];
|
||||
};
|
||||
type Cell = {
|
||||
value: string | { delta: DeltaInsert[] };
|
||||
};
|
||||
export const processTable = (
|
||||
columns: Column[],
|
||||
children: BlockSnapshot[],
|
||||
cells: SerializedCells
|
||||
): Table => {
|
||||
const table: Table = {
|
||||
headers: columns,
|
||||
rows: [],
|
||||
};
|
||||
children.forEach(v => {
|
||||
const row: Row = {
|
||||
cells: [],
|
||||
};
|
||||
const title = v.props.text;
|
||||
if (isDelta(title)) {
|
||||
row.cells.push({
|
||||
value: title,
|
||||
});
|
||||
} else {
|
||||
row.cells.push({
|
||||
value: '',
|
||||
});
|
||||
}
|
||||
|
||||
columns.forEach(col => {
|
||||
const property = databaseBlockModels[col.type];
|
||||
const cell = cells[v.id]?.[col.id];
|
||||
if (col.type === 'title') {
|
||||
return;
|
||||
}
|
||||
if (!cell || !property) {
|
||||
row.cells.push({
|
||||
value: '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
let value: string | { delta: DeltaInsert[] };
|
||||
if (isDelta(cell.value)) {
|
||||
value = cell.value;
|
||||
} else {
|
||||
value = property.config.cellToString({
|
||||
value: cell.value,
|
||||
data: col.data,
|
||||
});
|
||||
}
|
||||
row.cells.push({
|
||||
value,
|
||||
});
|
||||
});
|
||||
table.rows.push(row);
|
||||
});
|
||||
|
||||
return table;
|
||||
};
|
||||
46
blocksuite/affine/blocks/block-database/src/block-icons.ts
Normal file
46
blocksuite/affine/blocks/block-database/src/block-icons.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { ParagraphType } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BulletedListIcon,
|
||||
CheckBoxCheckLinearIcon,
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
Heading4Icon,
|
||||
Heading5Icon,
|
||||
Heading6Icon,
|
||||
NumberedListIcon,
|
||||
QuoteIcon,
|
||||
TextIcon,
|
||||
} from '@blocksuite/icons/lit';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
export const getIcon = (
|
||||
model: BlockModel & { type?: string }
|
||||
): TemplateResult => {
|
||||
if (model.flavour === 'affine:paragraph') {
|
||||
const type = model.type as ParagraphType;
|
||||
return (
|
||||
{
|
||||
text: TextIcon(),
|
||||
quote: QuoteIcon(),
|
||||
h1: Heading1Icon(),
|
||||
h2: Heading2Icon(),
|
||||
h3: Heading3Icon(),
|
||||
h4: Heading4Icon(),
|
||||
h5: Heading5Icon(),
|
||||
h6: Heading6Icon(),
|
||||
} as Record<ParagraphType, TemplateResult>
|
||||
)[type];
|
||||
}
|
||||
if (model.flavour === 'affine:list') {
|
||||
return (
|
||||
{
|
||||
bulleted: BulletedListIcon(),
|
||||
numbered: NumberedListIcon(),
|
||||
todo: CheckBoxCheckLinearIcon(),
|
||||
}[model.type ?? 'bulleted'] ?? BulletedListIcon()
|
||||
);
|
||||
}
|
||||
return TextIcon();
|
||||
};
|
||||
69
blocksuite/affine/blocks/block-database/src/commands.ts
Normal file
69
blocksuite/affine/blocks/block-database/src/commands.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { DatabaseBlockModel } from '@blocksuite/affine-model';
|
||||
import type { Command } from '@blocksuite/block-std';
|
||||
import type { BlockModel, Store } from '@blocksuite/store';
|
||||
|
||||
import {
|
||||
DatabaseBlockDataSource,
|
||||
databaseViewInitTemplate,
|
||||
} from './data-source';
|
||||
|
||||
export const insertDatabaseBlockCommand: Command<
|
||||
{
|
||||
selectedModels?: BlockModel[];
|
||||
viewType: string;
|
||||
place?: 'after' | 'before';
|
||||
removeEmptyLine?: boolean;
|
||||
},
|
||||
{
|
||||
insertedDatabaseBlockId: string;
|
||||
}
|
||||
> = (ctx, next) => {
|
||||
const { selectedModels, viewType, place, removeEmptyLine, std } = ctx;
|
||||
if (!selectedModels?.length) return;
|
||||
|
||||
const targetModel =
|
||||
place === 'before'
|
||||
? selectedModels[0]
|
||||
: selectedModels[selectedModels.length - 1];
|
||||
|
||||
if (!targetModel) return;
|
||||
|
||||
const result = std.store.addSiblingBlocks(
|
||||
targetModel,
|
||||
[{ flavour: 'affine:database' }],
|
||||
place
|
||||
);
|
||||
const string = result[0];
|
||||
|
||||
if (string == null) return;
|
||||
|
||||
initDatabaseBlock(std.store, targetModel, string, viewType, false);
|
||||
|
||||
if (removeEmptyLine && targetModel.text?.length === 0) {
|
||||
std.store.deleteBlock(targetModel);
|
||||
}
|
||||
|
||||
next({ insertedDatabaseBlockId: string });
|
||||
};
|
||||
|
||||
export const initDatabaseBlock = (
|
||||
doc: Store,
|
||||
model: BlockModel,
|
||||
databaseId: string,
|
||||
viewType: string,
|
||||
isAppendNewRow = true
|
||||
) => {
|
||||
const blockModel = doc.getBlock(databaseId)?.model as
|
||||
| DatabaseBlockModel
|
||||
| undefined;
|
||||
if (!blockModel) {
|
||||
return;
|
||||
}
|
||||
const datasource = new DatabaseBlockDataSource(blockModel);
|
||||
databaseViewInitTemplate(datasource, viewType);
|
||||
if (isAppendNewRow) {
|
||||
const parent = doc.getParent(model);
|
||||
if (!parent) return;
|
||||
doc.addBlock('affine:paragraph', {}, parent.id);
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user