snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 00:31:07 +05:00
commit 50f788e799
7907 changed files with 988785 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
{
"name": "@blocksuite/affine-block-bookmark",
"description": "Bookmark block for BlockSuite.",
"type": "module",
"scripts": {
"build": "yarn run -T tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-block-embed": "workspace:*",
"@blocksuite/affine-block-embed-doc": "workspace:*",
"@blocksuite/affine-block-surface": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/affine-widget-slash-menu": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.17",
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
"@preact/signals-core": "^1.8.0",
"@toeverything/theme": "^1.1.23",
"lit": "^3.2.0",
"rxjs": "^7.8.2",
"yjs": "^13.6.27"
},
"devDependencies": {
"@vitest/browser-playwright": "^4.1.10",
"playwright": "=1.58.2",
"vitest": "^4.1.8"
},
"exports": {
".": "./src/index.ts",
"./store": "./src/store.ts",
"./view": "./src/view.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

View File

@@ -0,0 +1,155 @@
import { describe, expect, it } from 'vitest';
import { footnoteUrlPreprocessor } from '../../adapters/markdown/preprocessor';
describe('footnoteUrlPreprocessor', () => {
it('should encode unencoded URLs in footnote definitions', () => {
const input =
'[^ref]: {"type":"url","url":"https://example.com?param=value"}';
const expected =
'[^ref]: {"type":"url","url":"https%3A%2F%2Fexample.com%3Fparam%3Dvalue"}';
expect(footnoteUrlPreprocessor(input)).toBe(expected);
});
it('should not encode already encoded URLs', () => {
const input = '[^ref]: {"type":"url","url":"https%3A%2F%2Fexample.com"}';
expect(footnoteUrlPreprocessor(input)).toBe(input);
});
it('should handle invalid JSON content', () => {
const input = '[^ref]: {"invalid json"}';
expect(footnoteUrlPreprocessor(input)).toBe(input);
});
it('should handle non-object footnote data', () => {
const input = '[^ref]: "not an object"';
expect(footnoteUrlPreprocessor(input)).toBe(input);
});
it('should handle footnote data without url property', () => {
const input = '[^ref]: {"type":"url"}';
expect(footnoteUrlPreprocessor(input)).toBe(input);
});
it('should handle multiple footnote definitions', () => {
const input = `
[^ref1]: {"type":"url","url":"https://example1.com"}
[^ref2]: {"type":"url","url":"https://example2.com"}
`.trim();
const expected = `
[^ref1]: {"type":"url","url":"https%3A%2F%2Fexample1.com"}
[^ref2]: {"type":"url","url":"https%3A%2F%2Fexample2.com"}
`.trim();
expect(footnoteUrlPreprocessor(input)).toBe(expected);
});
it('should handle special characters in URLs', () => {
const input =
'[^ref]: {"type":"url","url":"https://example.com/path with spaces?param=value&another=param"}';
const expected =
'[^ref]: {"type":"url","url":"https%3A%2F%2Fexample.com%2Fpath%20with%20spaces%3Fparam%3Dvalue%26another%3Dparam"}';
expect(footnoteUrlPreprocessor(input)).toBe(expected);
});
it('should encode unencoded favicon URLs', () => {
const input =
'[^ref]: {"type":"url","url":"https://example.com","favicon":"https://example.com/icon.png"}';
const expected =
'[^ref]: {"type":"url","url":"https%3A%2F%2Fexample.com","favicon":"https%3A%2F%2Fexample.com%2Ficon.png"}';
expect(footnoteUrlPreprocessor(input)).toBe(expected);
});
it('should not encode already encoded favicon URLs', () => {
const input =
'[^ref]: {"type":"url","url":"https://example.com","favicon":"https%3A%2F%2Fexample.com%2Ficon.png"}';
const expected =
'[^ref]: {"type":"url","url":"https%3A%2F%2Fexample.com","favicon":"https%3A%2F%2Fexample.com%2Ficon.png"}';
expect(footnoteUrlPreprocessor(input)).toBe(expected);
});
it('should handle both URL and icon encoding in the same footnote', () => {
const input =
'[^ref]: {"type":"url","url":"https://example.com?param=value","favicon":"https://example.com/icon.png?size=large"}';
const expected =
'[^ref]: {"type":"url","url":"https%3A%2F%2Fexample.com%3Fparam%3Dvalue","favicon":"https%3A%2F%2Fexample.com%2Ficon.png%3Fsize%3Dlarge"}';
expect(footnoteUrlPreprocessor(input)).toBe(expected);
});
it('should format footnote definition with space', () => {
const input =
'[^ref]:{"type":"url","url":"https://example.com?param=value"}';
const expected =
'[^ref]: {"type":"url","url":"https%3A%2F%2Fexample.com%3Fparam%3Dvalue"}';
expect(footnoteUrlPreprocessor(input)).toBe(expected);
});
it('should format footnote definition with multiple spaces', () => {
const input =
'[^ref]: {"type":"url","url":"https://example.com?param=value"}';
const expected =
'[^ref]: {"type":"url","url":"https%3A%2F%2Fexample.com%3Fparam%3Dvalue"}';
expect(footnoteUrlPreprocessor(input)).toBe(expected);
});
it('should handle special characters in reference tags', () => {
const input =
'[^ref-with-special-chars!@#]: {"type":"url","url":"https://example.com"}';
const expected =
'[^ref-with-special-chars!@#]: {"type":"url","url":"https%3A%2F%2Fexample.com"}';
expect(footnoteUrlPreprocessor(input)).toBe(expected);
});
it('should handle incomplete footnote definitions', () => {
const input = '[^ref]:';
expect(footnoteUrlPreprocessor(input)).toBe(input);
});
it('should handle mixed content with non-footnote text', () => {
const input = `
Some regular text
[^ref]: {"type":"url","url":"https://example.com"}
More regular text
`.trim();
const expected = `
Some regular text
[^ref]: {"type":"url","url":"https%3A%2F%2Fexample.com"}
More regular text
`.trim();
expect(footnoteUrlPreprocessor(input)).toBe(expected);
});
it('should handle empty input', () => {
expect(footnoteUrlPreprocessor('')).toBe('');
});
it('should handle malformed footnote definitions', () => {
const input = '[^ref: {"type":"url","url":"https://example.com"}';
expect(footnoteUrlPreprocessor(input)).toBe(input);
});
it('should handle multiple footnotes with mixed content', () => {
const input = `
[^ref1]: {"type":"url","url":"https://example1.com"}
Some text in between
[^ref2]: {"type":"url","url":"https://example2.com"}
`.trim();
const expected = `
[^ref1]: {"type":"url","url":"https%3A%2F%2Fexample1.com"}
Some text in between
[^ref2]: {"type":"url","url":"https%3A%2F%2Fexample2.com"}
`.trim();
expect(footnoteUrlPreprocessor(input)).toBe(expected);
});
it('should encode partial encoded URLs in footnote definitions', () => {
const input = `
[^ref]: {"type":"url","url":"https://zh.wikipedia.org/zh-hans/%E5%B0%8F%E7%B1%B3SU7"}
[^ref2]: {"type":"url","url":"https://www.dw.com/zh/%E5%B0%8F%E7%B1%B3%E9%A6%96%E6%AC%BE%E6%B1%BD%E8%BD%A6%E5%8F%91%E5%B8%83-su7%E8%B5%B7%E4%BB%B72159%E4%B8%87%E5%85%83/a-68693432"}
`.trim();
const expected = `
[^ref]: {"type":"url","url":"https%3A%2F%2Fzh.wikipedia.org%2Fzh-hans%2F%25E5%25B0%258F%25E7%25B1%25B3SU7"}
[^ref2]: {"type":"url","url":"https%3A%2F%2Fwww.dw.com%2Fzh%2F%25E5%25B0%258F%25E7%25B1%25B3%25E9%25A6%2596%25E6%25AC%25BE%25E6%25B1%25BD%25E8%25BD%25A6%25E5%258F%2591%25E5%25B8%2583-su7%25E8%25B5%25B7%25E4%25BB%25B72159%25E4%25B8%2587%25E5%2585%2583%2Fa-68693432"}
`.trim();
expect(footnoteUrlPreprocessor(input)).toBe(expected);
});
});

View File

@@ -0,0 +1,13 @@
import type { ExtensionType } from '@blocksuite/store';
import { BookmarkBlockHtmlAdapterExtension } from './html.js';
import { BookmarkBlockMarkdownAdapterExtensions } from './markdown/index.js';
import { BookmarkBlockNotionHtmlAdapterExtension } from './notion-html.js';
import { BookmarkBlockPlainTextAdapterExtension } from './plain-text.js';
export const BookmarkBlockAdapterExtensions: ExtensionType[] = [
BookmarkBlockHtmlAdapterExtension,
BookmarkBlockMarkdownAdapterExtensions,
BookmarkBlockNotionHtmlAdapterExtension,
BookmarkBlockPlainTextAdapterExtension,
].flat();

View File

@@ -0,0 +1,10 @@
import { createEmbedBlockHtmlAdapterMatcher } from '@blocksuite/affine-block-embed';
import { BookmarkBlockSchema } from '@blocksuite/affine-model';
import { BlockHtmlAdapterExtension } from '@blocksuite/affine-shared/adapters';
export const bookmarkBlockHtmlAdapterMatcher =
createEmbedBlockHtmlAdapterMatcher(BookmarkBlockSchema.model.flavour);
export const BookmarkBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
bookmarkBlockHtmlAdapterMatcher
);

View File

@@ -0,0 +1,4 @@
export * from './html.js';
export * from './markdown/index.js';
export * from './notion-html.js';
export * from './plain-text.js';

View File

@@ -0,0 +1,12 @@
import type { ExtensionType } from '@blocksuite/store';
import { BookmarkBlockMarkdownAdapterExtension } from './markdown.js';
import { BookmarkBlockMarkdownPreprocessorExtension } from './preprocessor.js';
export * from './markdown.js';
export * from './preprocessor.js';
export const BookmarkBlockMarkdownAdapterExtensions: ExtensionType[] = [
BookmarkBlockMarkdownPreprocessorExtension,
BookmarkBlockMarkdownAdapterExtension,
];

View File

@@ -0,0 +1,93 @@
import { createEmbedBlockMarkdownAdapterMatcher } from '@blocksuite/affine-block-embed';
import {
BookmarkBlockSchema,
FootNoteReferenceParamsSchema,
} from '@blocksuite/affine-model';
import {
BlockMarkdownAdapterExtension,
FOOTNOTE_DEFINITION_PREFIX,
getFootnoteDefinitionText,
isFootnoteDefinitionNode,
type MarkdownAST,
} from '@blocksuite/affine-shared/adapters';
import { nanoid } from '@blocksuite/store';
const isUrlFootnoteDefinitionNode = (node: MarkdownAST) => {
if (!isFootnoteDefinitionNode(node)) return false;
const footnoteDefinition = getFootnoteDefinitionText(node);
try {
const footnoteDefinitionJson = FootNoteReferenceParamsSchema.parse(
JSON.parse(footnoteDefinition)
);
return (
footnoteDefinitionJson.type === 'url' && !!footnoteDefinitionJson.url
);
} catch {
return false;
}
};
export const bookmarkBlockMarkdownAdapterMatcher =
createEmbedBlockMarkdownAdapterMatcher(BookmarkBlockSchema.model.flavour, {
toMatch: o => isUrlFootnoteDefinitionNode(o.node),
toBlockSnapshot: {
enter: (o, context) => {
if (!isFootnoteDefinitionNode(o.node)) {
return;
}
const { walkerContext, configs } = context;
const footnoteIdentifier = o.node.identifier;
const footnoteDefinitionKey = `${FOOTNOTE_DEFINITION_PREFIX}${footnoteIdentifier}`;
const footnoteDefinition = configs.get(footnoteDefinitionKey);
if (!footnoteDefinition) {
return;
}
let footnoteDefinitionJson;
try {
footnoteDefinitionJson = FootNoteReferenceParamsSchema.parse(
JSON.parse(footnoteDefinition)
);
// If the footnote definition contains url, decode it
if (footnoteDefinitionJson.url) {
footnoteDefinitionJson.url = decodeURIComponent(
footnoteDefinitionJson.url
);
}
if (footnoteDefinitionJson.favicon) {
footnoteDefinitionJson.favicon = decodeURIComponent(
footnoteDefinitionJson.favicon
);
}
} catch (err) {
console.warn('Failed to parse or decode footnote definition:', err);
return;
}
const { url, favicon, title, description } = footnoteDefinitionJson;
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: BookmarkBlockSchema.model.flavour,
props: {
url,
footnoteIdentifier,
icon: favicon,
title,
description,
style: 'citation',
},
children: [],
},
'children'
)
.closeNode();
walkerContext.skipAllChildren();
},
},
});
export const BookmarkBlockMarkdownAdapterExtension =
BlockMarkdownAdapterExtension(bookmarkBlockMarkdownAdapterMatcher);

