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
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:
48
blocksuite/affine/blocks/bookmark/package.json
Normal file
48
blocksuite/affine/blocks/bookmark/package.json
Normal 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"
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
13
blocksuite/affine/blocks/bookmark/src/adapters/extension.ts
Normal file
13
blocksuite/affine/blocks/bookmark/src/adapters/extension.ts
Normal 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();
|
||||
10
blocksuite/affine/blocks/bookmark/src/adapters/html.ts
Normal file
10
blocksuite/affine/blocks/bookmark/src/adapters/html.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createEmbedBlockHtmlAdapterMatcher } from '@blocksuite/affine-block-embed';
|
||||
import { BookmarkBlockSchema } from '@blocksuite/affine-model';
|
||||
import { BlockHtmlAdapterExtension } from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
export const bookmarkBlockHtmlAdapterMatcher =
|
||||
createEmbedBlockHtmlAdapterMatcher(BookmarkBlockSchema.model.flavour);
|
||||
|
||||
export const BookmarkBlockHtmlAdapterExtension = BlockHtmlAdapterExtension(
|
||||
bookmarkBlockHtmlAdapterMatcher
|
||||
);
|
||||
4
blocksuite/affine/blocks/bookmark/src/adapters/index.ts
Normal file
4
blocksuite/affine/blocks/bookmark/src/adapters/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './html.js';
|
||||
export * from './markdown/index.js';
|
||||
export * from './notion-html.js';
|
||||
export * from './plain-text.js';
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,71 @@
|
||||
import { BookmarkBlockSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
BlockNotionHtmlAdapterExtension,
|
||||
type BlockNotionHtmlAdapterMatcher,
|
||||
HastUtils,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import { nanoid } from '@blocksuite/store';
|
||||
|
||||
export const bookmarkBlockNotionHtmlAdapterMatcher: BlockNotionHtmlAdapterMatcher =
|
||||
{
|
||||
flavour: BookmarkBlockSchema.model.flavour,
|
||||
toMatch: o => {
|
||||
return (
|
||||
HastUtils.isElement(o.node) &&
|
||||
o.node.tagName === 'figure' &&
|
||||
!!HastUtils.querySelector(o.node, '.bookmark')
|
||||
);
|
||||
},
|
||||
fromMatch: () => false,
|
||||
toBlockSnapshot: {
|
||||
enter: (o, context) => {
|
||||
if (!HastUtils.isElement(o.node)) {
|
||||
return;
|
||||
}
|
||||
const bookmark = HastUtils.querySelector(o.node, '.bookmark');
|
||||
if (!bookmark) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { walkerContext } = context;
|
||||
const bookmarkURL = bookmark.properties?.href;
|
||||
const bookmarkTitle = HastUtils.getTextContent(
|
||||
HastUtils.querySelector(bookmark, '.bookmark-title')
|
||||
);
|
||||
const bookmarkDescription = HastUtils.getTextContent(
|
||||
HastUtils.querySelector(bookmark, '.bookmark-description')
|
||||
);
|
||||
const bookmarkIcon = HastUtils.querySelector(
|
||||
bookmark,
|
||||
'.bookmark-icon'
|
||||
);
|
||||
const bookmarkIconURL =
|
||||
typeof bookmarkIcon?.properties?.src === 'string'
|
||||
? bookmarkIcon.properties.src
|
||||
: '';
|
||||
walkerContext
|
||||
.openNode(
|
||||
{
|
||||
type: 'block',
|
||||
id: nanoid(),
|
||||
flavour: BookmarkBlockSchema.model.flavour,
|
||||
props: {
|
||||
type: 'card',
|
||||
url: bookmarkURL ?? '',
|
||||
title: bookmarkTitle,
|
||||
description: bookmarkDescription,
|
||||
icon: bookmarkIconURL,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
'children'
|
||||
)
|
||||
.closeNode();
|
||||
walkerContext.skipAllChildren();
|
||||
},
|
||||
},
|
||||
fromBlockSnapshot: {},
|
||||
};
|
||||
|
||||
export const BookmarkBlockNotionHtmlAdapterExtension =
|
||||
BlockNotionHtmlAdapterExtension(bookmarkBlockNotionHtmlAdapterMatcher);
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createEmbedBlockPlainTextAdapterMatcher } from '@blocksuite/affine-block-embed';
|
||||
import { BookmarkBlockSchema } from '@blocksuite/affine-model';
|
||||
import { BlockPlainTextAdapterExtension } from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
export const bookmarkBlockPlainTextAdapterMatcher =
|
||||
createEmbedBlockPlainTextAdapterMatcher(BookmarkBlockSchema.model.flavour);
|
||||
|
||||
export const BookmarkBlockPlainTextAdapterExtension =
|
||||
BlockPlainTextAdapterExtension(bookmarkBlockPlainTextAdapterMatcher);
|
||||
293
blocksuite/affine/blocks/bookmark/src/bookmark-block.ts
Normal file
293
blocksuite/affine/blocks/bookmark/src/bookmark-block.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
2
blocksuite/affine/blocks/bookmark/src/commands/index.ts
Normal file
2
blocksuite/affine/blocks/bookmark/src/commands/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { insertBookmarkCommand } from './insert-bookmark.js';
|
||||
export { insertLinkByQuickSearchCommand } from './insert-link-by-quick-search.js';
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './bookmark-card';
|
||||
65
blocksuite/affine/blocks/bookmark/src/configs/slash-menu.ts
Normal file
65
blocksuite/affine/blocks/bookmark/src/configs/slash-menu.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
620
blocksuite/affine/blocks/bookmark/src/configs/toolbar.ts
Normal file
620
blocksuite/affine/blocks/bookmark/src/configs/toolbar.ts
Normal 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,
|
||||
}),
|
||||
];
|
||||
};
|
||||
13
blocksuite/affine/blocks/bookmark/src/configs/tooltips.ts
Normal file
13
blocksuite/affine/blocks/bookmark/src/configs/tooltips.ts
Normal 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. </tspan><tspan x="8" y="63.6364">Any user may have a different perspective on what data they </tspan><tspan x="8" y="75.6364">either have, choose to share, or accept. </tspan><tspan x="8" y="111.636">For example, one user’s edits to a document might be on </tspan><tspan x="8" y="123.636">their laptop on an airplane; when the plane lands and the </tspan><tspan x="8" y="135.636">computer reconnects, those changes are distributed to </tspan><tspan x="8" y="147.636">other users. </tspan><tspan x="8" y="183.636">Other users might choose to accept all, some, or none of </tspan><tspan x="8" y="195.636">those changes to their version of the document.</tspan></text>
|
||||
<text fill="#1E67AF" xml:space="preserve" style="white-space: pre" font-family="Inter" font-size="11" letter-spacing="0em"><tspan x="8" y="45.5">Learn about AFFiNE</tspan></text>
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
12
blocksuite/affine/blocks/bookmark/src/effects.ts
Normal file
12
blocksuite/affine/blocks/bookmark/src/effects.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { BookmarkBlockComponent } from './bookmark-block';
|
||||
import { BookmarkEdgelessBlockComponent } from './bookmark-edgeless-block';
|
||||
import { BookmarkCard } from './components/bookmark-card';
|
||||
|
||||
export function effects() {
|
||||
customElements.define(
|
||||
'affine-edgeless-bookmark',
|
||||
BookmarkEdgelessBlockComponent
|
||||
);
|
||||
customElements.define('affine-bookmark', BookmarkBlockComponent);
|
||||
customElements.define('bookmark-card', BookmarkCard);
|
||||
}
|
||||
6
blocksuite/affine/blocks/bookmark/src/index.ts
Normal file
6
blocksuite/affine/blocks/bookmark/src/index.ts
Normal 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';
|
||||
17
blocksuite/affine/blocks/bookmark/src/store.ts
Normal file
17
blocksuite/affine/blocks/bookmark/src/store.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
283
blocksuite/affine/blocks/bookmark/src/styles.ts
Normal file
283
blocksuite/affine/blocks/bookmark/src/styles.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
47
blocksuite/affine/blocks/bookmark/src/utils.ts
Normal file
47
blocksuite/affine/blocks/bookmark/src/utils.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
43
blocksuite/affine/blocks/bookmark/src/view.ts
Normal file
43
blocksuite/affine/blocks/bookmark/src/view.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
22
blocksuite/affine/blocks/bookmark/tsconfig.json
Normal file
22
blocksuite/affine/blocks/bookmark/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
25
blocksuite/affine/blocks/bookmark/vitest.config.ts
Normal file
25
blocksuite/affine/blocks/bookmark/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user