mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-18 18:41:52 +08:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export * from './presets/affine-inline-specs.js';
|
||||
export * from './presets/markdown.js';
|
||||
export * from './presets/nodes/index.js';
|
||||
@@ -0,0 +1,193 @@
|
||||
import { ReferenceInfoSchema } from '@blocksuite/affine-model';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import { StdIdentifier } from '@blocksuite/block-std';
|
||||
import type { InlineEditor, InlineRootElement } from '@blocksuite/inline';
|
||||
import { html } from 'lit';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { InlineSpecExtension } from '../../extension/index.js';
|
||||
import {
|
||||
ReferenceNodeConfigIdentifier,
|
||||
ReferenceNodeConfigProvider,
|
||||
} from './nodes/reference-node/reference-config.js';
|
||||
|
||||
export type AffineInlineEditor = InlineEditor<AffineTextAttributes>;
|
||||
export type AffineInlineRootElement = InlineRootElement<AffineTextAttributes>;
|
||||
|
||||
export const BoldInlineSpecExtension = InlineSpecExtension({
|
||||
name: 'bold',
|
||||
schema: z.literal(true).optional().nullable().catch(undefined),
|
||||
match: delta => {
|
||||
return !!delta.attributes?.bold;
|
||||
},
|
||||
renderer: ({ delta }) => {
|
||||
return html`<affine-text .delta=${delta}></affine-text>`;
|
||||
},
|
||||
});
|
||||
|
||||
export const ItalicInlineSpecExtension = InlineSpecExtension({
|
||||
name: 'italic',
|
||||
schema: z.literal(true).optional().nullable().catch(undefined),
|
||||
match: delta => {
|
||||
return !!delta.attributes?.italic;
|
||||
},
|
||||
renderer: ({ delta }) => {
|
||||
return html`<affine-text .delta=${delta}></affine-text>`;
|
||||
},
|
||||
});
|
||||
|
||||
export const UnderlineInlineSpecExtension = InlineSpecExtension({
|
||||
name: 'underline',
|
||||
schema: z.literal(true).optional().nullable().catch(undefined),
|
||||
match: delta => {
|
||||
return !!delta.attributes?.underline;
|
||||
},
|
||||
renderer: ({ delta }) => {
|
||||
return html`<affine-text .delta=${delta}></affine-text>`;
|
||||
},
|
||||
});
|
||||
|
||||
export const StrikeInlineSpecExtension = InlineSpecExtension({
|
||||
name: 'strike',
|
||||
schema: z.literal(true).optional().nullable().catch(undefined),
|
||||
match: delta => {
|
||||
return !!delta.attributes?.strike;
|
||||
},
|
||||
renderer: ({ delta }) => {
|
||||
return html`<affine-text .delta=${delta}></affine-text>`;
|
||||
},
|
||||
});
|
||||
|
||||
export const CodeInlineSpecExtension = InlineSpecExtension({
|
||||
name: 'code',
|
||||
schema: z.literal(true).optional().nullable().catch(undefined),
|
||||
match: delta => {
|
||||
return !!delta.attributes?.code;
|
||||
},
|
||||
renderer: ({ delta }) => {
|
||||
return html`<affine-text .delta=${delta}></affine-text>`;
|
||||
},
|
||||
});
|
||||
|
||||
export const BackgroundInlineSpecExtension = InlineSpecExtension({
|
||||
name: 'background',
|
||||
schema: z.string().optional().nullable().catch(undefined),
|
||||
match: delta => {
|
||||
return !!delta.attributes?.background;
|
||||
},
|
||||
renderer: ({ delta }) => {
|
||||
return html`<affine-text .delta=${delta}></affine-text>`;
|
||||
},
|
||||
});
|
||||
|
||||
export const ColorInlineSpecExtension = InlineSpecExtension({
|
||||
name: 'color',
|
||||
schema: z.string().optional().nullable().catch(undefined),
|
||||
match: delta => {
|
||||
return !!delta.attributes?.color;
|
||||
},
|
||||
renderer: ({ delta }) => {
|
||||
return html`<affine-text .delta=${delta}></affine-text>`;
|
||||
},
|
||||
});
|
||||
|
||||
export const LatexInlineSpecExtension = InlineSpecExtension(
|
||||
'latex',
|
||||
provider => {
|
||||
const std = provider.get(StdIdentifier);
|
||||
return {
|
||||
name: 'latex',
|
||||
schema: z.string().optional().nullable().catch(undefined),
|
||||
match: delta => typeof delta.attributes?.latex === 'string',
|
||||
renderer: ({ delta, selected, editor, startOffset, endOffset }) => {
|
||||
return html`<affine-latex-node
|
||||
.std=${std}
|
||||
.delta=${delta}
|
||||
.selected=${selected}
|
||||
.editor=${editor}
|
||||
.startOffset=${startOffset}
|
||||
.endOffset=${endOffset}
|
||||
></affine-latex-node>`;
|
||||
},
|
||||
embed: true,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const ReferenceInlineSpecExtension = InlineSpecExtension(
|
||||
'reference',
|
||||
provider => {
|
||||
const std = provider.get(StdIdentifier);
|
||||
const configProvider = new ReferenceNodeConfigProvider(std);
|
||||
const config = provider.getOptional(ReferenceNodeConfigIdentifier) ?? {};
|
||||
if (config.customContent) {
|
||||
configProvider.setCustomContent(config.customContent);
|
||||
}
|
||||
if (config.interactable !== undefined) {
|
||||
configProvider.setInteractable(config.interactable);
|
||||
}
|
||||
if (config.hidePopup !== undefined) {
|
||||
configProvider.setHidePopup(config.hidePopup);
|
||||
}
|
||||
return {
|
||||
name: 'reference',
|
||||
schema: z
|
||||
.object({
|
||||
type: z.enum([
|
||||
// @deprecated Subpage is deprecated, use LinkedPage instead
|
||||
'Subpage',
|
||||
'LinkedPage',
|
||||
]),
|
||||
})
|
||||
.merge(ReferenceInfoSchema)
|
||||
.optional()
|
||||
.nullable()
|
||||
.catch(undefined),
|
||||
match: delta => {
|
||||
return !!delta.attributes?.reference;
|
||||
},
|
||||
renderer: ({ delta, selected }) => {
|
||||
return html`<affine-reference
|
||||
.delta=${delta}
|
||||
.selected=${selected}
|
||||
.config=${configProvider}
|
||||
></affine-reference>`;
|
||||
},
|
||||
embed: true,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export const LinkInlineSpecExtension = InlineSpecExtension({
|
||||
name: 'link',
|
||||
schema: z.string().optional().nullable().catch(undefined),
|
||||
match: delta => {
|
||||
return !!delta.attributes?.link;
|
||||
},
|
||||
renderer: ({ delta }) => {
|
||||
return html`<affine-link .delta=${delta}></affine-link>`;
|
||||
},
|
||||
});
|
||||
|
||||
export const LatexEditorUnitSpecExtension = InlineSpecExtension({
|
||||
name: 'latex-editor-unit',
|
||||
schema: z.undefined(),
|
||||
match: () => true,
|
||||
renderer: ({ delta }) => {
|
||||
return html`<latex-editor-unit .delta=${delta}></latex-editor-unit>`;
|
||||
},
|
||||
});
|
||||
|
||||
export const InlineSpecExtensions = [
|
||||
BoldInlineSpecExtension,
|
||||
ItalicInlineSpecExtension,
|
||||
UnderlineInlineSpecExtension,
|
||||
StrikeInlineSpecExtension,
|
||||
CodeInlineSpecExtension,
|
||||
BackgroundInlineSpecExtension,
|
||||
ColorInlineSpecExtension,
|
||||
LatexInlineSpecExtension,
|
||||
ReferenceInlineSpecExtension,
|
||||
LinkInlineSpecExtension,
|
||||
LatexEditorUnitSpecExtension,
|
||||
];
|
||||
@@ -0,0 +1,608 @@
|
||||
/* eslint-disable no-useless-escape */
|
||||
import type { BlockComponent, ExtensionType } from '@blocksuite/block-std';
|
||||
import {
|
||||
KEYBOARD_ALLOW_DEFAULT,
|
||||
KEYBOARD_PREVENT_DEFAULT,
|
||||
} from '@blocksuite/inline';
|
||||
|
||||
import { InlineMarkdownExtension } from '../../extension/markdown-matcher.js';
|
||||
|
||||
// inline markdown match rules:
|
||||
// covert: ***test*** + space
|
||||
// covert: ***t est*** + space
|
||||
// not convert: *** test*** + space
|
||||
// not convert: ***test *** + space
|
||||
// not convert: *** test *** + space
|
||||
|
||||
export const BoldItalicMarkdown = InlineMarkdownExtension({
|
||||
name: 'bolditalic',
|
||||
pattern: /(?:\*\*\*)([^\s\*](?:[^*]*?[^\s\*])?)(?:\*\*\*)$/g,
|
||||
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
|
||||
const match = pattern.exec(prefixText);
|
||||
if (!match) {
|
||||
return KEYBOARD_ALLOW_DEFAULT;
|
||||
}
|
||||
|
||||
const annotatedText = match[0];
|
||||
const startIndex = inlineRange.index - annotatedText.length;
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: startIndex + annotatedText.length,
|
||||
length: 0,
|
||||
},
|
||||
' '
|
||||
);
|
||||
inlineEditor.setInlineRange({
|
||||
index: startIndex + annotatedText.length + 1,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
undoManager.stopCapturing();
|
||||
|
||||
inlineEditor.formatText(
|
||||
{
|
||||
index: startIndex,
|
||||
length: annotatedText.length,
|
||||
},
|
||||
{
|
||||
bold: true,
|
||||
italic: true,
|
||||
}
|
||||
);
|
||||
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex + annotatedText.length,
|
||||
length: 1,
|
||||
});
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex + annotatedText.length - 3,
|
||||
length: 3,
|
||||
});
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex,
|
||||
length: 3,
|
||||
});
|
||||
|
||||
inlineEditor.setInlineRange({
|
||||
index: startIndex + annotatedText.length - 6,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
return KEYBOARD_PREVENT_DEFAULT;
|
||||
},
|
||||
});
|
||||
|
||||
export const BoldMarkdown = InlineMarkdownExtension({
|
||||
name: 'bold',
|
||||
pattern: /(?:\*\*)([^\s\*](?:[^*]*?[^\s\*])?)(?:\*\*)$/g,
|
||||
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
|
||||
const match = pattern.exec(prefixText);
|
||||
if (!match) {
|
||||
return KEYBOARD_ALLOW_DEFAULT;
|
||||
}
|
||||
const annotatedText = match[0];
|
||||
const startIndex = inlineRange.index - annotatedText.length;
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: startIndex + annotatedText.length,
|
||||
length: 0,
|
||||
},
|
||||
' '
|
||||
);
|
||||
inlineEditor.setInlineRange({
|
||||
index: startIndex + annotatedText.length + 1,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
undoManager.stopCapturing();
|
||||
|
||||
inlineEditor.formatText(
|
||||
{
|
||||
index: startIndex,
|
||||
length: annotatedText.length,
|
||||
},
|
||||
{
|
||||
bold: true,
|
||||
}
|
||||
);
|
||||
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex + annotatedText.length,
|
||||
length: 1,
|
||||
});
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex + annotatedText.length - 2,
|
||||
length: 2,
|
||||
});
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex,
|
||||
length: 2,
|
||||
});
|
||||
|
||||
inlineEditor.setInlineRange({
|
||||
index: startIndex + annotatedText.length - 4,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
return KEYBOARD_PREVENT_DEFAULT;
|
||||
},
|
||||
});
|
||||
|
||||
export const ItalicExtension = InlineMarkdownExtension({
|
||||
name: 'italic',
|
||||
pattern: /(?:\*)([^\s\*](?:[^*]*?[^\s\*])?)(?:\*)$/g,
|
||||
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
|
||||
const match = pattern.exec(prefixText);
|
||||
if (!match) {
|
||||
return KEYBOARD_ALLOW_DEFAULT;
|
||||
}
|
||||
const annotatedText = match[0];
|
||||
const startIndex = inlineRange.index - annotatedText.length;
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: startIndex + annotatedText.length,
|
||||
length: 0,
|
||||
},
|
||||
' '
|
||||
);
|
||||
inlineEditor.setInlineRange({
|
||||
index: startIndex + annotatedText.length + 1,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
undoManager.stopCapturing();
|
||||
|
||||
inlineEditor.formatText(
|
||||
{
|
||||
index: startIndex,
|
||||
length: annotatedText.length,
|
||||
},
|
||||
{
|
||||
italic: true,
|
||||
}
|
||||
);
|
||||
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex + annotatedText.length,
|
||||
length: 1,
|
||||
});
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex + annotatedText.length - 1,
|
||||
length: 1,
|
||||
});
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex,
|
||||
length: 1,
|
||||
});
|
||||
|
||||
inlineEditor.setInlineRange({
|
||||
index: startIndex + annotatedText.length - 2,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
return KEYBOARD_PREVENT_DEFAULT;
|
||||
},
|
||||
});
|
||||
|
||||
export const StrikethroughExtension = InlineMarkdownExtension({
|
||||
name: 'strikethrough',
|
||||
pattern: /(?:~~)([^\s~](?:[^~]*?[^\s~])?)(?:~~)$/g,
|
||||
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
|
||||
const match = pattern.exec(prefixText);
|
||||
if (!match) {
|
||||
return KEYBOARD_ALLOW_DEFAULT;
|
||||
}
|
||||
const annotatedText = match[0];
|
||||
const startIndex = inlineRange.index - annotatedText.length;
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: startIndex + annotatedText.length,
|
||||
length: 0,
|
||||
},
|
||||
' '
|
||||
);
|
||||
inlineEditor.setInlineRange({
|
||||
index: startIndex + annotatedText.length + 1,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
undoManager.stopCapturing();
|
||||
|
||||
inlineEditor.formatText(
|
||||
{
|
||||
index: startIndex,
|
||||
length: annotatedText.length,
|
||||
},
|
||||
{
|
||||
strike: true,
|
||||
}
|
||||
);
|
||||
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex + annotatedText.length,
|
||||
length: 1,
|
||||
});
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex + annotatedText.length - 2,
|
||||
length: 2,
|
||||
});
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex,
|
||||
length: 2,
|
||||
});
|
||||
|
||||
inlineEditor.setInlineRange({
|
||||
index: startIndex + annotatedText.length - 4,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
return KEYBOARD_PREVENT_DEFAULT;
|
||||
},
|
||||
});
|
||||
|
||||
export const UnderthroughExtension = InlineMarkdownExtension({
|
||||
name: 'underthrough',
|
||||
pattern: /(?:~)([^\s~](?:[^~]*?[^\s~])?)(?:~)$/g,
|
||||
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
|
||||
const match = pattern.exec(prefixText);
|
||||
if (!match) {
|
||||
return KEYBOARD_ALLOW_DEFAULT;
|
||||
}
|
||||
const annotatedText = match[0];
|
||||
const startIndex = inlineRange.index - annotatedText.length;
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: startIndex + annotatedText.length,
|
||||
length: 0,
|
||||
},
|
||||
' '
|
||||
);
|
||||
inlineEditor.setInlineRange({
|
||||
index: startIndex + annotatedText.length + 1,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
undoManager.stopCapturing();
|
||||
|
||||
inlineEditor.formatText(
|
||||
{
|
||||
index: startIndex,
|
||||
length: annotatedText.length,
|
||||
},
|
||||
{
|
||||
underline: true,
|
||||
}
|
||||
);
|
||||
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex + annotatedText.length,
|
||||
length: 1,
|
||||
});
|
||||
inlineEditor.deleteText({
|
||||
index: inlineRange.index - 1,
|
||||
length: 1,
|
||||
});
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex,
|
||||
length: 1,
|
||||
});
|
||||
|
||||
inlineEditor.setInlineRange({
|
||||
index: startIndex + annotatedText.length - 2,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
return KEYBOARD_PREVENT_DEFAULT;
|
||||
},
|
||||
});
|
||||
|
||||
export const CodeExtension = InlineMarkdownExtension({
|
||||
name: 'code',
|
||||
pattern: /(?:`)([^\s`](?:[^`]*?[^\s`])?)(?:`)$/g,
|
||||
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
|
||||
const match = pattern.exec(prefixText);
|
||||
if (!match) {
|
||||
return KEYBOARD_ALLOW_DEFAULT;
|
||||
}
|
||||
const annotatedText = match[0];
|
||||
const startIndex = inlineRange.index - annotatedText.length;
|
||||
|
||||
if (prefixText.match(/^([* \n]+)$/g)) {
|
||||
return KEYBOARD_ALLOW_DEFAULT;
|
||||
}
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: startIndex + annotatedText.length,
|
||||
length: 0,
|
||||
},
|
||||
' '
|
||||
);
|
||||
inlineEditor.setInlineRange({
|
||||
index: startIndex + annotatedText.length + 1,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
undoManager.stopCapturing();
|
||||
|
||||
inlineEditor.formatText(
|
||||
{
|
||||
index: startIndex,
|
||||
length: annotatedText.length,
|
||||
},
|
||||
{
|
||||
code: true,
|
||||
}
|
||||
);
|
||||
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex + annotatedText.length,
|
||||
length: 1,
|
||||
});
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex + annotatedText.length - 1,
|
||||
length: 1,
|
||||
});
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex,
|
||||
length: 1,
|
||||
});
|
||||
|
||||
inlineEditor.setInlineRange({
|
||||
index: startIndex + annotatedText.length - 2,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
return KEYBOARD_PREVENT_DEFAULT;
|
||||
},
|
||||
});
|
||||
|
||||
export const LinkExtension = InlineMarkdownExtension({
|
||||
name: 'link',
|
||||
pattern: /(?:\[(.+?)\])(?:\((.+?)\))$/g,
|
||||
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
|
||||
const startIndex = prefixText.search(pattern);
|
||||
const matchedText = prefixText.match(pattern)?.[0];
|
||||
const hrefText = prefixText.match(/(?:\[(.*?)\])/g)?.[0];
|
||||
const hrefLink = prefixText.match(/(?:\((.*?)\))/g)?.[0];
|
||||
if (startIndex === -1 || !matchedText || !hrefText || !hrefLink) {
|
||||
return KEYBOARD_ALLOW_DEFAULT;
|
||||
}
|
||||
const start = inlineRange.index - matchedText.length;
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: inlineRange.index,
|
||||
length: 0,
|
||||
},
|
||||
' '
|
||||
);
|
||||
inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + 1,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
undoManager.stopCapturing();
|
||||
|
||||
inlineEditor.formatText(
|
||||
{
|
||||
index: start,
|
||||
length: hrefText.length,
|
||||
},
|
||||
{
|
||||
link: hrefLink.slice(1, hrefLink.length - 1),
|
||||
}
|
||||
);
|
||||
|
||||
inlineEditor.deleteText({
|
||||
index: inlineRange.index + matchedText.length,
|
||||
length: 1,
|
||||
});
|
||||
inlineEditor.deleteText({
|
||||
index: inlineRange.index - hrefLink.length - 1,
|
||||
length: hrefLink.length + 1,
|
||||
});
|
||||
inlineEditor.deleteText({
|
||||
index: start,
|
||||
length: 1,
|
||||
});
|
||||
|
||||
inlineEditor.setInlineRange({
|
||||
index: start + hrefText.length - 1,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
return KEYBOARD_PREVENT_DEFAULT;
|
||||
},
|
||||
});
|
||||
|
||||
export const LatexExtension = InlineMarkdownExtension({
|
||||
name: 'latex',
|
||||
|
||||
pattern:
|
||||
/(?:\$\$)(?<content>[^\$]+)(?:\$\$)$|(?<blockPrefix>\$\$\$\$)|(?<inlinePrefix>\$\$)$/g,
|
||||
action: ({ inlineEditor, prefixText, inlineRange, pattern, undoManager }) => {
|
||||
const match = pattern.exec(prefixText);
|
||||
if (!match || !match.groups) {
|
||||
return KEYBOARD_ALLOW_DEFAULT;
|
||||
}
|
||||
const content = match.groups['content'];
|
||||
const inlinePrefix = match.groups['inlinePrefix'];
|
||||
const blockPrefix = match.groups['blockPrefix'];
|
||||
|
||||
if (blockPrefix === '$$$$') {
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: inlineRange.index,
|
||||
length: 0,
|
||||
},
|
||||
' '
|
||||
);
|
||||
inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + 1,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
undoManager.stopCapturing();
|
||||
|
||||
const blockComponent =
|
||||
inlineEditor.rootElement.closest<BlockComponent>('[data-block-id]');
|
||||
if (!blockComponent) return KEYBOARD_ALLOW_DEFAULT;
|
||||
|
||||
const doc = blockComponent.doc;
|
||||
const parentComponent = blockComponent.parentComponent;
|
||||
if (!parentComponent) return KEYBOARD_ALLOW_DEFAULT;
|
||||
|
||||
const index = parentComponent.model.children.indexOf(
|
||||
blockComponent.model
|
||||
);
|
||||
if (index === -1) return KEYBOARD_ALLOW_DEFAULT;
|
||||
|
||||
inlineEditor.deleteText({
|
||||
index: inlineRange.index - 4,
|
||||
length: 5,
|
||||
});
|
||||
|
||||
const id = doc.addBlock(
|
||||
'affine:latex',
|
||||
{
|
||||
latex: '',
|
||||
},
|
||||
parentComponent.model,
|
||||
index + 1
|
||||
);
|
||||
blockComponent.host.updateComplete
|
||||
.then(() => {
|
||||
const latexBlock = blockComponent.std.view.getBlock(id);
|
||||
if (!latexBlock || latexBlock.flavour !== 'affine:latex') return;
|
||||
|
||||
//FIXME(@Flrande): wait for refactor
|
||||
// @ts-expect-error FIXME: ts error
|
||||
latexBlock.toggleEditor();
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
return KEYBOARD_PREVENT_DEFAULT;
|
||||
}
|
||||
|
||||
if (inlinePrefix === '$$') {
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: inlineRange.index,
|
||||
length: 0,
|
||||
},
|
||||
' '
|
||||
);
|
||||
inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + 1,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
undoManager.stopCapturing();
|
||||
|
||||
inlineEditor.deleteText({
|
||||
index: inlineRange.index - 2,
|
||||
length: 3,
|
||||
});
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: inlineRange.index - 2,
|
||||
length: 0,
|
||||
},
|
||||
' '
|
||||
);
|
||||
inlineEditor.formatText(
|
||||
{
|
||||
index: inlineRange.index - 2,
|
||||
length: 1,
|
||||
},
|
||||
{
|
||||
latex: '',
|
||||
}
|
||||
);
|
||||
|
||||
inlineEditor
|
||||
.waitForUpdate()
|
||||
.then(async () => {
|
||||
await inlineEditor.waitForUpdate();
|
||||
|
||||
const textPoint = inlineEditor.getTextPoint(
|
||||
inlineRange.index - 2 + 1
|
||||
);
|
||||
if (!textPoint) return;
|
||||
|
||||
const [text] = textPoint;
|
||||
const latexNode = text.parentElement?.closest('affine-latex-node');
|
||||
if (!latexNode) return;
|
||||
|
||||
latexNode.toggleEditor();
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
return KEYBOARD_PREVENT_DEFAULT;
|
||||
}
|
||||
|
||||
if (!content || content.length === 0) {
|
||||
return KEYBOARD_ALLOW_DEFAULT;
|
||||
}
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: inlineRange.index,
|
||||
length: 0,
|
||||
},
|
||||
' '
|
||||
);
|
||||
inlineEditor.setInlineRange({
|
||||
index: inlineRange.index + 1,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
undoManager.stopCapturing();
|
||||
|
||||
const startIndex = inlineRange.index - 2 - content.length - 2;
|
||||
inlineEditor.deleteText({
|
||||
index: startIndex,
|
||||
length: 2 + content.length + 2 + 1,
|
||||
});
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: startIndex,
|
||||
length: 0,
|
||||
},
|
||||
' '
|
||||
);
|
||||
inlineEditor.formatText(
|
||||
{
|
||||
index: startIndex,
|
||||
length: 1,
|
||||
},
|
||||
{
|
||||
latex: String.raw`${content}`,
|
||||
}
|
||||
);
|
||||
|
||||
inlineEditor.setInlineRange({
|
||||
index: startIndex + 1,
|
||||
length: 0,
|
||||
});
|
||||
|
||||
return KEYBOARD_PREVENT_DEFAULT;
|
||||
},
|
||||
});
|
||||
|
||||
export const MarkdownExtensions: ExtensionType[] = [
|
||||
BoldItalicMarkdown,
|
||||
BoldMarkdown,
|
||||
ItalicExtension,
|
||||
StrikethroughExtension,
|
||||
UnderthroughExtension,
|
||||
CodeExtension,
|
||||
LinkExtension,
|
||||
LatexExtension,
|
||||
];
|
||||
@@ -0,0 +1,69 @@
|
||||
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 { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
export function affineTextStyles(
|
||||
props: AffineTextAttributes,
|
||||
override?: Readonly<StyleInfo>
|
||||
): StyleInfo {
|
||||
let textDecorations = '';
|
||||
if (props.underline) {
|
||||
textDecorations += 'underline';
|
||||
}
|
||||
if (props.strike) {
|
||||
textDecorations += ' line-through';
|
||||
}
|
||||
|
||||
let inlineCodeStyle = {};
|
||||
if (props.code) {
|
||||
inlineCodeStyle = {
|
||||
'font-family': 'var(--affine-font-code-family)',
|
||||
background: 'var(--affine-background-code-block)',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
'border-radius': '4px',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
'font-variant-ligatures': 'none',
|
||||
'line-height': 'auto',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
'font-weight': props.bold ? 'bolder' : 'inherit',
|
||||
'font-style': props.italic ? 'italic' : 'normal',
|
||||
'background-color': props.background ? props.background : undefined,
|
||||
color: props.color ? props.color : undefined,
|
||||
'text-decoration': textDecorations.length > 0 ? textDecorations : 'none',
|
||||
...inlineCodeStyle,
|
||||
...override,
|
||||
};
|
||||
}
|
||||
|
||||
export class AffineText extends ShadowlessElement {
|
||||
override render() {
|
||||
const style = this.delta.attributes
|
||||
? affineTextStyles(this.delta.attributes)
|
||||
: {};
|
||||
|
||||
// we need to avoid \n appearing before and after the span element, which will
|
||||
// cause the unexpected space
|
||||
if (this.delta.attributes?.code) {
|
||||
return html`<code style=${styleMap(style)}
|
||||
><v-text .str=${this.delta.insert}></v-text
|
||||
></code>`;
|
||||
}
|
||||
|
||||
// we need to avoid \n appearing before and after the span element, which will
|
||||
// cause the unexpected space
|
||||
return html`<span style=${styleMap(style)}
|
||||
><v-text .str=${this.delta.insert}></v-text
|
||||
></span>`;
|
||||
}
|
||||
|
||||
@property({ type: Object })
|
||||
accessor delta: DeltaInsert<AffineTextAttributes> = {
|
||||
insert: ZERO_WIDTH_SPACE,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const REFERENCE_NODE = ' ';
|
||||
export const DEFAULT_DOC_NAME = 'Untitled';
|
||||
@@ -0,0 +1,5 @@
|
||||
export { DEFAULT_DOC_NAME, REFERENCE_NODE } from './consts.js';
|
||||
export { AffineLink, toggleLinkPopup } from './link-node/index.js';
|
||||
export * from './reference-node/reference-config.js';
|
||||
export { AffineReference } from './reference-node/reference-node.js';
|
||||
export type { RefNodeSlots } from './reference-node/types.js';
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
import { ColorScheme } from '@blocksuite/affine-model';
|
||||
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { unsafeCSSVar } from '@blocksuite/affine-shared/theme';
|
||||
import { type BlockStdScope, ShadowlessElement } from '@blocksuite/block-std';
|
||||
import { noop, SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import { DoneIcon } from '@blocksuite/icons/lit';
|
||||
import type { Y } from '@blocksuite/store';
|
||||
import { DocCollection } from '@blocksuite/store';
|
||||
import { effect, type Signal, signal } from '@preact/signals-core';
|
||||
import { css, html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { codeToTokensBase, type ThemedToken } from 'shiki';
|
||||
|
||||
import { InlineManagerExtension } from '../../../../extension/index.js';
|
||||
import { LatexEditorUnitSpecExtension } from '../../affine-inline-specs.js';
|
||||
|
||||
export const LatexEditorInlineManagerExtension = InlineManagerExtension({
|
||||
id: 'latex-inline-editor',
|
||||
enableMarkdown: false,
|
||||
specs: [LatexEditorUnitSpecExtension.identifier],
|
||||
});
|
||||
|
||||
export class LatexEditorMenu extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
.latex-editor-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: auto auto;
|
||||
grid-template-areas:
|
||||
'editor-box confirm-box'
|
||||
'hint-box hint-box';
|
||||
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
border: 0.5px solid ${unsafeCSSVar('borderColor')};
|
||||
background: ${unsafeCSSVar('backgroundOverlayPanelColor')};
|
||||
|
||||
/* light/toolbarShadow */
|
||||
box-shadow: 0px 6px 16px 0px rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
|
||||
.latex-editor {
|
||||
grid-area: editor-box;
|
||||
width: 280px;
|
||||
padding: 4px 10px;
|
||||
|
||||
border-radius: 4px;
|
||||
background: ${unsafeCSSVar('white10')};
|
||||
|
||||
/* light/activeShadow */
|
||||
box-shadow: 0px 0px 0px 2px rgba(30, 150, 235, 0.3);
|
||||
|
||||
font-family: ${unsafeCSSVar('fontCodeFamily')};
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.latex-editor:focus-within {
|
||||
border: 1px solid ${unsafeCSSVar('blue700')};
|
||||
}
|
||||
|
||||
.latex-editor-confirm {
|
||||
grid-area: confirm-box;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.latex-editor-hint {
|
||||
grid-area: hint-box;
|
||||
padding-top: 6px;
|
||||
|
||||
color: ${unsafeCSSVar('placeholderColor')};
|
||||
|
||||
/* MobileTypeface/caption */
|
||||
font-family: 'SF Pro Text';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 16px; /* 133.333% */
|
||||
letter-spacing: -0.24px;
|
||||
}
|
||||
`;
|
||||
|
||||
highlightTokens$: Signal<ThemedToken[][]> = signal([]);
|
||||
|
||||
yText!: Y.Text;
|
||||
|
||||
get inlineManager() {
|
||||
return this.std.get(LatexEditorInlineManagerExtension.identifier);
|
||||
}
|
||||
|
||||
get richText() {
|
||||
return this.querySelector('rich-text');
|
||||
}
|
||||
|
||||
private _updateHighlightTokens(text: string) {
|
||||
const editorTheme = this.std.get(ThemeProvider).theme;
|
||||
const theme = editorTheme === ColorScheme.Dark ? 'dark-plus' : 'light-plus';
|
||||
|
||||
codeToTokensBase(text, {
|
||||
lang: 'latex',
|
||||
theme,
|
||||
})
|
||||
.then(token => {
|
||||
this.highlightTokens$.value = token;
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
const doc = new DocCollection.Y.Doc();
|
||||
this.yText = doc.getText('latex');
|
||||
this.yText.insert(0, this.latexSignal.value);
|
||||
|
||||
const yTextObserver = () => {
|
||||
const text = this.yText.toString();
|
||||
this.latexSignal.value = text;
|
||||
|
||||
this._updateHighlightTokens(text);
|
||||
};
|
||||
this.yText.observe(yTextObserver);
|
||||
this.disposables.add(() => {
|
||||
this.yText.unobserve(yTextObserver);
|
||||
});
|
||||
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
noop(this.highlightTokens$.value);
|
||||
this.richText?.inlineEditor?.render();
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.add(
|
||||
this.std.get(ThemeProvider).theme$.subscribe(() => {
|
||||
this._updateHighlightTokens(this.yText.toString());
|
||||
})
|
||||
);
|
||||
|
||||
this.disposables.addFromEvent(this, 'keydown', e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.abortController.abort();
|
||||
}
|
||||
});
|
||||
|
||||
this.disposables.addFromEvent(this, 'pointerdown', e => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
this.disposables.addFromEvent(this, 'pointerup', e => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
this.updateComplete
|
||||
.then(async () => {
|
||||
await this.richText?.updateComplete;
|
||||
|
||||
setTimeout(() => {
|
||||
this.richText?.inlineEditor?.focusEnd();
|
||||
});
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`<div class="latex-editor-container">
|
||||
<div class="latex-editor">
|
||||
<rich-text
|
||||
.yText=${this.yText}
|
||||
.attributesSchema=${this.inlineManager.getSchema()}
|
||||
.attributeRenderer=${this.inlineManager.getRenderer()}
|
||||
></rich-text>
|
||||
</div>
|
||||
<div class="latex-editor-confirm">
|
||||
<span @click=${() => this.abortController.abort()}
|
||||
>${DoneIcon({
|
||||
width: '24',
|
||||
height: '24',
|
||||
})}</span
|
||||
>
|
||||
</div>
|
||||
<div class="latex-editor-hint">Shift Enter to line break</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor abortController!: AbortController;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor latexSignal!: Signal<string>;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor std!: BlockStdScope;
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
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';
|
||||
|
||||
export class LatexEditorUnit extends ShadowlessElement {
|
||||
get latexMenu() {
|
||||
return this.closest('latex-editor-menu');
|
||||
}
|
||||
|
||||
get vElement() {
|
||||
return this.closest('v-element');
|
||||
}
|
||||
|
||||
override render() {
|
||||
const plainContent = html`<span
|
||||
><v-text .str=${this.delta.insert}></v-text
|
||||
></span>`;
|
||||
|
||||
const latexMenu = this.latexMenu;
|
||||
const vElement = this.vElement;
|
||||
if (!latexMenu || !vElement) {
|
||||
return plainContent;
|
||||
}
|
||||
|
||||
const lineIndex = this.vElement.lineIndex;
|
||||
const tokens = latexMenu.highlightTokens$.value[lineIndex] ?? [];
|
||||
if (
|
||||
tokens.length === 0 ||
|
||||
tokens.reduce((acc, token) => acc + token.content, '') !==
|
||||
this.delta.insert
|
||||
) {
|
||||
return plainContent;
|
||||
}
|
||||
|
||||
return html`<span
|
||||
>${tokens.map(token => {
|
||||
return html`<v-text
|
||||
.str=${token.content}
|
||||
style=${styleMap({
|
||||
color: token.color,
|
||||
})}
|
||||
></v-text>`;
|
||||
})}</span
|
||||
>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor delta: DeltaInsert<AffineTextAttributes> = {
|
||||
insert: ZERO_WIDTH_SPACE,
|
||||
};
|
||||
}
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
type BlockComponent,
|
||||
type BlockStdScope,
|
||||
ShadowlessElement,
|
||||
} from '@blocksuite/block-std';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
|
||||
import {
|
||||
type DeltaInsert,
|
||||
type InlineEditor,
|
||||
ZERO_WIDTH_NON_JOINER,
|
||||
ZERO_WIDTH_SPACE,
|
||||
} from '@blocksuite/inline';
|
||||
import { effect, signal } from '@preact/signals-core';
|
||||
import katex from 'katex';
|
||||
import { css, html, render } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import { createLitPortal } from '../../../../../portal/helper.js';
|
||||
|
||||
export class AffineLatexNode extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
affine-latex-node {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
affine-latex-node .affine-latex {
|
||||
white-space: nowrap;
|
||||
word-break: break-word;
|
||||
color: ${unsafeCSSVar('textPrimaryColor')};
|
||||
fill: var(--affine-icon-color);
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 1px 2px 1px 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto 0;
|
||||
place-items: center;
|
||||
padding: 0 4px;
|
||||
margin: 0 2px;
|
||||
}
|
||||
affine-latex-node .affine-latex:hover {
|
||||
background: ${unsafeCSSVar('hoverColor')};
|
||||
}
|
||||
affine-latex-node .affine-latex[data-selected='true'] {
|
||||
background: ${unsafeCSSVar('hoverColor')};
|
||||
}
|
||||
|
||||
affine-latex-node .error-placeholder {
|
||||
display: flex;
|
||||
padding: 2px 4px;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
|
||||
border-radius: 4px;
|
||||
background: ${
|
||||
// @ts-expect-error FIXME: ts error
|
||||
unsafeCSSVarV2('label/red')
|
||||
};
|
||||
|
||||
color: ${unsafeCSSVarV2('text/highlight/fg/red')};
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
affine-latex-node .placeholder {
|
||||
display: flex;
|
||||
padding: 2px 4px;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
|
||||
border-radius: 4px;
|
||||
background: ${unsafeCSSVarV2('layer/background/secondary')};
|
||||
|
||||
color: ${unsafeCSSVarV2('text/secondary')};
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: normal;
|
||||
}
|
||||
`;
|
||||
|
||||
private _editorAbortController: AbortController | null = null;
|
||||
|
||||
readonly latex$ = signal('');
|
||||
|
||||
get deltaLatex() {
|
||||
return this.delta.attributes?.latex as string;
|
||||
}
|
||||
|
||||
get latexContainer() {
|
||||
return this.querySelector<HTMLElement>('.latex-container');
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
const result = super.connectedCallback();
|
||||
|
||||
this.latex$.value = this.deltaLatex;
|
||||
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const latex = this.latex$.value;
|
||||
|
||||
if (latex !== this.deltaLatex) {
|
||||
this.editor.formatText(
|
||||
{
|
||||
index: this.startOffset,
|
||||
length: this.endOffset - this.startOffset,
|
||||
},
|
||||
{
|
||||
latex,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
this.updateComplete
|
||||
.then(() => {
|
||||
const latexContainer = this.latexContainer;
|
||||
if (!latexContainer) return;
|
||||
|
||||
latexContainer.replaceChildren();
|
||||
// @ts-expect-error FIXME: ts error
|
||||
delete latexContainer['_$litPart$'];
|
||||
|
||||
if (latex.length === 0) {
|
||||
render(
|
||||
html`<span class="placeholder">Equation</span>`,
|
||||
latexContainer
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
katex.render(latex, latexContainer, {
|
||||
displayMode: true,
|
||||
output: 'mathml',
|
||||
});
|
||||
} catch {
|
||||
latexContainer.replaceChildren();
|
||||
// @ts-expect-error FIXME: ts error
|
||||
delete latexContainer['_$litPart$'];
|
||||
render(
|
||||
html`<span class="error-placeholder">Error equation</span>`,
|
||||
latexContainer
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
})
|
||||
);
|
||||
|
||||
this._editorAbortController?.abort();
|
||||
this._editorAbortController = new AbortController();
|
||||
this.disposables.add(() => {
|
||||
this._editorAbortController?.abort();
|
||||
});
|
||||
|
||||
this.disposables.addFromEvent(this, 'click', e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.toggleEditor();
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`<span class="affine-latex" data-selected=${this.selected}
|
||||
><div class="latex-container"></div>
|
||||
<v-text .str=${ZERO_WIDTH_NON_JOINER}></v-text
|
||||
></span>`;
|
||||
}
|
||||
|
||||
toggleEditor() {
|
||||
const blockComponent = this.closest<BlockComponent>('[data-block-id]');
|
||||
if (!blockComponent) return;
|
||||
|
||||
this._editorAbortController?.abort();
|
||||
this._editorAbortController = new AbortController();
|
||||
|
||||
const portal = createLitPortal({
|
||||
template: html`<latex-editor-menu
|
||||
.std=${this.std}
|
||||
.latexSignal=${this.latex$}
|
||||
.abortController=${this._editorAbortController}
|
||||
></latex-editor-menu>`,
|
||||
container: blockComponent.host,
|
||||
computePosition: {
|
||||
referenceElement: this,
|
||||
placement: 'bottom-start',
|
||||
autoUpdate: {
|
||||
animationFrame: true,
|
||||
},
|
||||
},
|
||||
closeOnClickAway: true,
|
||||
abortController: this._editorAbortController,
|
||||
shadowDom: false,
|
||||
portalStyles: {
|
||||
zIndex: 'var(--affine-z-index-popover)',
|
||||
},
|
||||
});
|
||||
|
||||
this._editorAbortController.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
portal.remove();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor delta: DeltaInsert<AffineTextAttributes> = {
|
||||
insert: ZERO_WIDTH_SPACE,
|
||||
};
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor editor!: InlineEditor<AffineTextAttributes>;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor endOffset!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor selected = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor startOffset!: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor std!: BlockStdScope;
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
import type { ReferenceInfo } from '@blocksuite/affine-model';
|
||||
import { ParseDocUrlProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import type { BlockComponent } from '@blocksuite/block-std';
|
||||
import { BLOCK_ID_ATTR, ShadowlessElement } from '@blocksuite/block-std';
|
||||
import {
|
||||
type DeltaInsert,
|
||||
INLINE_ROOT_ATTR,
|
||||
type InlineRootElement,
|
||||
ZERO_WIDTH_SPACE,
|
||||
} from '@blocksuite/inline';
|
||||
import { css, html } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { ref } from 'lit/directives/ref.js';
|
||||
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { HoverController } from '../../../../../hover/index.js';
|
||||
import { RefNodeSlotsProvider } from '../../../../extension/index.js';
|
||||
import { affineTextStyles } from '../affine-text.js';
|
||||
import { toggleLinkPopup } from './link-popup/toggle-link-popup.js';
|
||||
|
||||
export class AffineLink extends ShadowlessElement {
|
||||
static override styles = css`
|
||||
affine-link a:hover [data-v-text='true'] {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
// The link has been identified.
|
||||
private _identified: boolean = false;
|
||||
|
||||
// see https://github.com/toeverything/AFFiNE/issues/1540
|
||||
private _onMouseUp = () => {
|
||||
const anchorElement = this.querySelector('a');
|
||||
if (!anchorElement || !anchorElement.isContentEditable) return;
|
||||
anchorElement.contentEditable = 'false';
|
||||
setTimeout(() => {
|
||||
anchorElement.removeAttribute('contenteditable');
|
||||
}, 0);
|
||||
};
|
||||
|
||||
private _referenceInfo: ReferenceInfo | null = null;
|
||||
|
||||
openLink = (e?: MouseEvent) => {
|
||||
if (!this._identified) {
|
||||
this._identified = true;
|
||||
this._identify();
|
||||
}
|
||||
|
||||
const referenceInfo = this._referenceInfo;
|
||||
if (!referenceInfo) return;
|
||||
|
||||
const refNodeSlotsProvider = this.std?.getOptional(RefNodeSlotsProvider);
|
||||
if (!refNodeSlotsProvider) return;
|
||||
|
||||
e?.preventDefault();
|
||||
|
||||
refNodeSlotsProvider.docLinkClicked.emit(referenceInfo);
|
||||
};
|
||||
|
||||
private _whenHover = new HoverController(
|
||||
this,
|
||||
({ abortController }) => {
|
||||
if (this.block?.doc.readonly) {
|
||||
return null;
|
||||
}
|
||||
if (!this.inlineEditor || !this.selfInlineRange) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selection = this.std?.selection;
|
||||
const textSelection = selection?.find('text');
|
||||
if (!!textSelection && !textSelection.isCollapsed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const blockSelections = selection?.filter('block');
|
||||
if (blockSelections?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
template: toggleLinkPopup(
|
||||
this.inlineEditor,
|
||||
'view',
|
||||
this.selfInlineRange,
|
||||
abortController,
|
||||
(e?: MouseEvent) => {
|
||||
this.openLink(e);
|
||||
abortController.abort();
|
||||
}
|
||||
),
|
||||
};
|
||||
},
|
||||
{ enterDelay: 500 }
|
||||
);
|
||||
|
||||
// Workaround for links not working in contenteditable div
|
||||
// see also https://stackoverflow.com/questions/12059211/how-to-make-clickable-anchor-in-contenteditable-div
|
||||
//
|
||||
// Note: We cannot use JS to directly open a new page as this may be blocked by the browser.
|
||||
//
|
||||
// Please also note that when readonly mode active,
|
||||
// this workaround is not necessary and links work normally.
|
||||
get block() {
|
||||
const block = this.inlineEditor?.rootElement.closest<BlockComponent>(
|
||||
`[${BLOCK_ID_ATTR}]`
|
||||
);
|
||||
return block;
|
||||
}
|
||||
|
||||
get inlineEditor() {
|
||||
const inlineRoot = this.closest<InlineRootElement<AffineTextAttributes>>(
|
||||
`[${INLINE_ROOT_ATTR}]`
|
||||
);
|
||||
return inlineRoot?.inlineEditor;
|
||||
}
|
||||
|
||||
get link() {
|
||||
return this.delta.attributes?.link ?? '';
|
||||
}
|
||||
|
||||
get selfInlineRange() {
|
||||
const selfInlineRange = this.inlineEditor?.getInlineRangeFromElement(this);
|
||||
return selfInlineRange;
|
||||
}
|
||||
|
||||
get std() {
|
||||
const std = this.block?.std;
|
||||
return std;
|
||||
}
|
||||
|
||||
// Identify if url is an internal link
|
||||
private _identify() {
|
||||
const link = this.link;
|
||||
if (!link) return;
|
||||
|
||||
const result = this.std
|
||||
?.getOptional(ParseDocUrlProvider)
|
||||
?.parseDocUrl(link);
|
||||
if (!result) return;
|
||||
|
||||
const { docId: pageId, ...params } = result;
|
||||
|
||||
this._referenceInfo = { pageId, params };
|
||||
}
|
||||
|
||||
private _renderLink(style: StyleInfo) {
|
||||
return html`<a
|
||||
${ref(this._whenHover.setReference)}
|
||||
href=${this.link}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
style=${styleMap(style)}
|
||||
@click=${this.openLink}
|
||||
@mouseup=${this._onMouseUp}
|
||||
><v-text .str=${this.delta.insert}></v-text
|
||||
></a>`;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const linkStyle = {
|
||||
color: 'var(--affine-link-color)',
|
||||
fill: 'var(--affine-link-color)',
|
||||
'text-decoration': 'none',
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
if (this.delta.attributes && this.delta.attributes?.code) {
|
||||
const codeStyle = affineTextStyles(this.delta.attributes);
|
||||
return html`<code style=${styleMap(codeStyle)}>
|
||||
${this._renderLink(linkStyle)}
|
||||
</code>`;
|
||||
}
|
||||
|
||||
const style = this.delta.attributes
|
||||
? affineTextStyles(this.delta.attributes, linkStyle)
|
||||
: {};
|
||||
|
||||
return this._renderLink(style);
|
||||
}
|
||||
|
||||
@property({ type: Object })
|
||||
accessor delta: DeltaInsert<AffineTextAttributes> = {
|
||||
insert: ZERO_WIDTH_SPACE,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { AffineLink } from './affine-link.js';
|
||||
export { toggleLinkPopup } from './link-popup/toggle-link-popup.js';
|
||||
+689
@@ -0,0 +1,689 @@
|
||||
import {
|
||||
EmbedOptionProvider,
|
||||
type LinkEventType,
|
||||
type TelemetryEvent,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import type { EmbedOptions } from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
getHostName,
|
||||
isValidUrl,
|
||||
normalizeUrl,
|
||||
stopPropagation,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
BLOCK_ID_ATTR,
|
||||
type BlockComponent,
|
||||
type BlockStdScope,
|
||||
} from '@blocksuite/block-std';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import type { InlineRange } from '@blocksuite/inline/types';
|
||||
import { computePosition, inline, offset, shift } from '@floating-ui/dom';
|
||||
import { html, LitElement, nothing } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { choose } from 'lit/directives/choose.js';
|
||||
import { join } from 'lit/directives/join.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import {
|
||||
ConfirmIcon,
|
||||
CopyIcon,
|
||||
DeleteIcon,
|
||||
EditIcon,
|
||||
MoreVerticalIcon,
|
||||
OpenIcon,
|
||||
SmallArrowDownIcon,
|
||||
UnlinkIcon,
|
||||
} from '../../../../../../icons/index.js';
|
||||
import { toast } from '../../../../../../toast/index.js';
|
||||
import type { EditorIconButton } from '../../../../../../toolbar/index.js';
|
||||
import {
|
||||
renderActions,
|
||||
renderToolbarSeparator,
|
||||
} from '../../../../../../toolbar/index.js';
|
||||
import type { AffineInlineEditor } from '../../../affine-inline-specs.js';
|
||||
import { linkPopupStyle } from './styles.js';
|
||||
|
||||
export class LinkPopup extends WithDisposable(LitElement) {
|
||||
static override styles = linkPopupStyle;
|
||||
|
||||
private _bodyOverflowStyle = '';
|
||||
|
||||
private _createTemplate = () => {
|
||||
this.updateComplete
|
||||
.then(() => {
|
||||
this.linkInput?.focus();
|
||||
|
||||
this._updateConfirmBtn();
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
return html`
|
||||
<div class="affine-link-popover create">
|
||||
<input
|
||||
id="link-input"
|
||||
class="affine-link-popover-input"
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
placeholder="Paste or type a link"
|
||||
@paste=${this._updateConfirmBtn}
|
||||
@input=${this._updateConfirmBtn}
|
||||
/>
|
||||
${this._confirmBtnTemplate()}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
private _delete = () => {
|
||||
if (this.inlineEditor.isValidInlineRange(this.targetInlineRange)) {
|
||||
this.inlineEditor.deleteText(this.targetInlineRange);
|
||||
}
|
||||
this.abortController.abort();
|
||||
};
|
||||
|
||||
private _edit = () => {
|
||||
if (!this.host) return;
|
||||
|
||||
this.type = 'edit';
|
||||
|
||||
track(this.host.std, 'OpenedAliasPopup', { control: 'edit' });
|
||||
};
|
||||
|
||||
private _editTemplate = () => {
|
||||
this.updateComplete
|
||||
.then(() => {
|
||||
if (
|
||||
!this.textInput ||
|
||||
!this.linkInput ||
|
||||
!this.currentText ||
|
||||
!this.currentLink
|
||||
)
|
||||
return;
|
||||
|
||||
this.textInput.value = this.currentText;
|
||||
this.linkInput.value = this.currentLink;
|
||||
|
||||
this.textInput.select();
|
||||
|
||||
this._updateConfirmBtn();
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
return html`
|
||||
<div class="affine-link-edit-popover">
|
||||
<div class="affine-edit-area text">
|
||||
<input
|
||||
class="affine-edit-input"
|
||||
id="text-input"
|
||||
type="text"
|
||||
placeholder="Enter text"
|
||||
@input=${this._updateConfirmBtn}
|
||||
/>
|
||||
<label class="affine-edit-label" for="text-input">Text</label>
|
||||
</div>
|
||||
<div class="affine-edit-area link">
|
||||
<input
|
||||
id="link-input"
|
||||
class="affine-edit-input"
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
placeholder="Paste or type a link"
|
||||
@input=${this._updateConfirmBtn}
|
||||
/>
|
||||
<label class="affine-edit-label" for="link-input">Link</label>
|
||||
</div>
|
||||
${this._confirmBtnTemplate()}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
private _embedOptions: EmbedOptions | null = null;
|
||||
|
||||
private _openLink = () => {
|
||||
if (this.openLink) {
|
||||
this.openLink();
|
||||
return;
|
||||
}
|
||||
|
||||
let link = this.currentLink;
|
||||
if (!link) return;
|
||||
if (!link.match(/^[a-zA-Z]+:\/\//)) {
|
||||
link = 'https://' + link;
|
||||
}
|
||||
window.open(link, '_blank');
|
||||
this.abortController.abort();
|
||||
};
|
||||
|
||||
private _removeLink = () => {
|
||||
if (this.inlineEditor.isValidInlineRange(this.targetInlineRange)) {
|
||||
this.inlineEditor.formatText(this.targetInlineRange, {
|
||||
link: null,
|
||||
});
|
||||
}
|
||||
this.abortController.abort();
|
||||
};
|
||||
|
||||
private _toggleViewSelector = (e: Event) => {
|
||||
if (!this.host) return;
|
||||
|
||||
const opened = (e as CustomEvent<boolean>).detail;
|
||||
if (!opened) return;
|
||||
|
||||
track(this.host.std, 'OpenedViewSelector', { control: 'switch view' });
|
||||
};
|
||||
|
||||
private _trackViewSelected = (type: string) => {
|
||||
if (!this.host) return;
|
||||
|
||||
track(this.host.std, 'SelectedView', {
|
||||
control: 'select view',
|
||||
type: `${type} view`,
|
||||
});
|
||||
};
|
||||
|
||||
private _viewTemplate = () => {
|
||||
if (!this.currentLink) return;
|
||||
|
||||
this._embedOptions =
|
||||
this.std
|
||||
?.get(EmbedOptionProvider)
|
||||
.getEmbedBlockOptions(this.currentLink) ?? null;
|
||||
|
||||
const buttons = [
|
||||
html`
|
||||
<a
|
||||
class="affine-link-preview"
|
||||
href=${this.currentLink}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
@click=${(e: MouseEvent) => this.openLink?.(e)}
|
||||
>
|
||||
<span>${getHostName(this.currentLink)}</span>
|
||||
</a>
|
||||
|
||||
<editor-icon-button
|
||||
aria-label="Copy"
|
||||
data-testid="copy-link"
|
||||
.tooltip=${'Copy link'}
|
||||
@click=${this._copyUrl}
|
||||
>
|
||||
${CopyIcon}
|
||||
</editor-icon-button>
|
||||
|
||||
<editor-icon-button
|
||||
aria-label="Edit"
|
||||
data-testid="edit"
|
||||
.tooltip=${'Edit'}
|
||||
@click=${this._edit}
|
||||
>
|
||||
${EditIcon}
|
||||
</editor-icon-button>
|
||||
`,
|
||||
|
||||
this._viewSelector(),
|
||||
|
||||
html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button aria-label="More" .tooltip=${'More'}>
|
||||
${MoreVerticalIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="large" data-orientation="vertical">
|
||||
${this._moreActions()}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`,
|
||||
];
|
||||
|
||||
return html`
|
||||
<editor-toolbar class="affine-link-popover view">
|
||||
${join(
|
||||
buttons.filter(button => button !== nothing),
|
||||
renderToolbarSeparator
|
||||
)}
|
||||
</editor-toolbar>
|
||||
`;
|
||||
};
|
||||
|
||||
private get _canConvertToEmbedView() {
|
||||
return this._embedOptions?.viewType === 'embed';
|
||||
}
|
||||
|
||||
private get _isBookmarkAllowed() {
|
||||
const block = this.block;
|
||||
if (!block) return false;
|
||||
const schema = block.doc.schema;
|
||||
const parent = block.doc.getParent(block.model);
|
||||
if (!parent) return false;
|
||||
const bookmarkSchema = schema.flavourSchemaMap.get('affine:bookmark');
|
||||
if (!bookmarkSchema) return false;
|
||||
const parentSchema = schema.flavourSchemaMap.get(parent.flavour);
|
||||
if (!parentSchema) return false;
|
||||
|
||||
try {
|
||||
schema.validateSchema(bookmarkSchema, parentSchema);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
get block() {
|
||||
const { rootElement } = this.inlineEditor;
|
||||
if (!rootElement) return null;
|
||||
|
||||
const block = rootElement.closest<BlockComponent>(`[${BLOCK_ID_ATTR}]`);
|
||||
if (!block) return null;
|
||||
return block;
|
||||
}
|
||||
|
||||
get currentLink() {
|
||||
return this.inlineEditor.getFormat(this.targetInlineRange).link;
|
||||
}
|
||||
|
||||
get currentText() {
|
||||
return this.inlineEditor.yTextString.slice(
|
||||
this.targetInlineRange.index,
|
||||
this.targetInlineRange.index + this.targetInlineRange.length
|
||||
);
|
||||
}
|
||||
|
||||
get host() {
|
||||
return this.block?.host;
|
||||
}
|
||||
|
||||
get std() {
|
||||
return this.block?.std;
|
||||
}
|
||||
|
||||
private _confirmBtnTemplate() {
|
||||
return html`
|
||||
<editor-icon-button
|
||||
class="affine-confirm-button"
|
||||
.iconSize=${'24px'}
|
||||
.disabled=${true}
|
||||
@click=${this._onConfirm}
|
||||
>
|
||||
${ConfirmIcon}
|
||||
</editor-icon-button>
|
||||
`;
|
||||
}
|
||||
|
||||
private _convertToCardView() {
|
||||
if (!this.inlineEditor.isValidInlineRange(this.targetInlineRange)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let targetFlavour = 'affine:bookmark';
|
||||
|
||||
if (this._embedOptions && this._embedOptions.viewType === 'card') {
|
||||
targetFlavour = this._embedOptions.flavour;
|
||||
}
|
||||
|
||||
const block = this.block;
|
||||
if (!block) return;
|
||||
const url = this.currentLink;
|
||||
const title = this.currentText;
|
||||
const props = {
|
||||
url,
|
||||
title: title === url ? '' : title,
|
||||
};
|
||||
const doc = block.doc;
|
||||
const parent = doc.getParent(block.model);
|
||||
if (!parent) return;
|
||||
const index = parent.children.indexOf(block.model);
|
||||
doc.addBlock(targetFlavour as never, props, parent, index + 1);
|
||||
|
||||
const totalTextLength = this.inlineEditor.yTextLength;
|
||||
const inlineTextLength = this.targetInlineRange.length;
|
||||
if (totalTextLength === inlineTextLength) {
|
||||
doc.deleteBlock(block.model);
|
||||
} else {
|
||||
this.inlineEditor.formatText(this.targetInlineRange, { link: null });
|
||||
}
|
||||
|
||||
this.abortController.abort();
|
||||
}
|
||||
|
||||
private _convertToEmbedView() {
|
||||
if (!this._embedOptions || this._embedOptions.viewType !== 'embed') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { flavour } = this._embedOptions;
|
||||
const url = this.currentLink;
|
||||
|
||||
const block = this.block;
|
||||
if (!block) return;
|
||||
const doc = block.doc;
|
||||
const parent = doc.getParent(block.model);
|
||||
if (!parent) return;
|
||||
const index = parent.children.indexOf(block.model);
|
||||
|
||||
doc.addBlock(flavour as never, { url }, parent, index + 1);
|
||||
|
||||
const totalTextLength = this.inlineEditor.yTextLength;
|
||||
const inlineTextLength = this.targetInlineRange.length;
|
||||
if (totalTextLength === inlineTextLength) {
|
||||
doc.deleteBlock(block.model);
|
||||
} else {
|
||||
this.inlineEditor.formatText(this.targetInlineRange, { link: null });
|
||||
}
|
||||
|
||||
this.abortController.abort();
|
||||
}
|
||||
|
||||
private _copyUrl() {
|
||||
if (!this.currentLink) return;
|
||||
navigator.clipboard.writeText(this.currentLink).catch(console.error);
|
||||
if (!this.host) return;
|
||||
toast(this.host, 'Copied link to clipboard');
|
||||
this.abortController.abort();
|
||||
|
||||
track(this.host.std, 'CopiedLink', { control: 'copy link' });
|
||||
}
|
||||
|
||||
private _moreActions() {
|
||||
return renderActions([
|
||||
[
|
||||
{
|
||||
label: 'Open',
|
||||
type: 'open',
|
||||
icon: OpenIcon,
|
||||
action: this._openLink,
|
||||
},
|
||||
|
||||
{
|
||||
label: 'Copy',
|
||||
type: 'copy',
|
||||
icon: CopyIcon,
|
||||
action: this._copyUrl,
|
||||
},
|
||||
|
||||
{
|
||||
label: 'Remove link',
|
||||
type: 'remove-link',
|
||||
icon: UnlinkIcon,
|
||||
action: this._removeLink,
|
||||
},
|
||||
],
|
||||
|
||||
[
|
||||
{
|
||||
type: 'delete',
|
||||
label: 'Delete',
|
||||
icon: DeleteIcon,
|
||||
action: this._delete,
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private _onConfirm() {
|
||||
if (!this.inlineEditor.isValidInlineRange(this.targetInlineRange)) return;
|
||||
if (!this.linkInput) return;
|
||||
|
||||
const linkInputValue = this.linkInput.value;
|
||||
if (!linkInputValue || !isValidUrl(linkInputValue)) return;
|
||||
|
||||
const link = normalizeUrl(linkInputValue);
|
||||
|
||||
if (this.type === 'create') {
|
||||
this.inlineEditor.formatText(this.targetInlineRange, {
|
||||
link: link,
|
||||
reference: null,
|
||||
});
|
||||
this.inlineEditor.setInlineRange(this.targetInlineRange);
|
||||
const textSelection = this.host?.selection.find('text');
|
||||
if (!textSelection) return;
|
||||
|
||||
this.std?.range.syncTextSelectionToRange(textSelection);
|
||||
} else if (this.type === 'edit') {
|
||||
const text = this.textInput?.value ?? link;
|
||||
this.inlineEditor.insertText(this.targetInlineRange, text, {
|
||||
link: link,
|
||||
reference: null,
|
||||
});
|
||||
this.inlineEditor.setInlineRange({
|
||||
index: this.targetInlineRange.index,
|
||||
length: text.length,
|
||||
});
|
||||
const textSelection = this.host?.selection.find('text');
|
||||
if (!textSelection) return;
|
||||
|
||||
this.std?.range.syncTextSelectionToRange(textSelection);
|
||||
}
|
||||
|
||||
this.abortController.abort();
|
||||
}
|
||||
|
||||
private _onKeydown(e: KeyboardEvent) {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter' && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
this._onConfirm();
|
||||
}
|
||||
}
|
||||
|
||||
private _updateConfirmBtn() {
|
||||
if (!this.confirmButton) {
|
||||
return;
|
||||
}
|
||||
const link = this.linkInput?.value.trim();
|
||||
const disabled = !(link && isValidUrl(link));
|
||||
this.confirmButton.disabled = disabled;
|
||||
this.confirmButton.active = !disabled;
|
||||
this.confirmButton.requestUpdate();
|
||||
}
|
||||
|
||||
private _viewSelector() {
|
||||
if (!this._isBookmarkAllowed) return nothing;
|
||||
|
||||
const buttons = [];
|
||||
|
||||
buttons.push({
|
||||
type: 'inline',
|
||||
label: 'Inline view',
|
||||
});
|
||||
|
||||
buttons.push({
|
||||
type: 'card',
|
||||
label: 'Card view',
|
||||
action: () => this._convertToCardView(),
|
||||
});
|
||||
|
||||
if (this._canConvertToEmbedView) {
|
||||
buttons.push({
|
||||
type: 'embed',
|
||||
label: 'Embed view',
|
||||
action: () => this._convertToEmbedView(),
|
||||
});
|
||||
}
|
||||
|
||||
return html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Switch view"
|
||||
.justify=${'space-between'}
|
||||
.labelHeight=${'20px'}
|
||||
.iconContainerWidth=${'110px'}
|
||||
>
|
||||
<div class="label">Inline view</div>
|
||||
${SmallArrowDownIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
@toggle=${this._toggleViewSelector}
|
||||
>
|
||||
<div data-size="small" data-orientation="vertical">
|
||||
${repeat(
|
||||
buttons,
|
||||
button => button.type,
|
||||
({ type, label, action }) => html`
|
||||
<editor-menu-action
|
||||
data-testid=${`link-to-${type}`}
|
||||
?data-selected=${type === 'inline'}
|
||||
?disabled=${type === 'inline'}
|
||||
@click=${() => {
|
||||
action?.();
|
||||
this._trackViewSelected(type);
|
||||
}}
|
||||
>
|
||||
${label}
|
||||
</editor-menu-action>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
if (this.targetInlineRange.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.type === 'edit' || this.type === 'create') {
|
||||
// disable body scroll
|
||||
this._bodyOverflowStyle = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
this.disposables.add({
|
||||
dispose: () => {
|
||||
document.body.style.overflow = this._bodyOverflowStyle;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected override firstUpdated() {
|
||||
if (!this.linkInput) return;
|
||||
|
||||
this._disposables.addFromEvent(this.linkInput, 'copy', stopPropagation);
|
||||
this._disposables.addFromEvent(this.linkInput, 'cut', stopPropagation);
|
||||
this._disposables.addFromEvent(this.linkInput, 'paste', stopPropagation);
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="overlay-root">
|
||||
${this.type === 'view'
|
||||
? nothing
|
||||
: html`
|
||||
<div
|
||||
class="affine-link-popover-overlay-mask"
|
||||
@click=${() => {
|
||||
this.abortController.abort();
|
||||
this.host?.selection.clear();
|
||||
}}
|
||||
></div>
|
||||
`}
|
||||
<div class="affine-link-popover-container" @keydown=${this._onKeydown}>
|
||||
${choose(this.type, [
|
||||
['create', this._createTemplate],
|
||||
['edit', this._editTemplate],
|
||||
['view', this._viewTemplate],
|
||||
])}
|
||||
</div>
|
||||
<div class="mock-selection-container"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override updated() {
|
||||
const range = this.inlineEditor.toDomRange(this.targetInlineRange);
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.type !== 'view') {
|
||||
const domRects = range.getClientRects();
|
||||
|
||||
Object.values(domRects).forEach(domRect => {
|
||||
if (!this.mockSelectionContainer) {
|
||||
return;
|
||||
}
|
||||
const mockSelection = document.createElement('div');
|
||||
mockSelection.classList.add('mock-selection');
|
||||
mockSelection.style.left = `${domRect.left}px`;
|
||||
mockSelection.style.top = `${domRect.top}px`;
|
||||
mockSelection.style.width = `${domRect.width}px`;
|
||||
mockSelection.style.height = `${domRect.height}px`;
|
||||
|
||||
this.mockSelectionContainer.append(mockSelection);
|
||||
});
|
||||
}
|
||||
|
||||
const visualElement = {
|
||||
getBoundingClientRect: () => range.getBoundingClientRect(),
|
||||
getClientRects: () => range.getClientRects(),
|
||||
};
|
||||
computePosition(visualElement, this.popupContainer, {
|
||||
middleware: [
|
||||
offset(10),
|
||||
inline(),
|
||||
shift({
|
||||
padding: 6,
|
||||
}),
|
||||
],
|
||||
})
|
||||
.then(({ x, y }) => {
|
||||
const popupContainer = this.popupContainer;
|
||||
if (!popupContainer) return;
|
||||
popupContainer.style.left = `${x}px`;
|
||||
popupContainer.style.top = `${y}px`;
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor abortController!: AbortController;
|
||||
|
||||
@query('.affine-confirm-button')
|
||||
accessor confirmButton: EditorIconButton | null = null;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor inlineEditor!: AffineInlineEditor;
|
||||
|
||||
@query('#link-input')
|
||||
accessor linkInput: HTMLInputElement | null = null;
|
||||
|
||||
@query('.mock-selection-container')
|
||||
accessor mockSelectionContainer!: HTMLDivElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor openLink: ((e?: MouseEvent) => void) | null = null;
|
||||
|
||||
@query('.affine-link-popover-container')
|
||||
accessor popupContainer!: HTMLDivElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor targetInlineRange!: InlineRange;
|
||||
|
||||
@query('#text-input')
|
||||
accessor textInput: HTMLInputElement | null = null;
|
||||
|
||||
@property()
|
||||
accessor type: 'create' | 'edit' | 'view' = 'create';
|
||||
}
|
||||
|
||||
function track(
|
||||
std: BlockStdScope,
|
||||
event: LinkEventType,
|
||||
props: Partial<TelemetryEvent>
|
||||
) {
|
||||
std.getOptional(TelemetryProvider)?.track(event, {
|
||||
segment: 'toolbar',
|
||||
page: 'doc editor',
|
||||
module: 'link toolbar',
|
||||
type: 'inline view',
|
||||
category: 'link',
|
||||
...props,
|
||||
});
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
import { FONT_XS, PANEL_BASE } from '@blocksuite/affine-shared/styles';
|
||||
import { css } from 'lit';
|
||||
|
||||
const editLinkStyle = css`
|
||||
.affine-link-edit-popover {
|
||||
${PANEL_BASE};
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
grid-template-areas:
|
||||
'text-area .'
|
||||
'link-area btn';
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
width: 320px;
|
||||
gap: 8px 12px;
|
||||
padding: 12px;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.affine-link-edit-popover label {
|
||||
box-sizing: border-box;
|
||||
color: var(--affine-icon-color);
|
||||
${FONT_XS};
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.affine-link-edit-popover input {
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--affine-text-primary-color);
|
||||
${FONT_XS};
|
||||
}
|
||||
.affine-link-edit-popover input::placeholder {
|
||||
color: var(--affine-placeholder-color);
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
}
|
||||
.affine-link-edit-popover input:focus ~ label,
|
||||
.affine-link-edit-popover input:active ~ label {
|
||||
color: var(--affine-primary-color);
|
||||
}
|
||||
|
||||
.affine-edit-area {
|
||||
width: 280px;
|
||||
padding: 4px 10px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: 26px auto;
|
||||
grid-template-rows: repeat(1, 1fr);
|
||||
grid-template-areas: 'label input';
|
||||
user-select: none;
|
||||
box-sizing: border-box;
|
||||
|
||||
border: 1px solid var(--affine-border-color);
|
||||
box-sizing: border-box;
|
||||
|
||||
outline: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
.affine-edit-area:focus-within {
|
||||
border-color: var(--affine-blue-700);
|
||||
box-shadow: var(--affine-active-shadow);
|
||||
}
|
||||
|
||||
.affine-edit-area.text {
|
||||
grid-area: text-area;
|
||||
}
|
||||
|
||||
.affine-edit-area.link {
|
||||
grid-area: link-area;
|
||||
}
|
||||
|
||||
.affine-edit-label {
|
||||
grid-area: label;
|
||||
}
|
||||
|
||||
.affine-edit-input {
|
||||
grid-area: input;
|
||||
}
|
||||
|
||||
.affine-confirm-button {
|
||||
grid-area: btn;
|
||||
user-select: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const linkPopupStyle = css`
|
||||
:host {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mock-selection {
|
||||
position: absolute;
|
||||
background-color: rgba(35, 131, 226, 0.28);
|
||||
}
|
||||
|
||||
.affine-link-popover-container {
|
||||
z-index: var(--affine-z-index-popover);
|
||||
animation: affine-popover-fade-in 0.2s ease;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
@keyframes affine-popover-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.affine-link-popover-overlay-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
}
|
||||
|
||||
.affine-link-preview {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
min-width: 60px;
|
||||
max-width: 140px;
|
||||
padding: var(--1, 0px);
|
||||
border-radius: var(--1, 0px);
|
||||
opacity: var(--add, 1);
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
|
||||
color: var(--affine-link-color);
|
||||
font-feature-settings:
|
||||
'clig' off,
|
||||
'liga' off;
|
||||
font-family: var(--affine-font-family);
|
||||
font-size: var(--affine-font-sm);
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
text-decoration: none;
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
|
||||
.affine-link-preview > span {
|
||||
display: inline-block;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
opacity: var(--add, 1);
|
||||
}
|
||||
|
||||
.affine-link-popover.create {
|
||||
${PANEL_BASE};
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
|
||||
color: var(--affine-text-primary-color);
|
||||
}
|
||||
|
||||
.affine-link-popover-input {
|
||||
min-width: 280px;
|
||||
height: 30px;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 10px;
|
||||
background: var(--affine-white-10);
|
||||
border-radius: 4px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: var(--affine-border-color);
|
||||
color: var(--affine-text-primary-color);
|
||||
${FONT_XS};
|
||||
}
|
||||
.affine-link-popover-input::placeholder {
|
||||
color: var(--affine-placeholder-color);
|
||||
}
|
||||
.affine-link-popover-input:focus {
|
||||
border-color: var(--affine-blue-700);
|
||||
box-shadow: var(--affine-active-shadow);
|
||||
}
|
||||
|
||||
${editLinkStyle}
|
||||
`;
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import type { InlineRange } from '@blocksuite/inline';
|
||||
|
||||
import type { AffineInlineEditor } from '../../../affine-inline-specs.js';
|
||||
import { LinkPopup } from './link-popup.js';
|
||||
|
||||
export function toggleLinkPopup(
|
||||
inlineEditor: AffineInlineEditor,
|
||||
type: LinkPopup['type'],
|
||||
targetInlineRange: InlineRange,
|
||||
abortController: AbortController,
|
||||
openLink: ((e?: MouseEvent) => void) | null = null
|
||||
): LinkPopup {
|
||||
const popup = new LinkPopup();
|
||||
popup.inlineEditor = inlineEditor;
|
||||
popup.type = type;
|
||||
popup.targetInlineRange = targetInlineRange;
|
||||
popup.openLink = openLink;
|
||||
popup.abortController = abortController;
|
||||
|
||||
document.body.append(popup);
|
||||
|
||||
return popup;
|
||||
}
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
import type { ReferenceInfo } from '@blocksuite/affine-model';
|
||||
import {
|
||||
type LinkEventType,
|
||||
type TelemetryEvent,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { FONT_XS, PANEL_BASE } from '@blocksuite/affine-shared/styles';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import { type BlockStdScope, ShadowlessElement } from '@blocksuite/block-std';
|
||||
import {
|
||||
assertExists,
|
||||
SignalWatcher,
|
||||
WithDisposable,
|
||||
} from '@blocksuite/global/utils';
|
||||
import { DoneIcon, ResetIcon } from '@blocksuite/icons/lit';
|
||||
import type { DeltaInsert, InlineRange } from '@blocksuite/inline';
|
||||
import { computePosition, inline, offset, shift } from '@floating-ui/dom';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import { css, html } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { live } from 'lit/directives/live.js';
|
||||
|
||||
import type { EditorIconButton } from '../../../../../toolbar/index.js';
|
||||
import type { AffineInlineEditor } from '../../affine-inline-specs.js';
|
||||
import { REFERENCE_NODE } from '../consts.js';
|
||||
|
||||
export class ReferenceAliasPopup extends SignalWatcher(
|
||||
WithDisposable(ShadowlessElement)
|
||||
) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.overlay-mask {
|
||||
position: fixed;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.alias-form-popup {
|
||||
${PANEL_BASE};
|
||||
position: absolute;
|
||||
display: flex;
|
||||
width: 321px;
|
||||
height: 37px;
|
||||
gap: 8px;
|
||||
box-sizing: content-box;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
animation: affine-popover-fade-in 0.2s ease;
|
||||
z-index: var(--affine-z-index-popover);
|
||||
}
|
||||
|
||||
@keyframes affine-popover-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--affine-text-primary-color);
|
||||
${FONT_XS};
|
||||
}
|
||||
input::placeholder {
|
||||
color: var(--affine-placeholder-color);
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
editor-icon-button.save .label {
|
||||
${FONT_XS};
|
||||
color: inherit;
|
||||
text-transform: none;
|
||||
}
|
||||
`;
|
||||
|
||||
private _onSave = () => {
|
||||
const title = this.title$.value.trim();
|
||||
if (!title) {
|
||||
this.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
this._setTitle(title);
|
||||
|
||||
track(this.std, 'SavedAlias', { control: 'save' });
|
||||
|
||||
this.remove();
|
||||
};
|
||||
|
||||
private _updateTitle = (e: InputEvent) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const value = target.value;
|
||||
this.title$.value = value;
|
||||
};
|
||||
|
||||
private _onKeydown(e: KeyboardEvent) {
|
||||
e.stopPropagation();
|
||||
if (!e.isComposing) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.remove();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this._onSave();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _onReset() {
|
||||
this.title$.value = this.docTitle;
|
||||
|
||||
this._setTitle();
|
||||
|
||||
track(this.std, 'ResetedAlias', { control: 'reset' });
|
||||
|
||||
this.remove();
|
||||
}
|
||||
|
||||
private _setTitle(title?: string) {
|
||||
const reference: AffineTextAttributes['reference'] = {
|
||||
type: 'LinkedPage',
|
||||
...this.referenceInfo,
|
||||
};
|
||||
|
||||
if (title) {
|
||||
reference.title = title;
|
||||
} else {
|
||||
delete reference.title;
|
||||
delete reference.description;
|
||||
}
|
||||
|
||||
this.inlineEditor.insertText(this.inlineRange, REFERENCE_NODE, {
|
||||
reference,
|
||||
});
|
||||
this.inlineEditor.setInlineRange({
|
||||
index: this.inlineRange.index + REFERENCE_NODE.length,
|
||||
length: 0,
|
||||
});
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.title$.value = this.referenceInfo.title ?? this.docTitle;
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
this.disposables.addFromEvent(this.overlayMask, 'click', e => {
|
||||
e.stopPropagation();
|
||||
this.remove();
|
||||
});
|
||||
this.disposables.addFromEvent(this, 'keydown', this._onKeydown);
|
||||
|
||||
this.inputElement.focus();
|
||||
this.inputElement.select();
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="overlay-root">
|
||||
<div class="overlay-mask"></div>
|
||||
<div class="alias-form-popup">
|
||||
<input
|
||||
id="alias-title"
|
||||
type="text"
|
||||
placeholder="Add a custom title"
|
||||
.value=${live(this.title$.value)}
|
||||
@input=${this._updateTitle}
|
||||
/>
|
||||
<editor-icon-button
|
||||
aria-label="Reset"
|
||||
class="reset"
|
||||
.iconContainerPadding=${4}
|
||||
.tooltip=${'Reset'}
|
||||
@click=${this._onReset}
|
||||
>
|
||||
${ResetIcon({ width: '16px', height: '16px' })}
|
||||
</editor-icon-button>
|
||||
<editor-toolbar-separator></editor-toolbar-separator>
|
||||
<editor-icon-button
|
||||
aria-label="Save"
|
||||
class="save"
|
||||
.active=${true}
|
||||
@click=${this._onSave}
|
||||
>
|
||||
${DoneIcon({ width: '16px', height: '16px' })}
|
||||
<span class="label">Save</span>
|
||||
</editor-icon-button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override updated() {
|
||||
const range = this.inlineEditor.toDomRange(this.inlineRange);
|
||||
assertExists(range);
|
||||
|
||||
const visualElement = {
|
||||
getBoundingClientRect: () => range.getBoundingClientRect(),
|
||||
getClientRects: () => range.getClientRects(),
|
||||
};
|
||||
computePosition(visualElement, this.popupContainer, {
|
||||
middleware: [
|
||||
offset(10),
|
||||
inline(),
|
||||
shift({
|
||||
padding: 6,
|
||||
}),
|
||||
],
|
||||
})
|
||||
.then(({ x, y }) => {
|
||||
const popupContainer = this.popupContainer;
|
||||
if (!popupContainer) return;
|
||||
popupContainer.style.left = `${x}px`;
|
||||
popupContainer.style.top = `${y}px`;
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
@property({ type: Object })
|
||||
accessor delta!: DeltaInsert<AffineTextAttributes>;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor docTitle!: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor inlineEditor!: AffineInlineEditor;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor inlineRange!: InlineRange;
|
||||
|
||||
@query('input#alias-title')
|
||||
accessor inputElement!: HTMLInputElement;
|
||||
|
||||
@query('.overlay-mask')
|
||||
accessor overlayMask!: HTMLDivElement;
|
||||
|
||||
@query('.alias-form-popup')
|
||||
accessor popupContainer!: HTMLDivElement;
|
||||
|
||||
@property({ type: Object })
|
||||
accessor referenceInfo!: ReferenceInfo;
|
||||
|
||||
@query('editor-icon-button.save')
|
||||
accessor saveButton!: EditorIconButton;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor std!: BlockStdScope;
|
||||
|
||||
accessor title$ = signal<string>('');
|
||||
}
|
||||
|
||||
function track(
|
||||
std: BlockStdScope,
|
||||
event: LinkEventType,
|
||||
props: Partial<TelemetryEvent>
|
||||
) {
|
||||
std.getOptional(TelemetryProvider)?.track(event, {
|
||||
segment: 'toolbar',
|
||||
page: 'doc editor',
|
||||
module: 'reference edit popup',
|
||||
type: 'inline view',
|
||||
category: 'linked doc',
|
||||
...props,
|
||||
});
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import type { BlockStdScope, ExtensionType } from '@blocksuite/block-std';
|
||||
import { createIdentifier } from '@blocksuite/global/di';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
import type { AffineReference } from './reference-node.js';
|
||||
|
||||
export interface ReferenceNodeConfig {
|
||||
customContent?: (reference: AffineReference) => TemplateResult;
|
||||
interactable?: boolean;
|
||||
hidePopup?: boolean;
|
||||
}
|
||||
|
||||
export const ReferenceNodeConfigIdentifier =
|
||||
createIdentifier<ReferenceNodeConfig>('AffineReferenceNodeConfig');
|
||||
|
||||
export function ReferenceNodeConfigExtension(
|
||||
config: ReferenceNodeConfig
|
||||
): ExtensionType {
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(ReferenceNodeConfigIdentifier, () => ({ ...config }));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class ReferenceNodeConfigProvider {
|
||||
private _customContent:
|
||||
| ((reference: AffineReference) => TemplateResult)
|
||||
| undefined = undefined;
|
||||
|
||||
private _hidePopup = false;
|
||||
|
||||
private _interactable = true;
|
||||
|
||||
get customContent() {
|
||||
return this._customContent;
|
||||
}
|
||||
|
||||
get doc() {
|
||||
return this.std.doc;
|
||||
}
|
||||
|
||||
get hidePopup() {
|
||||
return this._hidePopup;
|
||||
}
|
||||
|
||||
get interactable() {
|
||||
return this._interactable;
|
||||
}
|
||||
|
||||
constructor(readonly std: BlockStdScope) {}
|
||||
|
||||
setCustomContent(content: ReferenceNodeConfigProvider['_customContent']) {
|
||||
this._customContent = content;
|
||||
}
|
||||
|
||||
setHidePopup(hidePopup: boolean) {
|
||||
this._hidePopup = hidePopup;
|
||||
}
|
||||
|
||||
setInteractable(interactable: boolean) {
|
||||
this._interactable = interactable;
|
||||
}
|
||||
}
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
import type { ReferenceInfo } from '@blocksuite/affine-model';
|
||||
import { DocDisplayMetaProvider } from '@blocksuite/affine-shared/services';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
cloneReferenceInfo,
|
||||
referenceToNode,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
BLOCK_ID_ATTR,
|
||||
type BlockComponent,
|
||||
ShadowlessElement,
|
||||
} from '@blocksuite/block-std';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { LinkedPageIcon } from '@blocksuite/icons/lit';
|
||||
import {
|
||||
type DeltaInsert,
|
||||
INLINE_ROOT_ATTR,
|
||||
type InlineRootElement,
|
||||
ZERO_WIDTH_NON_JOINER,
|
||||
ZERO_WIDTH_SPACE,
|
||||
} from '@blocksuite/inline';
|
||||
import type { Doc, DocMeta } from '@blocksuite/store';
|
||||
import { css, html, nothing } from 'lit';
|
||||
import { property, state } from 'lit/decorators.js';
|
||||
import { choose } from 'lit/directives/choose.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { ref } from 'lit/directives/ref.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { HoverController } from '../../../../../hover/index.js';
|
||||
import { Peekable } from '../../../../../peek/index.js';
|
||||
import { RefNodeSlotsProvider } from '../../../../extension/index.js';
|
||||
import { affineTextStyles } from '../affine-text.js';
|
||||
import { DEFAULT_DOC_NAME, REFERENCE_NODE } from '../consts.js';
|
||||
import type { ReferenceNodeConfigProvider } from './reference-config.js';
|
||||
import { toggleReferencePopup } from './reference-popup.js';
|
||||
|
||||
@Peekable({ action: false })
|
||||
export class AffineReference extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = css`
|
||||
.affine-reference {
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
color: var(--affine-text-primary-color);
|
||||
fill: var(--affine-icon-color);
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding: 1px 2px 1px 0;
|
||||
}
|
||||
.affine-reference:hover {
|
||||
background: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.affine-reference[data-selected='true'] {
|
||||
background: var(--affine-hover-color);
|
||||
}
|
||||
|
||||
.affine-reference-title {
|
||||
margin-left: 4px;
|
||||
border-bottom: 0.5px solid var(--affine-divider-color);
|
||||
transition: border 0.2s ease-out;
|
||||
}
|
||||
.affine-reference-title:hover {
|
||||
border-bottom: 0.5px solid var(--affine-icon-color);
|
||||
}
|
||||
`;
|
||||
|
||||
private _updateRefMeta = (doc: Doc) => {
|
||||
const refAttribute = this.delta.attributes?.reference;
|
||||
if (!refAttribute) {
|
||||
return;
|
||||
}
|
||||
|
||||
const refMeta = doc.collection.meta.docMetas.find(
|
||||
doc => doc.id === refAttribute.pageId
|
||||
);
|
||||
this.refMeta = refMeta
|
||||
? {
|
||||
...refMeta,
|
||||
}
|
||||
: undefined;
|
||||
};
|
||||
|
||||
// Since the linked doc may be deleted, the `_refMeta` could be undefined.
|
||||
@state()
|
||||
accessor refMeta: DocMeta | undefined = undefined;
|
||||
|
||||
private _whenHover: HoverController = new HoverController(
|
||||
this,
|
||||
({ abortController }) => {
|
||||
if (
|
||||
this.config.hidePopup ||
|
||||
this.doc?.readonly ||
|
||||
this.closest('.prevent-reference-popup') ||
|
||||
!this.selfInlineRange ||
|
||||
!this.inlineEditor
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selection = this.std?.selection;
|
||||
if (!selection) {
|
||||
return null;
|
||||
}
|
||||
const textSelection = selection.find('text');
|
||||
if (!!textSelection && !textSelection.isCollapsed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const blockSelections = selection.filter('block');
|
||||
if (blockSelections.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
template: toggleReferencePopup(
|
||||
this,
|
||||
this.referenceToNode(),
|
||||
this.referenceInfo,
|
||||
this.inlineEditor,
|
||||
this.selfInlineRange,
|
||||
this.refMeta?.title ?? DEFAULT_DOC_NAME,
|
||||
abortController
|
||||
),
|
||||
};
|
||||
},
|
||||
{ enterDelay: 500 }
|
||||
);
|
||||
|
||||
get _icon() {
|
||||
const { pageId, params, title } = this.referenceInfo;
|
||||
return this.block?.std
|
||||
?.get(DocDisplayMetaProvider)
|
||||
.icon(pageId, { params, title, referenced: true }).value;
|
||||
}
|
||||
|
||||
get _title() {
|
||||
const { pageId, params, title } = this.referenceInfo;
|
||||
return (
|
||||
title ||
|
||||
this.block?.std
|
||||
?.get(DocDisplayMetaProvider)
|
||||
.title(pageId, { params, title, referenced: true }).value
|
||||
);
|
||||
}
|
||||
|
||||
get block() {
|
||||
const block = this.inlineEditor?.rootElement.closest<BlockComponent>(
|
||||
`[${BLOCK_ID_ATTR}]`
|
||||
);
|
||||
return block;
|
||||
}
|
||||
|
||||
get customContent() {
|
||||
return this.config.customContent;
|
||||
}
|
||||
|
||||
get doc() {
|
||||
const doc = this.config.doc;
|
||||
return doc;
|
||||
}
|
||||
|
||||
get inlineEditor() {
|
||||
const inlineRoot = this.closest<InlineRootElement<AffineTextAttributes>>(
|
||||
`[${INLINE_ROOT_ATTR}]`
|
||||
);
|
||||
return inlineRoot?.inlineEditor;
|
||||
}
|
||||
|
||||
get referenceInfo(): ReferenceInfo {
|
||||
const reference = this.delta.attributes?.reference;
|
||||
const id = this.doc?.id ?? '';
|
||||
if (!reference) return { pageId: id };
|
||||
return cloneReferenceInfo(reference);
|
||||
}
|
||||
|
||||
get selfInlineRange() {
|
||||
const selfInlineRange = this.inlineEditor?.getInlineRangeFromElement(this);
|
||||
return selfInlineRange;
|
||||
}
|
||||
|
||||
get std() {
|
||||
const std = this.block?.std;
|
||||
if (!std) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.ValueNotExists,
|
||||
'std not found in reference node'
|
||||
);
|
||||
}
|
||||
return std;
|
||||
}
|
||||
|
||||
private _onClick() {
|
||||
if (!this.config.interactable) return;
|
||||
this.std
|
||||
.getOptional(RefNodeSlotsProvider)
|
||||
?.docLinkClicked.emit(this.referenceInfo);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
if (!this.config) {
|
||||
console.error('`reference-node` need `ReferenceNodeConfig`.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.delta.insert !== REFERENCE_NODE) {
|
||||
console.error(
|
||||
`Reference node must be initialized with '${REFERENCE_NODE}', but got '${this.delta.insert}'`
|
||||
);
|
||||
}
|
||||
|
||||
const doc = this.doc;
|
||||
if (doc) {
|
||||
this._disposables.add(
|
||||
doc.collection.slots.docUpdated.on(() => this._updateRefMeta(doc))
|
||||
);
|
||||
}
|
||||
|
||||
this.updateComplete
|
||||
.then(() => {
|
||||
if (!this.inlineEditor || !doc) return;
|
||||
|
||||
// observe yText update
|
||||
this.disposables.add(
|
||||
this.inlineEditor.slots.textChange.on(() => this._updateRefMeta(doc))
|
||||
);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
// reference to block/element
|
||||
referenceToNode() {
|
||||
return referenceToNode(this.referenceInfo);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const refMeta = this.refMeta;
|
||||
const isDeleted = !refMeta;
|
||||
|
||||
const attributes = this.delta.attributes;
|
||||
const reference = attributes?.reference;
|
||||
const type = reference?.type;
|
||||
if (!attributes || !type) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const title = this._title;
|
||||
const icon = choose(type, [
|
||||
['LinkedPage', () => this._icon],
|
||||
[
|
||||
'Subpage',
|
||||
() =>
|
||||
LinkedPageIcon({
|
||||
width: '1.25em',
|
||||
height: '1.25em',
|
||||
style:
|
||||
'user-select:none;flex-shrink:0;vertical-align:middle;font-size:inherit;margin-bottom:0.1em;',
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
const style = affineTextStyles(
|
||||
attributes,
|
||||
isDeleted
|
||||
? {
|
||||
color: 'var(--affine-text-disable-color)',
|
||||
textDecoration: 'line-through',
|
||||
fill: 'var(--affine-text-disable-color)',
|
||||
}
|
||||
: {}
|
||||
);
|
||||
|
||||
const content = this.customContent
|
||||
? this.customContent(this)
|
||||
: html`${icon}<span
|
||||
data-title=${ifDefined(title)}
|
||||
class="affine-reference-title"
|
||||
>${title}</span
|
||||
>`;
|
||||
|
||||
// we need to add `<v-text .str=${ZERO_WIDTH_NON_JOINER}></v-text>` in an
|
||||
// embed element to make sure inline range calculation is correct
|
||||
return html`<span
|
||||
${this.config.interactable ? ref(this._whenHover.setReference) : ''}
|
||||
data-selected=${this.selected}
|
||||
class="affine-reference"
|
||||
style=${styleMap(style)}
|
||||
@click=${this._onClick}
|
||||
>${content}<v-text .str=${ZERO_WIDTH_NON_JOINER}></v-text
|
||||
></span>`;
|
||||
}
|
||||
|
||||
override willUpdate(_changedProperties: Map<PropertyKey, unknown>) {
|
||||
super.willUpdate(_changedProperties);
|
||||
|
||||
const doc = this.doc;
|
||||
if (doc) {
|
||||
this._updateRefMeta(doc);
|
||||
}
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor config!: ReferenceNodeConfigProvider;
|
||||
|
||||
@property({ type: Object })
|
||||
accessor delta: DeltaInsert<AffineTextAttributes> = {
|
||||
insert: ZERO_WIDTH_SPACE,
|
||||
attributes: {},
|
||||
};
|
||||
|
||||
@property({ type: Boolean })
|
||||
accessor selected = false;
|
||||
}
|
||||
+559
@@ -0,0 +1,559 @@
|
||||
import type { ReferenceInfo } from '@blocksuite/affine-model';
|
||||
import {
|
||||
GenerateDocUrlProvider,
|
||||
type LinkEventType,
|
||||
type TelemetryEvent,
|
||||
TelemetryProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
cloneReferenceInfoWithoutAliases,
|
||||
isInsideBlockByFlavour,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
BLOCK_ID_ATTR,
|
||||
type BlockComponent,
|
||||
type BlockStdScope,
|
||||
} from '@blocksuite/block-std';
|
||||
import { assertExists, WithDisposable } from '@blocksuite/global/utils';
|
||||
import type { InlineRange } from '@blocksuite/inline';
|
||||
import { computePosition, inline, offset, shift } from '@floating-ui/dom';
|
||||
import { effect } from '@preact/signals-core';
|
||||
import { html, LitElement, nothing } from 'lit';
|
||||
import { property, query } from 'lit/decorators.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { join } from 'lit/directives/join.js';
|
||||
import { repeat } from 'lit/directives/repeat.js';
|
||||
|
||||
import {
|
||||
CenterPeekIcon,
|
||||
CopyIcon,
|
||||
DeleteIcon,
|
||||
EditIcon,
|
||||
ExpandFullSmallIcon,
|
||||
MoreVerticalIcon,
|
||||
OpenIcon,
|
||||
SmallArrowDownIcon,
|
||||
} from '../../../../../icons/index.js';
|
||||
import { notifyLinkedDocSwitchedToEmbed } from '../../../../../notification/index.js';
|
||||
import { isPeekable, peek } from '../../../../../peek/index.js';
|
||||
import { toast } from '../../../../../toast/toast.js';
|
||||
import {
|
||||
type MenuItem,
|
||||
renderActions,
|
||||
renderToolbarSeparator,
|
||||
} from '../../../../../toolbar/index.js';
|
||||
import { RefNodeSlotsProvider } from '../../../../extension/index.js';
|
||||
import type { AffineInlineEditor } from '../../affine-inline-specs.js';
|
||||
import { ReferenceAliasPopup } from './reference-alias-popup.js';
|
||||
import { styles } from './styles.js';
|
||||
|
||||
export class ReferencePopup extends WithDisposable(LitElement) {
|
||||
static override styles = styles;
|
||||
|
||||
private _copyLink = () => {
|
||||
const url = this.std
|
||||
.getOptional(GenerateDocUrlProvider)
|
||||
?.generateDocUrl(this.referenceInfo.pageId, this.referenceInfo.params);
|
||||
|
||||
if (url) {
|
||||
navigator.clipboard.writeText(url).catch(console.error);
|
||||
toast(this.std.host, 'Copied link to clipboard');
|
||||
}
|
||||
|
||||
this.abortController.abort();
|
||||
|
||||
track(this.std, 'CopiedLink', { control: 'copy link' });
|
||||
};
|
||||
|
||||
private _openDoc = () => {
|
||||
this.std
|
||||
.getOptional(RefNodeSlotsProvider)
|
||||
?.docLinkClicked.emit(this.referenceInfo);
|
||||
};
|
||||
|
||||
private _openEditPopup = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (document.body.querySelector('reference-alias-popup')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
std,
|
||||
docTitle,
|
||||
referenceInfo,
|
||||
inlineEditor,
|
||||
targetInlineRange,
|
||||
abortController,
|
||||
} = this;
|
||||
|
||||
const aliasPopup = new ReferenceAliasPopup();
|
||||
|
||||
aliasPopup.std = std;
|
||||
aliasPopup.docTitle = docTitle;
|
||||
aliasPopup.referenceInfo = referenceInfo;
|
||||
aliasPopup.inlineEditor = inlineEditor;
|
||||
aliasPopup.inlineRange = targetInlineRange;
|
||||
|
||||
document.body.append(aliasPopup);
|
||||
|
||||
abortController.abort();
|
||||
|
||||
track(std, 'OpenedAliasPopup', { control: 'edit' });
|
||||
};
|
||||
|
||||
private _toggleViewSelector = (e: Event) => {
|
||||
const opened = (e as CustomEvent<boolean>).detail;
|
||||
if (!opened) return;
|
||||
|
||||
track(this.std, 'OpenedViewSelector', { control: 'switch view' });
|
||||
};
|
||||
|
||||
private _trackViewSelected = (type: string) => {
|
||||
track(this.std, 'SelectedView', {
|
||||
control: 'select view',
|
||||
type: `${type} view`,
|
||||
});
|
||||
};
|
||||
|
||||
get _embedViewButtonDisabled() {
|
||||
if (
|
||||
this.block.doc.readonly ||
|
||||
isInsideBlockByFlavour(
|
||||
this.block.doc,
|
||||
this.block.model,
|
||||
'affine:edgeless-text'
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
!!this.block.closest('affine-embed-synced-doc-block') ||
|
||||
this.referenceDocId === this.doc.id
|
||||
);
|
||||
}
|
||||
|
||||
get _openButtonDisabled() {
|
||||
return this.referenceDocId === this.doc.id;
|
||||
}
|
||||
|
||||
get block() {
|
||||
const block = this.inlineEditor.rootElement.closest<BlockComponent>(
|
||||
`[${BLOCK_ID_ATTR}]`
|
||||
);
|
||||
assertExists(block);
|
||||
return block;
|
||||
}
|
||||
|
||||
get doc() {
|
||||
const doc = this.block.doc;
|
||||
assertExists(doc);
|
||||
return doc;
|
||||
}
|
||||
|
||||
get referenceDocId() {
|
||||
const docId = this.inlineEditor.getFormat(this.targetInlineRange).reference
|
||||
?.pageId;
|
||||
assertExists(docId);
|
||||
return docId;
|
||||
}
|
||||
|
||||
get std() {
|
||||
const std = this.block.std;
|
||||
assertExists(std);
|
||||
return std;
|
||||
}
|
||||
|
||||
private _convertToCardView() {
|
||||
const block = this.block;
|
||||
const doc = block.host.doc;
|
||||
const parent = doc.getParent(block.model);
|
||||
assertExists(parent);
|
||||
|
||||
const index = parent.children.indexOf(block.model);
|
||||
|
||||
doc.addBlock(
|
||||
'affine:embed-linked-doc',
|
||||
this.referenceInfo,
|
||||
parent,
|
||||
index + 1
|
||||
);
|
||||
|
||||
const totalTextLength = this.inlineEditor.yTextLength;
|
||||
const inlineTextLength = this.targetInlineRange.length;
|
||||
if (totalTextLength === inlineTextLength) {
|
||||
doc.deleteBlock(block.model);
|
||||
} else {
|
||||
this.inlineEditor.insertText(this.targetInlineRange, this.docTitle);
|
||||
}
|
||||
|
||||
this.abortController.abort();
|
||||
}
|
||||
|
||||
private _convertToEmbedView() {
|
||||
const block = this.block;
|
||||
const std = block.std;
|
||||
const doc = block.host.doc;
|
||||
const parent = doc.getParent(block.model);
|
||||
assertExists(parent);
|
||||
|
||||
const index = parent.children.indexOf(block.model);
|
||||
const referenceInfo = this.referenceInfo;
|
||||
const hasTitleAlias = Boolean(referenceInfo.title);
|
||||
|
||||
doc.addBlock(
|
||||
'affine:embed-synced-doc',
|
||||
cloneReferenceInfoWithoutAliases(referenceInfo),
|
||||
parent,
|
||||
index + 1
|
||||
);
|
||||
|
||||
const totalTextLength = this.inlineEditor.yTextLength;
|
||||
const inlineTextLength = this.targetInlineRange.length;
|
||||
if (totalTextLength === inlineTextLength) {
|
||||
doc.deleteBlock(block.model);
|
||||
} else {
|
||||
this.inlineEditor.insertText(this.targetInlineRange, this.docTitle);
|
||||
}
|
||||
|
||||
if (hasTitleAlias) {
|
||||
notifyLinkedDocSwitchedToEmbed(std);
|
||||
}
|
||||
|
||||
this.abortController.abort();
|
||||
}
|
||||
|
||||
private _delete() {
|
||||
if (this.inlineEditor.isValidInlineRange(this.targetInlineRange)) {
|
||||
this.inlineEditor.deleteText(this.targetInlineRange);
|
||||
}
|
||||
this.abortController.abort();
|
||||
}
|
||||
|
||||
private _moreActions() {
|
||||
return renderActions([
|
||||
[
|
||||
{
|
||||
type: 'delete',
|
||||
label: 'Delete',
|
||||
icon: DeleteIcon,
|
||||
disabled: this.doc.readonly,
|
||||
action: () => this._delete(),
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private _openMenuButton() {
|
||||
const buttons: MenuItem[] = [
|
||||
{
|
||||
label: 'Open this doc',
|
||||
type: 'open-this-doc',
|
||||
icon: ExpandFullSmallIcon,
|
||||
action: this._openDoc,
|
||||
disabled: this._openButtonDisabled,
|
||||
},
|
||||
];
|
||||
|
||||
// open in new tab
|
||||
|
||||
if (isPeekable(this.target)) {
|
||||
buttons.push({
|
||||
label: 'Open in center peek',
|
||||
type: 'open-in-center-peek',
|
||||
icon: CenterPeekIcon,
|
||||
action: () => peek(this.target),
|
||||
});
|
||||
}
|
||||
|
||||
// open in split view
|
||||
|
||||
if (buttons.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Open doc"
|
||||
.justify=${'space-between'}
|
||||
.labelHeight=${'20px'}
|
||||
>
|
||||
${OpenIcon}${SmallArrowDownIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="large" data-orientation="vertical">
|
||||
${repeat(
|
||||
buttons,
|
||||
button => button.label,
|
||||
({ label, icon, action, disabled }) => html`
|
||||
<editor-menu-action
|
||||
aria-label=${ifDefined(label)}
|
||||
?disabled=${disabled}
|
||||
@click=${action}
|
||||
>
|
||||
${icon}<span class="label">${label}</span>
|
||||
</editor-menu-action>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
}
|
||||
|
||||
private _viewSelector() {
|
||||
// synced doc entry controlled by awareness flag
|
||||
const isSyncedDocEnabled = this.doc.awarenessStore.getFlag(
|
||||
'enable_synced_doc_block'
|
||||
);
|
||||
const buttons = [];
|
||||
|
||||
buttons.push({
|
||||
type: 'inline',
|
||||
label: 'Inline view',
|
||||
});
|
||||
|
||||
buttons.push({
|
||||
type: 'card',
|
||||
label: 'Card view',
|
||||
action: () => this._convertToCardView(),
|
||||
disabled: this.doc.readonly,
|
||||
});
|
||||
|
||||
if (isSyncedDocEnabled) {
|
||||
buttons.push({
|
||||
type: 'embed',
|
||||
label: 'Embed view',
|
||||
action: () => this._convertToEmbedView(),
|
||||
disabled:
|
||||
this.doc.readonly ||
|
||||
this.isLinkedNode ||
|
||||
this._embedViewButtonDisabled,
|
||||
});
|
||||
}
|
||||
|
||||
return html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button
|
||||
aria-label="Switch view"
|
||||
.justify=${'space-between'}
|
||||
.labelHeight=${'20px'}
|
||||
.iconContainerWidth=${'110px'}
|
||||
>
|
||||
<span class="label">Inline view</span>
|
||||
${SmallArrowDownIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
@toggle=${this._toggleViewSelector}
|
||||
>
|
||||
<div data-size="small" data-orientation="vertical">
|
||||
${repeat(
|
||||
buttons,
|
||||
button => button.type,
|
||||
({ type, label, action, disabled }) => html`
|
||||
<editor-menu-action
|
||||
aria-label=${label}
|
||||
data-testid=${`link-to-${type}`}
|
||||
?data-selected=${type === 'inline'}
|
||||
?disabled=${disabled || type === 'inline'}
|
||||
@click=${() => {
|
||||
action?.();
|
||||
this._trackViewSelected(type);
|
||||
}}
|
||||
>
|
||||
${label}
|
||||
</editor-menu-action>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
if (this.targetInlineRange.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = this.block.host.doc.getParent(this.block.model);
|
||||
assertExists(parent);
|
||||
|
||||
this.disposables.add(
|
||||
effect(() => {
|
||||
const children = parent.children;
|
||||
if (children.includes(this.block.model)) return;
|
||||
this.abortController.abort();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const titleButton = this.referenceInfo.title
|
||||
? html`
|
||||
<editor-icon-button
|
||||
class="doc-title"
|
||||
aria-label="Doc title"
|
||||
.hover=${false}
|
||||
.labelHeight=${'20px'}
|
||||
.tooltip=${this.docTitle}
|
||||
@click=${this._openDoc}
|
||||
>
|
||||
<span class="label">${this.docTitle}</span>
|
||||
</editor-icon-button>
|
||||
`
|
||||
: nothing;
|
||||
|
||||
const buttons = [
|
||||
this._openMenuButton(),
|
||||
|
||||
html`
|
||||
${titleButton}
|
||||
|
||||
<editor-icon-button
|
||||
aria-label="Copy link"
|
||||
data-testid="copy-link"
|
||||
.tooltip=${'Copy link'}
|
||||
@click=${this._copyLink}
|
||||
>
|
||||
${CopyIcon}
|
||||
</editor-icon-button>
|
||||
|
||||
<editor-icon-button
|
||||
aria-label="Edit"
|
||||
data-testid="edit"
|
||||
.tooltip=${'Edit'}
|
||||
?disabled=${this.doc.readonly}
|
||||
@click=${this._openEditPopup}
|
||||
>
|
||||
${EditIcon}
|
||||
</editor-icon-button>
|
||||
`,
|
||||
|
||||
this._viewSelector(),
|
||||
|
||||
html`
|
||||
<editor-menu-button
|
||||
.contentPadding=${'8px'}
|
||||
.button=${html`
|
||||
<editor-icon-button aria-label="More" .tooltip=${'More'}>
|
||||
${MoreVerticalIcon}
|
||||
</editor-icon-button>
|
||||
`}
|
||||
>
|
||||
<div data-size="large" data-orientation="vertical">
|
||||
${this._moreActions()}
|
||||
</div>
|
||||
</editor-menu-button>
|
||||
`,
|
||||
];
|
||||
|
||||
return html`
|
||||
<div class="overlay-root">
|
||||
<div class="affine-reference-popover-container">
|
||||
<editor-toolbar class="affine-reference-popover view">
|
||||
${join(
|
||||
buttons.filter(button => button !== nothing),
|
||||
renderToolbarSeparator
|
||||
)}
|
||||
</editor-toolbar>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
override updated() {
|
||||
assertExists(this.popupContainer);
|
||||
const range = this.inlineEditor.toDomRange(this.targetInlineRange);
|
||||
assertExists(range);
|
||||
|
||||
const visualElement = {
|
||||
getBoundingClientRect: () => range.getBoundingClientRect(),
|
||||
getClientRects: () => range.getClientRects(),
|
||||
};
|
||||
computePosition(visualElement, this.popupContainer, {
|
||||
middleware: [
|
||||
offset(10),
|
||||
inline(),
|
||||
shift({
|
||||
padding: 6,
|
||||
}),
|
||||
],
|
||||
})
|
||||
.then(({ x, y }) => {
|
||||
const popupContainer = this.popupContainer;
|
||||
if (!popupContainer) return;
|
||||
popupContainer.style.left = `${x}px`;
|
||||
popupContainer.style.top = `${y}px`;
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor abortController!: AbortController;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor docTitle!: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor inlineEditor!: AffineInlineEditor;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor isLinkedNode!: boolean;
|
||||
|
||||
@query('.affine-reference-popover-container')
|
||||
accessor popupContainer!: HTMLDivElement;
|
||||
|
||||
@property({ type: Object })
|
||||
accessor referenceInfo!: ReferenceInfo;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor target!: LitElement;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor targetInlineRange!: InlineRange;
|
||||
}
|
||||
|
||||
export function toggleReferencePopup(
|
||||
target: LitElement,
|
||||
isLinkedNode: boolean,
|
||||
referenceInfo: ReferenceInfo,
|
||||
inlineEditor: AffineInlineEditor,
|
||||
targetInlineRange: InlineRange,
|
||||
docTitle: string,
|
||||
abortController: AbortController
|
||||
): ReferencePopup {
|
||||
const popup = new ReferencePopup();
|
||||
popup.target = target;
|
||||
popup.isLinkedNode = isLinkedNode;
|
||||
popup.referenceInfo = referenceInfo;
|
||||
popup.inlineEditor = inlineEditor;
|
||||
popup.targetInlineRange = targetInlineRange;
|
||||
popup.docTitle = docTitle;
|
||||
popup.abortController = abortController;
|
||||
|
||||
document.body.append(popup);
|
||||
|
||||
return popup;
|
||||
}
|
||||
|
||||
function track(
|
||||
std: BlockStdScope,
|
||||
event: LinkEventType,
|
||||
props: Partial<TelemetryEvent>
|
||||
) {
|
||||
std.getOptional(TelemetryProvider)?.track(event, {
|
||||
segment: 'toolbar',
|
||||
page: 'doc editor',
|
||||
module: 'reference toolbar',
|
||||
type: 'inline view',
|
||||
category: 'linked doc',
|
||||
...props,
|
||||
});
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { css } from 'lit';
|
||||
|
||||
export const styles = css`
|
||||
:host {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.affine-reference-popover-container {
|
||||
z-index: var(--affine-z-index-popover);
|
||||
animation: affine-popover-fade-in 0.2s ease;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
@keyframes affine-popover-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
editor-icon-button.doc-title .label {
|
||||
max-width: 110px;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
color: var(--affine-link-color);
|
||||
font-feature-settings:
|
||||
'clig' off,
|
||||
'liga' off;
|
||||
font-family: var(--affine-font-family);
|
||||
font-size: var(--affine-font-sm);
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
text-decoration: none;
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
`;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import type { ReferenceInfo } from '@blocksuite/affine-model';
|
||||
import type { Slot } from '@blocksuite/global/utils';
|
||||
|
||||
export type RefNodeSlots = {
|
||||
docLinkClicked: Slot<ReferenceInfo>;
|
||||
};
|
||||
Reference in New Issue
Block a user