View File

@@ -0,0 +1,263 @@
import {
type FootNoteReferenceParams,
FootNoteReferenceParamsSchema,
} from '@blocksuite/affine-model';
import {
type MarkdownAdapterPreprocessor,
MarkdownPreprocessorExtension,
} from '@blocksuite/affine-shared/adapters';
// Types for footnote parser tokens
type Token = {
type:
| 'FOOTNOTE_START'
| 'REFERENCE'
| 'SEPARATOR'
| 'JSON_CONTENT'
| 'NEWLINE';
value: string;
};
type FootnoteDefinition = {
reference: string;
content: FootNoteReferenceParams;
};
/**
* Check if a URL is already encoded with encodeURIComponent to avoid markdown link parsing
* @example
* https://example.com/path%20with%20spaces should return false
* https://example.com/ should return false
* https%3A%2F%2Fexample.com%2F should return true
*/
function isFullyEncoded(uri: string): boolean {
try {
// Should check if the components of the URI are fully encoded
return uri === encodeURIComponent(decodeURIComponent(uri));
} catch {
// If decoding fails, the URI contains invalid percent-encoding
return true;
}
}
// Format footnote definition with consistent spacing
function formatFootnoteDefinition(reference: string, data: object): string {
return `[^${reference}]: ${JSON.stringify(data)}`;
}
class FootnoteParser {
private pos: number = 0;
private input: string = '';
private tokens: Token[] = [];
// Lexer: Convert input string into tokens
private tokenize(input: string): Token[] {
this.input = input;
this.pos = 0;
this.tokens = [];
while (this.pos < this.input.length) {
const char = this.input[this.pos];
// Handle newlines
if (char === '\n') {
this.tokens.push({ type: 'NEWLINE', value: '\n' });
this.pos++;
continue;
}
// Match footnote start [^
if (char === '[' && this.input[this.pos + 1] === '^') {
this.tokens.push({ type: 'FOOTNOTE_START', value: '[^' });
this.pos += 2;
continue;
}
// Match reference content until ]
if (
this.tokens.length > 0 &&
this.tokens[this.tokens.length - 1].type === 'FOOTNOTE_START'
) {
let reference = '';
while (this.pos < this.input.length && this.input[this.pos] !== ']') {
reference += this.input[this.pos];
this.pos++;
}
if (reference) {
this.tokens.push({ type: 'REFERENCE', value: reference });
}
this.pos++; // Skip the closing ]
continue;
}
// Match separator ': '
if (char === ':') {
let nextPos = this.pos + 1;
while (nextPos < this.input.length && this.input[nextPos] === ' ') {
nextPos++;
}
this.tokens.push({
type: 'SEPARATOR',
value: this.input.slice(this.pos, nextPos),
});
this.pos = nextPos;
continue;
}
// Match JSON content
if (char === '{') {
let jsonContent = '';
let braceCount = 1;
let startPos = this.pos;
this.pos++; // Move past the opening brace
let inString = false;
let escaped = false;
while (this.pos < this.input.length && braceCount > 0) {
const char = this.input[this.pos];
if (!inString) {
if (char === '{') braceCount++;
else if (char === '}') braceCount--;
}
if (escaped) {
escaped = false;
} else {
if (char === '\\') {
escaped = true;
} else if (char === '"') {
inString = !inString;
}
}
jsonContent += char;
this.pos++;
}
if (braceCount === 0) {
this.tokens.push({
type: 'JSON_CONTENT',
value: '{' + jsonContent,
});
} else {
// Reset position if JSON is malformed
this.pos = startPos;
this.pos++;
}
continue;
}
// Skip other characters
this.pos++;
}
return this.tokens;
}
// Parser: Convert tokens into structured data
private parse(tokens: Token[]): FootnoteDefinition[] {
const footnotes: FootnoteDefinition[] = [];
let currentFootnote: Partial<FootnoteDefinition> = {};
let isParsingFootnote = false;
for (const token of tokens) {
switch (token.type) {
case 'FOOTNOTE_START':
isParsingFootnote = true;
currentFootnote = {};
break;
case 'REFERENCE':
if (isParsingFootnote) {
currentFootnote.reference = token.value;
}
break;
case 'JSON_CONTENT':
if (isParsingFootnote) {
try {
currentFootnote.content = FootNoteReferenceParamsSchema.parse(
JSON.parse(token.value)
);
footnotes.push(currentFootnote as FootnoteDefinition);
} catch {
// Invalid JSON, ignore this footnote
}
isParsingFootnote = false;
currentFootnote = {};
}
break;
case 'NEWLINE':
isParsingFootnote = false;
currentFootnote = {};
break;
}
}
return footnotes;
}
// Process URLs in footnote content
private processUrls(footnote: FootnoteDefinition): FootnoteDefinition {
const content = footnote.content;
if (content.url && !isFullyEncoded(content.url)) {
content.url = encodeURIComponent(content.url);
}
if (content.favicon && !isFullyEncoded(content.favicon)) {
content.favicon = encodeURIComponent(content.favicon);
}
return footnote;
}
// Main processing function
public process(input: string): string {
// 1. Tokenize the input
const tokens = this.tokenize(input);
// 2. Parse tokens into footnotes
const footnotes = this.parse(tokens);
// 3. Process URLs in footnotes
const processedFootnotes = footnotes.map(fn => this.processUrls(fn));
// 4. Convert back to original format
const footnoteMap = new Map(
processedFootnotes.map(fn => [
fn.reference,
formatFootnoteDefinition(fn.reference, fn.content),
])
);
// 5. Replace in original text
return input
.split('\n')
.map(line => {
const match = line.match(/^\[\^([^\]]+)\]:/);
if (match && footnoteMap.has(match[1])) {
return footnoteMap.get(match[1])!;
}
return line;
})
.join('\n');
}
}
/**
* Preprocessor for footnote url
* We should encode url in footnote definition to avoid markdown link parsing
*
* Example of footnote definition:
* [^ref]: {"type":"url","url":"https://example.com"}
*/
export function footnoteUrlPreprocessor(content: string): string {
const parser = new FootnoteParser();
return parser.process(content);
}
const bookmarkBlockPreprocessor: MarkdownAdapterPreprocessor = {
name: 'bookmark-block',
levels: ['block', 'slice', 'doc'],
preprocess: footnoteUrlPreprocessor,
};
export const BookmarkBlockMarkdownPreprocessorExtension =
MarkdownPreprocessorExtension(bookmarkBlockPreprocessor);

View File

@@ -0,0 +1,71 @@
import { BookmarkBlockSchema } from '@blocksuite/affine-model';
import {
BlockNotionHtmlAdapterExtension,
type BlockNotionHtmlAdapterMatcher,
HastUtils,
} from '@blocksuite/affine-shared/adapters';
import { nanoid } from '@blocksuite/store';
export const bookmarkBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher =
{
flavour: BookmarkBlockSchema.model.flavour,
toMatch: o => {
return (
HastUtils.isElement(o.node) &&
o.node.tagName === 'figure' &&
!!HastUtils.querySelector(o.node, '.bookmark')
);
},
fromMatch: () => false,
toBlockSnapshot: {
enter: (o, context) => {
if (!HastUtils.isElement(o.node)) {
return;
}
const bookmark = HastUtils.querySelector(o.node, '.bookmark');
if (!bookmark) {
return;
}
const { walkerContext } = context;
const bookmarkURL = bookmark.properties?.href;
const bookmarkTitle = HastUtils.getTextContent(
HastUtils.querySelector(bookmark, '.bookmark-title')
);
const bookmarkDescription = HastUtils.getTextContent(
HastUtils.querySelector(bookmark, '.bookmark-description')
);
const bookmarkIcon = HastUtils.querySelector(
bookmark,
'.bookmark-icon'
);
const bookmarkIconURL =
typeof bookmarkIcon?.properties?.src === 'string'
? bookmarkIcon.properties.src
: '';
walkerContext
.openNode(
{
type: 'block',
id: nanoid(),
flavour: BookmarkBlockSchema.model.flavour,
props: {
type: 'card',
url: bookmarkURL ?? '',
title: bookmarkTitle,
description: bookmarkDescription,
icon: bookmarkIconURL,
},
children: [],
},
'children'
)
.closeNode();
walkerContext.skipAllChildren();
},
},
fromBlockSnapshot: {},
};
export const BookmarkBlockNotionHtmlAdapterExtension =
BlockNotionHtmlAdapterExtension(bookmarkBlockNotionHtmlAdapterMatcher);

View File

@@ -0,0 +1,9 @@
import { createEmbedBlockPlainTextAdapterMatcher } from '@blocksuite/affine-block-embed';
import { BookmarkBlockSchema } from '@blocksuite/affine-model';
import { BlockPlainTextAdapterExtension } from '@blocksuite/affine-shared/adapters';
export const bookmarkBlockPlainTextAdapterMatcher =
createEmbedBlockPlainTextAdapterMatcher(BookmarkBlockSchema.model.flavour);
export const BookmarkBlockPlainTextAdapterExtension =
BlockPlainTextAdapterExtension(bookmarkBlockPlainTextAdapterMatcher);

View File

@@ -0,0 +1,293 @@
import {
CaptionedBlockComponent,
SelectedStyle,
} from '@blocksuite/affine-components/caption';
import type {
BookmarkBlockModel,
LinkPreviewData,
} from '@blocksuite/affine-model';
import { ImageProxyService } from '@blocksuite/affine-shared/adapters';
import {
BlockElementCommentManager,
CitationProvider,
DocModeProvider,
LinkPreviewServiceIdentifier,
} from '@blocksuite/affine-shared/services';
import { normalizeUrl } from '@blocksuite/affine-shared/utils';
import { BlockSelection } from '@blocksuite/std';
import { computed, type ReadonlySignal, signal } from '@preact/signals-core';
import { html } from 'lit';
import { property, query } from 'lit/decorators.js';
import { type ClassInfo, classMap } from 'lit/directives/class-map.js';
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
import { filter } from 'rxjs/operators';
import { refreshBookmarkUrlData } from './utils.js';
export const BOOKMARK_MIN_WIDTH = 450;
export class BookmarkBlockComponent extends CaptionedBlockComponent<BookmarkBlockModel> {
selectedStyle$: ReadonlySignal<ClassInfo> | null = computed<ClassInfo>(
() => ({
'selected-style': this.selected$.value,
})
);
private _fetchAbortController?: AbortController;
blockDraggable = true;
protected containerStyleMap!: ReturnType<typeof styleMap>;
/**
* @description Local link preview data
* When the doc is in readonly mode, and the link preview data are not provided (stored in the block model),
* We will use the local link preview data fetched from the link previewer service to render the block.
*/
private readonly _localLinkPreview$ = signal<LinkPreviewData>({
icon: null,
title: null,
description: null,
image: null,
});
/**
* @description Link preview data for actual rendering
* When the doc is not in readonly mode, and the link preview data are provided (stored in the block model),
* We will use the model props to render the block.
* Otherwise, we will use the local link preview data to render the block.
*/
linkPreview$ = computed(() => {
const modelProps = this.model.props;
const local = this._localLinkPreview$.value;
return {
icon: modelProps.icon$.value ?? local.icon ?? null,
title: modelProps.title$.value ?? local.title ?? null,
description: modelProps.description$.value ?? local.description ?? null,
image: modelProps.image$.value ?? local.image ?? null,
};
});
private readonly _updateLocalLinkPreview = () => {
// cancel any inflight request
this._fetchAbortController?.abort();
this._fetchAbortController = new AbortController();
this.loading = true;
this.error = false;
this.std
.get(LinkPreviewServiceIdentifier)
.query(this.model.props.url, this._fetchAbortController.signal)
.then(data => {
this._localLinkPreview$.value = {
icon: data.icon ?? null,
title: data.title ?? null,
description: data.description ?? null,
image: data.image ?? null,
};
})
.catch(() => {
this.error = true;
})
.finally(() => {
this.loading = false;
});
};
selectBlock = () => {
const selectionManager = this.std.selection;
const blockSelection = selectionManager.create(BlockSelection, {
blockId: this.blockId,
});
selectionManager.setGroup('note', [blockSelection]);
};
get link() {
return normalizeUrl(this.model.props.url);
}
open = () => {
const link = this.link;
if (!link) return;
window.open(link, '_blank', 'noopener,noreferrer');
};
refreshData = () => {
refreshBookmarkUrlData(this, this._fetchAbortController?.signal).catch(
console.error
);
};
get citationService() {
return this.std.get(CitationProvider);
}
get isCitation() {
return this.citationService.isCitationModel(this.model);
}
get imageProxyService() {
return this.std.get(ImageProxyService);
}
get isCommentHighlighted() {
return (
this.std
.getOptional(BlockElementCommentManager)
?.isBlockCommentHighlighted(this.model) ?? false
);
}
handleClick = (event: MouseEvent) => {
event.stopPropagation();
if (
this.model.parent?.flavour !== 'affine:surface' &&
!this.store.readonly
) {
this.selectBlock();
}
};
handleDoubleClick = (event: MouseEvent) => {
event.stopPropagation();
this.open();
};
private readonly _renderCitationView = () => {
const { url, footnoteIdentifier } = this.model.props;
const { icon, title, description } = this.linkPreview$.value;
const iconSrc = icon ? this.imageProxyService.buildUrl(icon) : undefined;
return html`
<affine-citation-card
.icon=${iconSrc}
.citationTitle=${title || url}
.citationContent=${description}
.citationIdentifier=${footnoteIdentifier}
.onClickCallback=${this.handleClick}
.onDoubleClickCallback=${this.handleDoubleClick}
.active=${this.selected$.value}
></affine-citation-card>
`;
};
private readonly _renderCardView = () => {
return html`<bookmark-card
.bookmark=${this}
.loading=${this.loading}
.error=${this.error}
></bookmark-card>`;
};
private readonly _trackCitationDeleteEvent = () => {
// Check citation delete event
this._disposables.add(
this.std.store.slots.blockUpdated
.pipe(
filter(payload => {
if (!payload.isLocal) return false;
const { flavour, id, type } = payload;
if (
type !== 'delete' ||
flavour !== this.model.flavour ||
id !== this.model.id
)
return false;
const { model } = payload;
if (!this.citationService.isCitationModel(model)) return false;
return true;
})
)
.subscribe(() => {
this.citationService.trackEvent('Delete');
})
);
};
override connectedCallback() {
super.connectedCallback();
const mode = this.std.get(DocModeProvider).getEditorMode();
const miniWidth = `${BOOKMARK_MIN_WIDTH}px`;
this.containerStyleMap = styleMap({
position: 'relative',
width: '100%',
...(mode === 'edgeless' ? { miniWidth } : {}),
});
this._fetchAbortController = new AbortController();
this.contentEditable = 'false';
if (
(!this.model.props.description && !this.model.props.title) ||
(!this.model.props.image && this.model.props.style === 'vertical')
) {
// When the doc is readonly, and the preview data not provided
// We should fetch the preview data and update the local link preview data
if (this.store.readonly) {
this._updateLocalLinkPreview();
return;
}
// Otherwise, we should refresh the data to the model props
this.refreshData();
}
this.disposables.add(
this.model.propsUpdated.subscribe(({ key }) => {
if (key === 'url') {
this.refreshData();
}
})
);
this._trackCitationDeleteEvent();
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this._fetchAbortController?.abort();
}
override renderBlock() {
return html`
<div
draggable="${this.blockDraggable ? 'true' : 'false'}"
class=${classMap({
'affine-bookmark-container': true,
...this.selectedStyle$?.value,
})}
style=${this.containerStyleMap}
>
${this.isCitation ? this._renderCitationView() : this._renderCardView()}
</div>
`;
}
protected override accessor blockContainerStyles: StyleInfo = {
margin: '18px 0',
};
@query('bookmark-card')
accessor bookmarkCard!: HTMLElement;
@property({ attribute: false })
accessor error = false;
@property({ attribute: false })
accessor loading = false;
override accessor selectedStyle = SelectedStyle.Border;
override accessor useCaptionEditor = true;
override accessor useZeroWidth = true;
}
declare global {
interface HTMLElementTagNameMap {
'affine-bookmark': BookmarkBlockComponent;
}
}

View File

@@ -0,0 +1,88 @@
import { BookmarkBlockSchema } from '@blocksuite/affine-model';
import {
EMBED_CARD_HEIGHT,
EMBED_CARD_WIDTH,
} from '@blocksuite/affine-shared/consts';
import { toGfxBlockComponent } from '@blocksuite/std';
import { GfxViewInteractionExtension } from '@blocksuite/std/gfx';
import { type StyleInfo, styleMap } from 'lit/directives/style-map.js';
import { BookmarkBlockComponent } from './bookmark-block.js';
export class BookmarkEdgelessBlockComponent extends toGfxBlockComponent(
BookmarkBlockComponent
) {
override selectedStyle$ = null;
override blockDraggable = false;
override getRenderingRect() {
const elementBound = this.model.elementBound;
const style = this.model.props.style$.value;
return {
x: elementBound.x,
y: elementBound.y,
w: EMBED_CARD_WIDTH[style],
h: EMBED_CARD_HEIGHT[style],
zIndex: this.toZIndex(),
};
}
override connectedCallback(): void {
super.connectedCallback();
this.disposables.add(
this.gfx.selection.slots.updated.subscribe(() => {
this.requestUpdate();
})
);
}
override renderGfxBlock() {
const style = this.model.props.style$.value;
const width = EMBED_CARD_WIDTH[style];
const height = EMBED_CARD_HEIGHT[style];
const bound = this.model.elementBound;
const scaleX = bound.w / width;
const scaleY = bound.h / height;
const isSelected = this.gfx.selection.has(this.model.id);
this.containerStyleMap = styleMap({
width: `100%`,
height: `100%`,
transform: `scale(${scaleX}, ${scaleY})`,
transformOrigin: '0 0',
pointerEvents: isSelected ? 'auto' : 'none',
});
return this.renderPageContent();
}
protected override accessor blockContainerStyles: StyleInfo = {
height: '100%',
};
}
export const BookmarkBlockInteraction = GfxViewInteractionExtension(
BookmarkBlockSchema.model.flavour,
{
resizeConstraint: {
lockRatio: true,
},
handleRotate: () => {
return {
beforeRotate(context) {
context.set({
rotatable: false,
});
},
};
},
}
);
declare global {
interface HTMLElementTagNameMap {
'affine-edgeless-bookmark': BookmarkEdgelessBlockComponent;
}
}

View File

@@ -0,0 +1,2 @@
export { insertBookmarkCommand } from './insert-bookmark.js';
export { insertLinkByQuickSearchCommand } from './insert-link-by-quick-search.js';

View File

@@ -0,0 +1,23 @@
import { insertEmbedCard } from '@blocksuite/affine-block-embed';
import type { EmbedCardStyle } from '@blocksuite/affine-model';
import { EmbedOptionProvider } from '@blocksuite/affine-shared/services';
import type { Command } from '@blocksuite/std';
export const insertBookmarkCommand: Command<
{ url: string },
{ blockId: string; flavour: string }
> = (ctx, next) => {
const { url, std } = ctx;
const embedOptions = std.get(EmbedOptionProvider).getEmbedBlockOptions(url);
let flavour = 'affine:bookmark';
let targetStyle: EmbedCardStyle = 'vertical';
const props: Record<string, unknown> = { url };
if (embedOptions) {
flavour = embedOptions.flavour;
targetStyle = embedOptions.styles[0];
}
const blockId = insertEmbedCard(std, { flavour, targetStyle, props });
if (!blockId) return;
next({ blockId, flavour });
};

View File

@@ -0,0 +1,79 @@
import { insertEmbedIframeWithUrlCommand } from '@blocksuite/affine-block-embed';
import {
type InsertedLinkType,
insertEmbedLinkedDocCommand,
insertEmbedSyncedDocCommand,
type LinkableFlavour,
} from '@blocksuite/affine-block-embed-doc';
import {
DocModeProvider,
EditorSettingProvider,
QuickSearchProvider,
} from '@blocksuite/affine-shared/services';
import type { Command } from '@blocksuite/std';
import { insertBookmarkCommand } from './insert-bookmark';
export const insertLinkByQuickSearchCommand: Command<
{},
{ insertedLinkType: Promise<InsertedLinkType> }
> = (ctx, next) => {
const { std } = ctx;
const quickSearchService = std.getOptional(QuickSearchProvider);
if (!quickSearchService) {
return;
}
const insertedLinkType: Promise<InsertedLinkType> = quickSearchService
.openQuickSearch()
.then(result => {
if (!result) return null;
// add linked doc
if ('docId' in result) {
const editorMode = std.get(DocModeProvider).getEditorMode();
const editorSettings = std.get(EditorSettingProvider);
let flavour: LinkableFlavour = 'affine:embed-linked-doc';
if (editorMode === 'edgeless') {
flavour =
editorSettings.setting$.value.docCanvasPreferView ?? flavour;
}
const insertCommand =
flavour === 'affine:embed-linked-doc'
? insertEmbedLinkedDocCommand
: insertEmbedSyncedDocCommand;
std.command.exec(insertCommand, {
docId: result.docId,
params: result.params,
});
return {
flavour,
};
}
// add normal link;
if ('externalUrl' in result) {
// try to insert embed iframe block first
const [success, { flavour }] = std.command
.chain()
.try(chain => [
chain.pipe(insertEmbedIframeWithUrlCommand, {
url: result.externalUrl,
}),
chain.pipe(insertBookmarkCommand, { url: result.externalUrl }),
])
.run();
if (!success || !flavour) return null;
return {
flavour: flavour as LinkableFlavour,
};
}
return null;
});
next({ insertedLinkType });
};

View File

@@ -0,0 +1,139 @@
import { getEmbedCardIcons } from '@blocksuite/affine-block-embed';
import { LoadingIcon, WebIcon16 } from '@blocksuite/affine-components/icons';
import { ImageProxyService } from '@blocksuite/affine-shared/adapters';
import {
LinkPreviewServiceIdentifier,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import { getHostName } from '@blocksuite/affine-shared/utils';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
import { OpenInNewIcon } from '@blocksuite/icons/lit';
import { isGfxBlockComponent, ShadowlessElement } from '@blocksuite/std';
import { html } from 'lit';
import { property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import type { BookmarkBlockComponent } from '../bookmark-block.js';
import { styles } from '../styles.js';
export class BookmarkCard extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
static override styles = styles;
override connectedCallback(): void {
super.connectedCallback();
this.disposables.add(
this.bookmark.model.propsUpdated.subscribe(() => {
this.requestUpdate();
})
);
this.disposables.add(
this.bookmark.std
.get(ThemeProvider)
.theme$.subscribe(() => this.requestUpdate())
);
}
override render() {
const { url, style } = this.bookmark.model.props;
const { icon, title, description, image } =
this.bookmark.linkPreview$.value;
const cardClassMap = classMap({
loading: this.loading,
error: this.error,
[style]: true,
selected: this.bookmark.selected$.value,
edgeless: isGfxBlockComponent(this.bookmark),
'comment-highlighted': this.bookmark.isCommentHighlighted,
});
const domainName = url.match(
/^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:/\n]+)/im
)?.[1];
const titleText = this.loading
? 'Loading...'
: !title
? this.error
? (domainName ?? 'Link card')
: ''
: title;
const theme = this.bookmark.std.get(ThemeProvider).theme;
const { EmbedCardBannerIcon } = getEmbedCardIcons(theme);
const imageProxyService = this.bookmark.store.get(ImageProxyService);
const titleIcon = this.loading
? LoadingIcon()
: icon
? html`<img src=${imageProxyService.buildUrl(icon)} alt="icon" />`
: WebIcon16;
const descriptionText = this.loading
? ''
: !description
? this.error
? 'Failed to retrieve link information.'
: url
: (description ?? '');
const bannerImage =
!this.loading && image
? html`<img src=${imageProxyService.buildUrl(image)} alt="banner" />`
: EmbedCardBannerIcon;
return html`
<div
class="affine-bookmark-card ${cardClassMap}"
@click=${this.bookmark.handleClick}
@dblclick=${this.bookmark.handleDoubleClick}
>
<div class="affine-bookmark-content">
<div class="affine-bookmark-content-title">
<div class="affine-bookmark-content-title-icon">${titleIcon}</div>
<div class="affine-bookmark-content-title-text">${titleText}</div>
</div>
<div class="affine-bookmark-content-description">
${descriptionText}
</div>
<div class="affine-bookmark-content-url-wrapper">
<div
class="affine-bookmark-content-url"
@click=${this.bookmark.open}
>
<span>${getHostName(url)}</span>
<div class="affine-bookmark-content-url-icon">
${OpenInNewIcon({ width: '12', height: '12' })}
</div>
</div>
</div>
</div>
<div class="affine-bookmark-banner">${bannerImage}</div>
</div>
<affine-link-preview-details
.url=${url}
.floating=${isGfxBlockComponent(this.bookmark)}
.provider=${this.bookmark.std.get(LinkPreviewServiceIdentifier)}
></affine-link-preview-details>
`;
}
@property({ attribute: false })
accessor bookmark!: BookmarkBlockComponent;
@property({ attribute: false })
accessor error!: boolean;
@property({ attribute: false })
accessor loading!: boolean;
}
declare global {
interface HTMLElementTagNameMap {
'bookmark-card': BookmarkCard;
}
}

View File

@@ -0,0 +1 @@
export * from './bookmark-card';

View File

@@ -0,0 +1,65 @@
import { DefaultTool } from '@blocksuite/affine-block-surface';
import { toggleEmbedCardCreateModal } from '@blocksuite/affine-components/embed-card-modal';
import { BookmarkBlockSchema } from '@blocksuite/affine-model';
import {
type SlashMenuConfig,
SlashMenuConfigIdentifier,
} from '@blocksuite/affine-widget-slash-menu';
import { LinkIcon } from '@blocksuite/icons/lit';
import { GfxControllerIdentifier } from '@blocksuite/std/gfx';
import type { ExtensionType } from '@blocksuite/store';
import { LinkTooltip } from './tooltips';
const bookmarkSlashMenuConfig: SlashMenuConfig = {
items: [
{
name: 'Link',
description: 'Add a bookmark for reference.',
icon: LinkIcon(),
tooltip: {
figure: LinkTooltip,
caption: 'Link',
},
group: '4_Content & Media@2',
when: ({ model }) =>
model.store.schema.flavourSchemaMap.has('affine:bookmark'),
action: ({ std, model }) => {
const { host } = std;
const parentModel = host.store.getParent(model);
if (!parentModel) {
return;
}
const index = parentModel.children.indexOf(model) + 1;
toggleEmbedCardCreateModal(
host,
'Links',
'The added link will be displayed as a card view.',
{ mode: 'page', parentModel, index },
({ mode }) => {
if (mode === 'edgeless') {
const gfx = std.get(GfxControllerIdentifier);
gfx.tool.setTool(DefaultTool);
}
}
)
.then(() => {
if (model.text?.length === 0) {
model.store.deleteBlock(model);
}
})
.catch(console.error);
},
},
],
};
export const BookmarkSlashMenuConfigIdentifier = SlashMenuConfigIdentifier(
BookmarkBlockSchema.model.flavour
);
export const BookmarkSlashMenuConfigExtension: ExtensionType = {
setup: di => {
di.addImpl(BookmarkSlashMenuConfigIdentifier, bookmarkSlashMenuConfig);
},
};

View File

@@ -0,0 +1,620 @@
import {
canEmbedAsEmbedBlock,
canEmbedAsIframe,
EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE,
EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE,
} from '@blocksuite/affine-block-embed';
import { reassociateConnectorsCommand } from '@blocksuite/affine-block-surface';
import { toast } from '@blocksuite/affine-components/toast';
import {
BookmarkBlockModel,
BookmarkStyles,
type EmbedCardStyle,
} from '@blocksuite/affine-model';
import {
EMBED_CARD_HEIGHT,
EMBED_CARD_WIDTH,
} from '@blocksuite/affine-shared/consts';
import {
ActionPlacement,
EmbedIframeService,
EmbedOptionProvider,
type LinkEventType,
type ToolbarAction,
type ToolbarActionGroup,
type ToolbarContext,
type ToolbarModuleConfig,
ToolbarModuleExtension,
} from '@blocksuite/affine-shared/services';
import { getBlockProps } from '@blocksuite/affine-shared/utils';
import { Bound } from '@blocksuite/global/gfx';
import {
CaptionIcon,
CopyIcon,
DeleteIcon,
DuplicateIcon,
ResetIcon,
} from '@blocksuite/icons/lit';
import { BlockFlavourIdentifier, BlockSelection } from '@blocksuite/std';
import { type ExtensionType, Slice, Text } from '@blocksuite/store';
import { computed, signal } from '@preact/signals-core';
import { html } from 'lit';
import { keyed } from 'lit/directives/keyed.js';
import * as Y from 'yjs';
import { BookmarkBlockComponent } from '../bookmark-block';
const trackBaseProps = {
category: 'bookmark',
type: 'card view',
};
const previewAction = {
id: 'a.preview',
content(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return null;
const { url } = model.props;
return html`<affine-link-preview .url=${url}></affine-link-preview>`;
},
} satisfies ToolbarAction;
const captionAction = {
id: 'd.caption',
tooltip: 'Caption',
icon: CaptionIcon(),
run(ctx) {
const block = ctx.getCurrentBlockByType(BookmarkBlockComponent);
block?.captionEditor?.show();
ctx.track('OpenedCaptionEditor', {
...trackBaseProps,
control: 'add caption',
});
},
} satisfies ToolbarAction;
const createOnToggleFn =
(
ctx: ToolbarContext,
name: Extract<
LinkEventType,
| 'OpenedViewSelector'
| 'OpenedCardStyleSelector'
| 'OpenedCardScaleSelector'
>,
control: string
) =>
(e: CustomEvent<boolean>) => {
e.stopPropagation();
const opened = e.detail;
if (!opened) return;
ctx.track(name, {
...trackBaseProps,
control,
});
};
const builtinToolbarConfig = {
actions: [
previewAction,
{
id: 'b.conversions',
actions: [
{
id: 'inline',
label: 'Inline view',
run(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return;
const { title, caption, url } = model.props;
const { parent } = model;
const index = parent?.children.indexOf(model);
const yText = new Y.Text();
const insert = title || caption || url;
yText.insert(0, insert);
yText.format(0, insert.length, { link: url });
const text = new Text(yText);
ctx.store.addBlock('affine:paragraph', { text }, parent, index);
ctx.store.deleteBlock(model);
// Clears
ctx.reset();
ctx.select('note');
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'inline view',
});
},
},
{
id: 'card',
label: 'Card view',
disabled: true,
},
{
id: 'embed',
label: 'Embed view',
disabled(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return true;
const url = model.props.url;
return (
!canEmbedAsIframe(ctx.std, url) &&
!canEmbedAsEmbedBlock(ctx.std, url)
);
},
run(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return;
const { caption, url, title, description, style } = model.props;
const { parent } = model;
const index = parent?.children.indexOf(model);
if (!parent) return;
let blockId: string | undefined;
// first try to embed as a custom embed block
if (canEmbedAsEmbedBlock(ctx.std, url)) {
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
if (!options) return;
const { flavour, styles } = options;
const newStyle = styles.includes(style)
? style
: styles.find(s => s !== 'vertical' && s !== 'cube');
blockId = ctx.store.addBlock(
flavour,
{
url,
caption,
title,
description,
style: newStyle,
},
parent,
index
);
} else if (canEmbedAsIframe(ctx.std, url)) {
const embedIframeService = ctx.std.get(EmbedIframeService);
blockId = embedIframeService.addEmbedIframeBlock(
{ url, caption, title, description },
parent.id,
index
);
}
if (!blockId) return;
ctx.store.deleteBlock(model);
// Selects new block
ctx.select('note', [
ctx.selection.create(BlockSelection, { blockId }),
]);
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'embed view',
});
},
},
],
content(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return null;
const actions = this.actions.map(action => ({ ...action }));
const viewType$ = signal(actions[1].label);
const onToggle = createOnToggleFn(
ctx,
'OpenedViewSelector',
'switch view'
);
return html`${keyed(
model,
html`<affine-view-dropdown-menu
@toggle=${onToggle}
.actions=${actions}
.context=${ctx}
.viewTypeSignal=${viewType$}
></affine-view-dropdown-menu>`
)}`;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'c.style',
actions: [
{
id: 'horizontal',
label: 'Large horizontal style',
},
{
id: 'list',
label: 'Small horizontal style',
},
],
content(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return null;
const actions = this.actions.map(action => ({
...action,
run: ({ store }) => {
store.updateBlock(model, { style: action.id });
ctx.track('SelectedCardStyle', {
...trackBaseProps,
control: 'select card style',
type: action.id,
});
},
})) satisfies ToolbarAction[];
const onToggle = createOnToggleFn(
ctx,
'OpenedCardStyleSelector',
'switch card style'
);
return html`${keyed(
model,
html`<affine-card-style-dropdown-menu
@toggle=${onToggle}
.actions=${actions}
.context=${ctx}
.styleSignal=${model.props.style$}
></affine-card-style-dropdown-menu>`
)}`;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
captionAction,
{
placement: ActionPlacement.More,
id: 'a.clipboard',
actions: [
{
id: 'copy',
label: 'Copy',
icon: CopyIcon(),
run(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return;
const slice = Slice.fromModels(ctx.store, [model]);
ctx.clipboard
.copySlice(slice)
.then(() => toast(ctx.host, 'Copied to clipboard'))
.catch(console.error);
},
},
{
id: 'duplicate',
label: 'Duplicate',
icon: DuplicateIcon(),
run(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return;
const { flavour, parent } = model;
const props = getBlockProps(model);
const index = parent?.children.indexOf(model);
ctx.store.addBlock(flavour, props, parent, index);
},
},
],
},
{
placement: ActionPlacement.More,
id: 'b.refresh',
label: 'Reload',
icon: ResetIcon(),
run(ctx) {
const block = ctx.getCurrentBlockByType(BookmarkBlockComponent);
block?.refreshData();
},
},
{
placement: ActionPlacement.More,
id: 'c.delete',
label: 'Delete',
icon: DeleteIcon(),
variant: 'destructive',
run(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return;
ctx.store.deleteBlock(model);
// Clears
ctx.select('note');
ctx.reset();
},
},
],
} as const satisfies ToolbarModuleConfig;
const builtinSurfaceToolbarConfig = {
actions: [
previewAction,
{
id: 'b.conversions',
actions: [
{
id: 'card',
label: 'Card view',
disabled: true,
},
{
id: 'embed',
label: 'Embed view',
run(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return;
const { id: oldId, xywh, parent } = model;
const { url, caption, title, description } = model.props;
let newId: string | undefined;
// first try to embed as a custom embed block
if (canEmbedAsEmbedBlock(ctx.std, url)) {
const options = ctx.std
.get(EmbedOptionProvider)
.getEmbedBlockOptions(url);
if (options?.viewType !== 'embed') return;
const { flavour, styles } = options;
let style: EmbedCardStyle = model.props.style;
if (!styles.includes(style)) {
style = styles[0];
}
const bound = Bound.deserialize(xywh);
bound.w = EMBED_CARD_WIDTH[style];
bound.h = EMBED_CARD_HEIGHT[style];
newId = ctx.store.addBlock(
flavour,
{
url,
caption,
title,
description,
style,
xywh: bound.serialize(),
},
parent
);
} else if (canEmbedAsIframe(ctx.std, url)) {
const embedIframeService = ctx.std.get(EmbedIframeService);
const config = embedIframeService.getConfig(url);
if (!config) {
return;
}
const bound = Bound.deserialize(xywh);
const options = config.options;
const { widthInSurface, heightInSurface } = options ?? {};
bound.w = widthInSurface ?? EMBED_IFRAME_DEFAULT_WIDTH_IN_SURFACE;
bound.h =
heightInSurface ?? EMBED_IFRAME_DEFAULT_HEIGHT_IN_SURFACE;
newId = ctx.store.addBlock(
'affine:embed-iframe',
{ url, caption, title, description, xywh: bound.serialize() },
parent
);
}
if (!newId) return;
ctx.command.exec(reassociateConnectorsCommand, { oldId, newId });
ctx.store.deleteBlock(model);
// Selects new block
ctx.gfx.selection.set({ editing: false, elements: [newId] });
ctx.track('SelectedView', {
...trackBaseProps,
control: 'select view',
type: 'embed view',
});
},
},
],
when(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return false;
const { url } = model.props;
return (
canEmbedAsIframe(ctx.std, url) || canEmbedAsEmbedBlock(ctx.std, url)
);
},
content(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return null;
const actions = this.actions.map(action => ({ ...action }));
const viewType$ = signal('Card view');
const onToggle = createOnToggleFn(
ctx,
'OpenedViewSelector',
'switch view'
);
return html`${keyed(
model,
html`<affine-view-dropdown-menu
@toggle=${onToggle}
.actions=${actions}
.context=${ctx}
.viewTypeSignal=${viewType$}
></affine-view-dropdown-menu>`
)}`;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
id: 'b.style',
actions: (
[
{
id: 'horizontal',
label: 'Large horizontal style',
},
{
id: 'list',
label: 'Small horizontal style',
},
{
id: 'vertical',
label: 'Large vertical style',
},
{
id: 'cube',
label: 'Small vertical style',
},
] as const
).filter(action => BookmarkStyles.includes(action.id)),
content(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return null;
const actions = this.actions.map(action => ({
...action,
run: ({ store }) => {
const style = action.id as EmbedCardStyle;
const bounds = Bound.deserialize(model.xywh);
bounds.w = EMBED_CARD_WIDTH[style];
bounds.h = EMBED_CARD_HEIGHT[style];
const xywh = bounds.serialize();
store.updateBlock(model, { style, xywh });
ctx.track('SelectedCardStyle', {
...trackBaseProps,
control: 'select card style',
type: style,
});
},
})) satisfies ToolbarAction[];
const style$ = model.props.style$;
const onToggle = createOnToggleFn(
ctx,
'OpenedCardStyleSelector',
'switch card style'
);
return html`${keyed(
model,
html`<affine-card-style-dropdown-menu
@toggle=${onToggle}
.actions=${actions}
.context=${ctx}
.styleSignal=${style$}
></affine-card-style-dropdown-menu>`
)}`;
},
} satisfies ToolbarActionGroup<ToolbarAction>,
{
...captionAction,
id: 'c.caption',
},
{
id: 'd.scale',
content(ctx) {
const model = ctx.getCurrentModelByType(BookmarkBlockModel);
if (!model) return null;
const scale$ = computed(() => {
const {
xywh$: { value: xywh },
} = model;
const {
style$: { value: style },
} = model.props;
const bounds = Bound.deserialize(xywh);
const height = EMBED_CARD_HEIGHT[style];
return Math.round(100 * (bounds.h / height));
});
const onSelect = (e: CustomEvent<number>) => {
e.stopPropagation();
const scale = e.detail / 100;
const bounds = Bound.deserialize(model.xywh);
const style = model.props.style;
bounds.w = EMBED_CARD_WIDTH[style] * scale;
bounds.h = EMBED_CARD_HEIGHT[style] * scale;
const xywh = bounds.serialize();
ctx.store.updateBlock(model, { xywh });
ctx.track('SelectedCardScale', {
...trackBaseProps,
control: 'select card scale',
});
};
const onToggle = createOnToggleFn(
ctx,
'OpenedCardScaleSelector',
'switch card scale'
);
const format = (value: number) => `${value}%`;
return html`${keyed(
model,
html`<affine-size-dropdown-menu
@select=${onSelect}
@toggle=${onToggle}
.format=${format}
.sizeSignal=${scale$}
></affine-size-dropdown-menu>`
)}`;
},
},
],
when: ctx => ctx.getSurfaceModelsByType(BookmarkBlockModel).length === 1,
} as const satisfies ToolbarModuleConfig;
export const createBuiltinToolbarConfigExtension = (
flavour: string
): ExtensionType[] => {
const name = flavour.split(':').pop();
return [
ToolbarModuleExtension({
id: BlockFlavourIdentifier(flavour),
config: builtinToolbarConfig,
}),
ToolbarModuleExtension({
id: BlockFlavourIdentifier(`affine:surface:${name}`),
config: builtinSurfaceToolbarConfig,
}),
];
};

View File

@@ -0,0 +1,13 @@
import { html } from 'lit';
// prettier-ignore
export const LinkTooltip = html`<svg width="170" height="68" viewBox="0 0 170 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="170" height="68" rx="2" fill="white"/>
<mask id="mask0_16460_1007" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="170" height="68">
<rect width="170" height="68" rx="2" fill="white"/>
</mask>
<g mask="url(#mask0_16460_1007)">
<text fill="#121212" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="10" letter-spacing="0px"><tspan x="8" y="15.6364">In a decentralized system, we can have a kaleidoscopic </tspan><tspan x="8" y="27.6364">complexity to our data.&#10;</tspan><tspan x="8" y="63.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="75.6364">either have, choose to share, or accept.&#10;</tspan><tspan x="8" y="111.636">For example, one user&#x2019;s edits to a document might be on </tspan><tspan x="8" y="123.636">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="135.636">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="147.636">other users.&#10;</tspan><tspan x="8" y="183.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="195.636">those changes to their version of the document.</tspan></text>
<text fill="#1E67AF" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="11" letter-spacing="0em"><tspan x="8" y="45.5">Learn about AFFiNE</tspan></text>
</g>
</svg>
`;

View File

@@ -0,0 +1,29 @@
import { EdgelessClipboardConfig } from '@blocksuite/affine-block-surface';
import { type BlockSnapshot } from '@blocksuite/store';
export class EdgelessClipboardBookmarkConfig extends EdgelessClipboardConfig {
static override readonly key = 'affine:bookmark';
override createBlock(bookmark: BlockSnapshot): string | null {
if (!this.surface) return null;
const { xywh, style, url, caption, description, icon, image, title } =
bookmark.props;
const bookmarkId = this.crud.addBlock(
'affine:bookmark',
{
xywh,
style,
url,
caption,
description,
icon,
image,
title,
},
this.surface.model.id
);
return bookmarkId;
}
}

View File

@@ -0,0 +1,12 @@
import { BookmarkBlockComponent } from './bookmark-block';
import { BookmarkEdgelessBlockComponent } from './bookmark-edgeless-block';
import { BookmarkCard } from './components/bookmark-card';
export function effects() {
customElements.define(
'affine-edgeless-bookmark',
BookmarkEdgelessBlockComponent
);
customElements.define('affine-bookmark', BookmarkBlockComponent);
customElements.define('bookmark-card', BookmarkCard);
}

View File

@@ -0,0 +1,6 @@
export * from './adapters';
export * from './bookmark-block';
export * from './commands';
export * from './components';
export { BookmarkSlashMenuConfigIdentifier } from './configs/slash-menu';
export * from './edgeless-clipboard-config';

View File

@@ -0,0 +1,17 @@
import {
type StoreExtensionContext,
StoreExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import { BookmarkBlockSchemaExtension } from '@blocksuite/affine-model';
import { BookmarkBlockAdapterExtensions } from './adapters/extension';
export class BookmarkStoreExtension extends StoreExtensionProvider {
override name = 'affine-bookmark-block';
override setup(context: StoreExtensionContext) {
super.setup(context);
context.register(BookmarkBlockSchemaExtension);
context.register(BookmarkBlockAdapterExtensions);
}
}

View File

@@ -0,0 +1,283 @@
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { baseTheme } from '@toeverything/theme';
import { css, unsafeCSS } from 'lit';
export const styles = css`
bookmark-card {
position: relative;
display: block;
height: 100%;
width: 100%;
}
.affine-bookmark-card {
container: affine-bookmark-card / inline-size;
margin: 0 auto;
box-sizing: border-box;
display: flex;
width: 100%;
border-radius: 8px;
border: 1px solid ${unsafeCSSVarV2('layer/background/tertiary')};
background: ${unsafeCSSVarV2('layer/background/primary')};
user-select: none;
}
.affine-bookmark-content {
width: calc(100% - 204px);
display: flex;
flex-direction: column;
flex-grow: 1;
align-self: stretch;
gap: 4px;
padding: 12px;
}
.affine-bookmark-content-title {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
align-self: stretch;
}
.affine-bookmark-content-title-icon {
display: flex;
width: 16px;
height: 16px;
justify-content: center;
align-items: center;
}
.affine-bookmark-content-title-icon img,
.affine-bookmark-content-title-icon object,
.affine-bookmark-content-title-icon svg {
width: 16px;
height: 16px;
fill: var(--affine-background-primary-color);
}
.affine-bookmark-content-title-text {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
word-break: break-word;
overflow: hidden;
text-overflow: ellipsis;
color: var(--affine-text-primary-color);
font-family: var(--affine-font-family);
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 600;
line-height: 22px;
}
.affine-bookmark-content-description {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
flex-grow: 1;
white-space: normal;
word-break: break-word;
overflow: hidden;
text-overflow: ellipsis;
color: var(--affine-text-primary-color);
font-family: var(--affine-font-family);
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 400;
line-height: 20px;
}
.affine-bookmark-content-url {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 4px;
width: max-content;
max-width: 100%;
}
.affine-bookmark-content-url > span {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
word-break: break-all;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
color: var(--affine-text-secondary-color);
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 400;
line-height: 20px;
}
.affine-bookmark-content-url:hover > span {
color: var(--affine-link-color);
}
.affine-bookmark-content-url:hover {
fill: var(--affine-link-color);
}
.affine-bookmark-content-url-icon {
display: flex;
align-items: center;
justify-content: center;
width: 12px;
height: 20px;
}
.affine-bookmark-content-url-icon {
height: 12px;
width: 12px;
color: ${unsafeCSSVar('iconSecondary')};
}
.affine-bookmark-banner {
margin: 12px 12px 0px 0px;
width: 204px;
max-width: 100%;
height: 102px;
}
.affine-bookmark-banner img,
.affine-bookmark-banner object,
.affine-bookmark-banner svg {
width: 204px;
max-width: 100%;
height: 102px;
object-fit: cover;
border-radius: 4px;
}
.affine-bookmark-card.comment-highlighted {
outline: 2px solid ${unsafeCSSVarV2('block/comment/highlightUnderline')};
}
.affine-bookmark-card.loading {
.affine-bookmark-content-title-text {
color: var(--affine-placeholder-color);
}
}
.affine-bookmark-card.error {
.affine-bookmark-content-description {
color: var(--affine-placeholder-color);
}
}
.affine-bookmark-card.selected {
.affine-bookmark-content-url > span {
color: var(--affine-link-color);
}
.affine-bookmark-content-url .affine-bookmark-content-url-icon {
color: var(--affine-link-color);
}
}
.affine-bookmark-card.list {
.affine-bookmark-content {
width: 100%;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.affine-bookmark-content-title {
width: calc(100% - 204px);
}
.affine-bookmark-content-url {
width: 204px;
justify-content: flex-end;
}
.affine-bookmark-content-description {
display: none;
}
.affine-bookmark-banner {
display: none;
}
}
.affine-bookmark-card.vertical {
flex-direction: column-reverse;
height: 100%;
.affine-bookmark-content {
width: 100%;
}
.affine-bookmark-content-description {
-webkit-line-clamp: 6;
max-height: 120px;
}
.affine-bookmark-content-url-wrapper {
max-width: fit-content;
display: flex;
align-items: flex-end;
flex-grow: 1;
cursor: pointer;
}
.affine-bookmark-banner {
width: 340px;
height: 170px;
margin-left: 12px;
}
.affine-bookmark-banner img,
.affine-bookmark-banner object,
.affine-bookmark-banner svg {
width: 340px;
height: 170px;
}
}
.affine-bookmark-card.cube {
.affine-bookmark-content {
width: 100%;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
}
.affine-bookmark-content-title {
flex-direction: column;
gap: 4px;
align-items: flex-start;
}
.affine-bookmark-content-title-text {
-webkit-line-clamp: 2;
}
.affine-bookmark-content-description {
display: none;
}
.affine-bookmark-banner {
display: none;
}
}
@container affine-bookmark-card (width < 375px) {
.affine-bookmark-content {
width: 100%;
}
.affine-bookmark-card:not(.edgeless) .affine-bookmark-banner {
display: none;
}
}
`;

View File

@@ -0,0 +1,47 @@
import { LinkPreviewServiceIdentifier } from '@blocksuite/affine-shared/services';
import { isAbortError } from '@blocksuite/affine-shared/utils';
import type { BookmarkBlockComponent } from './bookmark-block.js';
export async function refreshBookmarkUrlData(
bookmarkElement: BookmarkBlockComponent,
signal?: AbortSignal
) {
let title = null,
description = null,
icon = null,
image = null;
try {
bookmarkElement.loading = true;
const linkPreviewer = bookmarkElement.std.get(LinkPreviewServiceIdentifier);
const bookmarkUrlData = await linkPreviewer.query(
bookmarkElement.model.props.url,
signal
);
title = bookmarkUrlData.title ?? null;
description = bookmarkUrlData.description ?? null;
icon = bookmarkUrlData.icon ?? null;
image = bookmarkUrlData.image ?? null;
if (!title && !description && !icon && !image) {
bookmarkElement.error = true;
}
if (signal?.aborted) return;
bookmarkElement.store.updateBlock(bookmarkElement.model, {
title,
description,
icon,
image,
});
} catch (error) {
if (signal?.aborted || isAbortError(error)) return;
throw error;
} finally {
bookmarkElement.loading = false;
}
}

View File

@@ -0,0 +1,43 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import { BookmarkBlockSchema } from '@blocksuite/affine-model';
import { BlockViewExtension, FlavourExtension } from '@blocksuite/std';
import { literal } from 'lit/static-html.js';
import { BookmarkBlockInteraction } from './bookmark-edgeless-block';
import { BookmarkSlashMenuConfigExtension } from './configs/slash-menu';
import { createBuiltinToolbarConfigExtension } from './configs/toolbar';
import { EdgelessClipboardBookmarkConfig } from './edgeless-clipboard-config';
import { effects } from './effects';
const flavour = BookmarkBlockSchema.model.flavour;
export class BookmarkViewExtension extends ViewExtensionProvider {
override name = 'affine-bookmark-block';
override effect() {
super.effect();
effects();
}
override setup(context: ViewExtensionContext) {
super.setup(context);
context.register([
FlavourExtension(flavour),
BlockViewExtension(flavour, model => {
return model.parent?.flavour === 'affine:surface'
? literal`affine-edgeless-bookmark`
: literal`affine-bookmark`;
}),
BookmarkSlashMenuConfigExtension,
]);
context.register(createBuiltinToolbarConfigExtension(flavour));
const isEdgeless = this.isEdgeless(context.scope);
if (isEdgeless) {
context.register(EdgelessClipboardBookmarkConfig);
context.register(BookmarkBlockInteraction);
}
}
}

View File

@@ -0,0 +1,22 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../embed" },
{ "path": "../embed-doc" },
{ "path": "../surface" },
{ "path": "../../components" },
{ "path": "../../ext-loader" },
{ "path": "../../model" },
{ "path": "../../shared" },
{ "path": "../../widgets/slash-menu" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" },
{ "path": "../../../framework/store" }
]
}

View File

@@ -0,0 +1,25 @@
import { playwright } from '@vitest/browser-playwright';
import { defineConfig } from 'vitest/config';
export default defineConfig({
esbuild: {
target: 'es2018',
},
test: {
browser: {
enabled: true,
headless: true,
instances: [{ browser: 'chromium' }],
provider: playwright(),
isolate: false,
},
include: ['src/__tests__/**/*.unit.spec.ts'],
testTimeout: 500,
coverage: {
provider: 'istanbul',
reporter: ['lcov'],
reportsDirectory: '../../../.coverage/bookmark',
},
restoreMocks: true,
},
});