snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (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 / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
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 / 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 / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (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 3s
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 / 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 / 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 / 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 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 (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (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 (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
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 / 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 (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
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (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 / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
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 / 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 / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (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 3s
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 / 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 / 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 / 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 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 (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (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 (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
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 / 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 (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
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped
This commit is contained in:
44
blocksuite/affine/inlines/footnote/package.json
Normal file
44
blocksuite/affine/inlines/footnote/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@blocksuite/affine-inline-footnote",
|
||||
"description": "Inline footnote for BlockSuite.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "yarn run -T tsc"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"keywords": [],
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@blocksuite/affine-components": "workspace:*",
|
||||
"@blocksuite/affine-ext-loader": "workspace:*",
|
||||
"@blocksuite/affine-model": "workspace:*",
|
||||
"@blocksuite/affine-shared": "workspace:*",
|
||||
"@blocksuite/global": "workspace:*",
|
||||
"@blocksuite/std": "workspace:*",
|
||||
"@blocksuite/store": "workspace:*",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@preact/signals-core": "^1.8.0",
|
||||
"@toeverything/theme": "^1.1.23",
|
||||
"lit": "^3.2.0",
|
||||
"lit-html": "^3.2.1",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/browser-playwright": "^4.1.10",
|
||||
"playwright": "=1.58.2",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./view": "./src/view.ts",
|
||||
"./store": "./src/store.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"dist",
|
||||
"!src/__tests__",
|
||||
"!dist/__tests__"
|
||||
],
|
||||
"version": "0.27.5"
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { preprocessFootnoteReference } from '../../adapters/markdown/preprocessor';
|
||||
|
||||
describe('FootnoteReferenceMarkdownPreprocessorExtension', () => {
|
||||
it('should add space before footnote reference when it follows a URL', () => {
|
||||
const content = 'https://example.com[^label]';
|
||||
const expected = 'https://example.com [^label]';
|
||||
expect(preprocessFootnoteReference(content)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should add space before footnote reference when URL has text prefix with space', () => {
|
||||
const content = 'hello world https://example.com[^label]';
|
||||
const expected = 'hello world https://example.com [^label]';
|
||||
expect(preprocessFootnoteReference(content)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should add space before footnote reference when URL has text prefix with dash', () => {
|
||||
const content = 'text-https://example.com[^label]';
|
||||
const expected = 'text-https://example.com [^label]';
|
||||
expect(preprocessFootnoteReference(content)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should not add space when footnote reference follows non-URL text', () => {
|
||||
const content = 'normal text[^label]';
|
||||
const expected = 'normal text[^label]';
|
||||
expect(preprocessFootnoteReference(content)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should not add space when there is already a space before footnote reference', () => {
|
||||
const content = 'https://example.com [^label]';
|
||||
const expected = 'https://example.com [^label]';
|
||||
expect(preprocessFootnoteReference(content)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle multiple footnote references with mixed URL and non-URL text', () => {
|
||||
const content = 'https://example.com[^1]normal text[^2]http://test.com[^3]';
|
||||
const expected =
|
||||
'https://example.com [^1]normal text[^2]http://test.com [^3]';
|
||||
expect(preprocessFootnoteReference(content)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should not modify footnote definitions', () => {
|
||||
const content = '[^label]: This is a footnote definition';
|
||||
const expected = '[^label]: This is a footnote definition';
|
||||
expect(preprocessFootnoteReference(content)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle content without footnote references', () => {
|
||||
const content = 'This is a normal text without any footnotes';
|
||||
const expected = 'This is a normal text without any footnotes';
|
||||
expect(preprocessFootnoteReference(content)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle complex URLs with paths and parameters', () => {
|
||||
const content = 'https://example.com/path?param=value[^label]';
|
||||
const expected = 'https://example.com/path?param=value [^label]';
|
||||
expect(preprocessFootnoteReference(content)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle invalid URLs', () => {
|
||||
const content = 'not-a-url[^label]';
|
||||
const expected = 'not-a-url[^label]';
|
||||
expect(preprocessFootnoteReference(content)).toBe(expected);
|
||||
});
|
||||
});
|
||||
3
blocksuite/affine/inlines/footnote/src/adapters/index.ts
Normal file
3
blocksuite/affine/inlines/footnote/src/adapters/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './markdown/inline-delta';
|
||||
export * from './markdown/markdown-inline';
|
||||
export * from './markdown/preprocessor';
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
FOOTNOTE_DEFINITION_PREFIX,
|
||||
InlineDeltaToMarkdownAdapterExtension,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
import type { PhrasingContent } from 'mdast';
|
||||
|
||||
export const footnoteReferenceDeltaToMarkdownAdapterMatcher =
|
||||
InlineDeltaToMarkdownAdapterExtension({
|
||||
name: 'footnote-reference',
|
||||
match: delta => !!delta.attributes?.footnote,
|
||||
toAST: (delta, context) => {
|
||||
const mdast: PhrasingContent = {
|
||||
type: 'text',
|
||||
value: delta.insert,
|
||||
};
|
||||
const footnote = delta.attributes?.footnote;
|
||||
if (!footnote) {
|
||||
return mdast;
|
||||
}
|
||||
const footnoteDefinitionKey = `${FOOTNOTE_DEFINITION_PREFIX}${footnote.label}`;
|
||||
const { configs } = context;
|
||||
// FootnoteReference should be paired with FootnoteDefinition
|
||||
// If the footnoteDefinition is not in the configs, set it to configs
|
||||
// We should add the footnoteDefinition markdown ast nodes to tree after all the footnoteReference markdown ast nodes are added
|
||||
if (!configs.has(footnoteDefinitionKey)) {
|
||||
// clone the footnote reference
|
||||
const clonedFootnoteReference = { ...footnote.reference };
|
||||
// If the footnote reference contains url, encode it
|
||||
if (clonedFootnoteReference.url) {
|
||||
clonedFootnoteReference.url = encodeURIComponent(
|
||||
clonedFootnoteReference.url
|
||||
);
|
||||
}
|
||||
if (clonedFootnoteReference.favicon) {
|
||||
clonedFootnoteReference.favicon = encodeURIComponent(
|
||||
clonedFootnoteReference.favicon
|
||||
);
|
||||
}
|
||||
configs.set(
|
||||
footnoteDefinitionKey,
|
||||
JSON.stringify(clonedFootnoteReference)
|
||||
);
|
||||
}
|
||||
return {
|
||||
type: 'footnoteReference',
|
||||
label: footnote.label,
|
||||
identifier: footnote.label,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { FootNoteReferenceParamsSchema } from '@blocksuite/affine-model';
|
||||
import {
|
||||
FOOTNOTE_DEFINITION_PREFIX,
|
||||
MarkdownASTToDeltaExtension,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
export const markdownFootnoteReferenceToDeltaMatcher =
|
||||
MarkdownASTToDeltaExtension({
|
||||
name: 'footnote-reference',
|
||||
match: ast => ast.type === 'footnoteReference',
|
||||
toDelta: (ast, context) => {
|
||||
if (ast.type !== 'footnoteReference') {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const { configs } = context;
|
||||
const footnoteDefinitionKey = `${FOOTNOTE_DEFINITION_PREFIX}${ast.identifier}`;
|
||||
const footnoteDefinition = configs.get(footnoteDefinitionKey);
|
||||
if (!footnoteDefinition) {
|
||||
return [];
|
||||
}
|
||||
const footnoteDefinitionJson = 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
|
||||
);
|
||||
}
|
||||
const footnoteReference = FootNoteReferenceParamsSchema.parse(
|
||||
footnoteDefinitionJson
|
||||
);
|
||||
|
||||
// If the footnote reference is an attachment, and the file type is not set,
|
||||
// Try to infer the file type from the file name.
|
||||
const { type: referenceType, fileName, fileType } = footnoteReference;
|
||||
if (referenceType === 'attachment' && fileName && !fileType) {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase();
|
||||
if (ext) {
|
||||
footnoteReference.fileType = ext;
|
||||
}
|
||||
}
|
||||
|
||||
const footnote = {
|
||||
label: ast.identifier,
|
||||
reference: footnoteReference,
|
||||
};
|
||||
return [{ insert: ' ', attributes: { footnote } }];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
type MarkdownAdapterPreprocessor,
|
||||
MarkdownPreprocessorExtension,
|
||||
} from '@blocksuite/affine-shared/adapters';
|
||||
|
||||
/**
|
||||
* Check if a string is a URL
|
||||
* @param str
|
||||
* @returns
|
||||
*/
|
||||
function isUrl(str: string): boolean {
|
||||
try {
|
||||
new URL(str);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preprocess footnote references to avoid markdown link parsing
|
||||
* Only add space when footnote reference follows a URL
|
||||
* @param content
|
||||
* @returns
|
||||
* @example
|
||||
* ```md
|
||||
* https://example.com[^label] -> https://example.com [^label]
|
||||
* normal text[^label] -> normal text[^label]
|
||||
* ```
|
||||
*/
|
||||
export function preprocessFootnoteReference(content: string) {
|
||||
return content.replace(
|
||||
/([^\s]+?)(\[\^[^\]]+\])(?!:)/g,
|
||||
(match, prevText, footnoteRef) => {
|
||||
// Only add space if the previous text is a URL
|
||||
if (isUrl(prevText)) {
|
||||
return prevText + ' ' + footnoteRef;
|
||||
}
|
||||
// Otherwise return the original match
|
||||
return match;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const footnoteReferencePreprocessor: MarkdownAdapterPreprocessor = {
|
||||
name: 'footnote-reference',
|
||||
levels: ['block', 'slice', 'doc'],
|
||||
preprocess: content => {
|
||||
return preprocessFootnoteReference(content);
|
||||
},
|
||||
};
|
||||
|
||||
export const FootnoteReferenceMarkdownPreprocessorExtension =
|
||||
MarkdownPreprocessorExtension(footnoteReferencePreprocessor);
|
||||
17
blocksuite/affine/inlines/footnote/src/effects.ts
Normal file
17
blocksuite/affine/inlines/footnote/src/effects.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { AffineFootnoteNode } from './footnote-node/footnote-node';
|
||||
import { FootNotePopup } from './footnote-node/footnote-popup';
|
||||
import { FootNotePopupChip } from './footnote-node/footnote-popup-chip';
|
||||
|
||||
export function effects() {
|
||||
customElements.define('affine-footnote-node', AffineFootnoteNode);
|
||||
customElements.define('footnote-popup', FootNotePopup);
|
||||
customElements.define('footnote-popup-chip', FootNotePopupChip);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'affine-footnote-node': AffineFootnoteNode;
|
||||
'footnote-popup': FootNotePopup;
|
||||
'footnote-popup-chip': FootNotePopupChip;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { FootNote } from '@blocksuite/affine-model';
|
||||
import { createIdentifier } from '@blocksuite/global/di';
|
||||
import { type BlockStdScope, StdIdentifier } from '@blocksuite/std';
|
||||
import type { ExtensionType } from '@blocksuite/store';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
type FootNoteNodeRenderer = (
|
||||
footnote: FootNote,
|
||||
std: BlockStdScope
|
||||
) => TemplateResult<1>;
|
||||
|
||||
type FootNotePopupRenderer = (
|
||||
footnote: FootNote,
|
||||
std: BlockStdScope,
|
||||
abortController: AbortController
|
||||
) => TemplateResult<1>;
|
||||
|
||||
export type FootNotePopupClickHandler = (
|
||||
footnote: FootNote,
|
||||
abortController: AbortController
|
||||
) => void;
|
||||
|
||||
export interface FootNoteNodeConfig {
|
||||
customNodeRenderer?: FootNoteNodeRenderer;
|
||||
customPopupRenderer?: FootNotePopupRenderer;
|
||||
interactive?: boolean;
|
||||
hidePopup?: boolean;
|
||||
disableHoverEffect?: boolean;
|
||||
onPopupClick?: FootNotePopupClickHandler;
|
||||
}
|
||||
|
||||
export class FootNoteNodeConfigProvider {
|
||||
private _customNodeRenderer?: FootNoteNodeRenderer;
|
||||
private _customPopupRenderer?: FootNotePopupRenderer;
|
||||
private _hidePopup: boolean;
|
||||
private _interactive: boolean;
|
||||
private _disableHoverEffect: boolean;
|
||||
private _onPopupClick?: FootNotePopupClickHandler;
|
||||
|
||||
get customNodeRenderer() {
|
||||
return this._customNodeRenderer;
|
||||
}
|
||||
|
||||
get customPopupRenderer() {
|
||||
return this._customPopupRenderer;
|
||||
}
|
||||
|
||||
get onPopupClick() {
|
||||
return this._onPopupClick;
|
||||
}
|
||||
|
||||
get doc() {
|
||||
return this.std.store;
|
||||
}
|
||||
|
||||
get hidePopup() {
|
||||
return this._hidePopup;
|
||||
}
|
||||
|
||||
get interactive() {
|
||||
return this._interactive;
|
||||
}
|
||||
|
||||
get disableHoverEffect() {
|
||||
return this._disableHoverEffect;
|
||||
}
|
||||
|
||||
constructor(
|
||||
config: FootNoteNodeConfig,
|
||||
readonly std: BlockStdScope
|
||||
) {
|
||||
this._customNodeRenderer = config.customNodeRenderer;
|
||||
this._customPopupRenderer = config.customPopupRenderer;
|
||||
this._hidePopup = config.hidePopup ?? false;
|
||||
this._interactive = config.interactive ?? true;
|
||||
this._disableHoverEffect = config.disableHoverEffect ?? false;
|
||||
this._onPopupClick = config.onPopupClick;
|
||||
}
|
||||
|
||||
setCustomNodeRenderer(renderer: FootNoteNodeRenderer) {
|
||||
this._customNodeRenderer = renderer;
|
||||
}
|
||||
|
||||
setCustomPopupRenderer(renderer: FootNotePopupRenderer) {
|
||||
this._customPopupRenderer = renderer;
|
||||
}
|
||||
|
||||
setHidePopup(hidePopup: boolean) {
|
||||
this._hidePopup = hidePopup;
|
||||
}
|
||||
|
||||
setInteractive(interactive: boolean) {
|
||||
this._interactive = interactive;
|
||||
}
|
||||
|
||||
setDisableHoverEffect(disableHoverEffect: boolean) {
|
||||
this._disableHoverEffect = disableHoverEffect;
|
||||
}
|
||||
|
||||
setPopupClick(onPopupClick: FootNotePopupClickHandler) {
|
||||
this._onPopupClick = onPopupClick;
|
||||
}
|
||||
}
|
||||
|
||||
export const FootNoteNodeConfigIdentifier =
|
||||
createIdentifier<FootNoteNodeConfigProvider>('AffineFootNoteNodeConfig');
|
||||
|
||||
export function FootNoteNodeConfigExtension(
|
||||
config: FootNoteNodeConfig
|
||||
): ExtensionType {
|
||||
return {
|
||||
setup: di => {
|
||||
di.addImpl(
|
||||
FootNoteNodeConfigIdentifier,
|
||||
provider =>
|
||||
new FootNoteNodeConfigProvider(config, provider.get(StdIdentifier))
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { HoverController } from '@blocksuite/affine-components/hover';
|
||||
import { PeekViewProvider } from '@blocksuite/affine-components/peek';
|
||||
import type { FootNote } from '@blocksuite/affine-model';
|
||||
import { CitationProvider } from '@blocksuite/affine-shared/services';
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import { isValidUrl, normalizeUrl } from '@blocksuite/affine-shared/utils';
|
||||
import { WithDisposable } from '@blocksuite/global/lit';
|
||||
import {
|
||||
BlockSelection,
|
||||
type BlockStdScope,
|
||||
ShadowlessElement,
|
||||
TextSelection,
|
||||
} from '@blocksuite/std';
|
||||
import {
|
||||
INLINE_ROOT_ATTR,
|
||||
type InlineRootElement,
|
||||
ZERO_WIDTH_FOR_EMBED_NODE,
|
||||
ZERO_WIDTH_FOR_EMPTY_LINE,
|
||||
} from '@blocksuite/std/inline';
|
||||
import type { DeltaInsert } from '@blocksuite/store';
|
||||
import { flip, offset, shift } from '@floating-ui/dom';
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { css, html, nothing, unsafeCSS } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit-html/directives/class-map.js';
|
||||
import { ref } from 'lit-html/directives/ref.js';
|
||||
|
||||
import type { FootNoteNodeConfigProvider } from './footnote-config';
|
||||
|
||||
// Virtual padding for the footnote popup overflow detection offsets.
|
||||
const POPUP_SHIFT_PADDING = 8;
|
||||
// The offset between the footnote node and the popup.
|
||||
const POPUP_OFFSET = 4;
|
||||
|
||||
export class AffineFootnoteNode extends WithDisposable(ShadowlessElement) {
|
||||
static override styles = css`
|
||||
.footnote-node {
|
||||
padding: 0 2px;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.footnote-node {
|
||||
.footnote-content-default {
|
||||
display: inline-block;
|
||||
background: ${unsafeCSSVarV2('block/footnote/numberBgHover')};
|
||||
color: ${unsafeCSSVarV2('button/pureWhiteText')};
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
line-height: 14px;
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||
transition: background 0.3s ease-in-out;
|
||||
transform: translateY(-0.2em);
|
||||
}
|
||||
}
|
||||
|
||||
.footnote-node.hover-effect {
|
||||
.footnote-content-default {
|
||||
color: var(--affine-text-primary-color);
|
||||
background: ${unsafeCSSVarV2('block/footnote/numberBg')};
|
||||
}
|
||||
}
|
||||
|
||||
.footnote-node.hover-effect:hover {
|
||||
.footnote-content-default {
|
||||
color: ${unsafeCSSVarV2('button/pureWhiteText')};
|
||||
background: ${unsafeCSSVarV2('block/footnote/numberBgHover')};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
get customNodeRenderer() {
|
||||
return this.config?.customNodeRenderer;
|
||||
}
|
||||
|
||||
get customPopupRenderer() {
|
||||
return this.config?.customPopupRenderer;
|
||||
}
|
||||
|
||||
get interactive() {
|
||||
return this.config?.interactive;
|
||||
}
|
||||
|
||||
get hidePopup() {
|
||||
return this.config?.hidePopup;
|
||||
}
|
||||
|
||||
get disableHoverEffect() {
|
||||
return this.config?.disableHoverEffect;
|
||||
}
|
||||
|
||||
get onPopupClick() {
|
||||
return this.config?.onPopupClick;
|
||||
}
|
||||
|
||||
get inlineEditor() {
|
||||
const inlineRoot = this.closest<InlineRootElement<AffineTextAttributes>>(
|
||||
`[${INLINE_ROOT_ATTR}]`
|
||||
);
|
||||
return inlineRoot?.inlineEditor;
|
||||
}
|
||||
|
||||
get selfInlineRange() {
|
||||
const selfInlineRange = this.inlineEditor?.getInlineRangeFromElement(this);
|
||||
return selfInlineRange;
|
||||
}
|
||||
|
||||
get footnote() {
|
||||
return this.delta.attributes?.footnote;
|
||||
}
|
||||
|
||||
get readonly() {
|
||||
return this.std.store.readonly;
|
||||
}
|
||||
|
||||
get citationService() {
|
||||
return this.std.get(CitationProvider);
|
||||
}
|
||||
|
||||
onFootnoteClick = () => {
|
||||
if (!this.footnote) {
|
||||
return;
|
||||
}
|
||||
const { type, docId, url } = this.footnote.reference;
|
||||
|
||||
switch (type) {
|
||||
case 'doc':
|
||||
if (docId) {
|
||||
this._handleDocReference(docId);
|
||||
}
|
||||
break;
|
||||
case 'url':
|
||||
if (url) {
|
||||
this._handleUrlReference(url);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _handleDocReference = (docId: string) => {
|
||||
this.std
|
||||
.getOptional(PeekViewProvider)
|
||||
?.peek({
|
||||
docId,
|
||||
})
|
||||
.catch(console.error);
|
||||
};
|
||||
|
||||
private readonly _handleUrlReference = (url: string) => {
|
||||
const normalizedUrl = normalizeUrl(url);
|
||||
if (!normalizedUrl || !isValidUrl(normalizedUrl)) return;
|
||||
window.open(normalizedUrl, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
private readonly _updateFootnoteAttributes = (footnote: FootNote) => {
|
||||
if (!this.footnote || this.readonly) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.inlineEditor || !this.selfInlineRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.inlineEditor.formatText(this.selfInlineRange, {
|
||||
footnote: footnote,
|
||||
});
|
||||
};
|
||||
|
||||
private readonly _FootNoteDefaultContent = (footnote: FootNote) => {
|
||||
const label =
|
||||
footnote.label.match(/^(?:doc|attachment)-(\d+)$/)?.[1] ?? footnote.label;
|
||||
return html`<span
|
||||
class="footnote-content-default"
|
||||
@click=${this.onFootnoteClick}
|
||||
>${label}</span
|
||||
>`;
|
||||
};
|
||||
|
||||
private readonly _FootNotePopup = (
|
||||
footnote: FootNote,
|
||||
abortController: AbortController
|
||||
) => {
|
||||
return this.customPopupRenderer
|
||||
? this.customPopupRenderer(footnote, this.std, abortController)
|
||||
: html`<footnote-popup
|
||||
.footnote=${footnote}
|
||||
.std=${this.std}
|
||||
.abortController=${abortController}
|
||||
.onPopupClick=${this.onPopupClick ?? this.onFootnoteClick}
|
||||
.updateFootnoteAttributes=${this._updateFootnoteAttributes}
|
||||
></footnote-popup>`;
|
||||
};
|
||||
|
||||
private readonly _whenHover: HoverController = new HoverController(
|
||||
this,
|
||||
({ abortController }) => {
|
||||
const { footnote } = this;
|
||||
if (!footnote) return null;
|
||||
|
||||
if (
|
||||
this.config?.hidePopup ||
|
||||
!this.selfInlineRange ||
|
||||
!this.inlineEditor
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selection = this.std?.selection;
|
||||
if (!selection) {
|
||||
return null;
|
||||
}
|
||||
const textSelection = selection.find(TextSelection);
|
||||
if (!!textSelection && !textSelection.isCollapsed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const blockSelections = selection.filter(BlockSelection);
|
||||
if (blockSelections.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.citationService.trackEvent('Hover', {
|
||||
control: 'Source Footnote',
|
||||
});
|
||||
|
||||
return {
|
||||
template: this._FootNotePopup(footnote, abortController),
|
||||
container: this.std.host,
|
||||
computePosition: {
|
||||
referenceElement: this,
|
||||
placement: 'top',
|
||||
autoUpdate: true,
|
||||
middleware: [
|
||||
shift({ padding: POPUP_SHIFT_PADDING }),
|
||||
flip(),
|
||||
offset(POPUP_OFFSET),
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
{ enterDelay: 300 }
|
||||
);
|
||||
|
||||
override render() {
|
||||
const attributes = this.delta.attributes;
|
||||
const footnote = attributes?.footnote;
|
||||
if (!footnote) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const node = this.customNodeRenderer
|
||||
? this.customNodeRenderer(footnote, this.std)
|
||||
: this._FootNoteDefaultContent(footnote);
|
||||
|
||||
const nodeClasses = classMap({
|
||||
'footnote-node': true,
|
||||
'hover-effect': !this.disableHoverEffect,
|
||||
});
|
||||
|
||||
return html`<span
|
||||
${this.hidePopup ? '' : ref(this._whenHover.setReference)}
|
||||
class=${nodeClasses}
|
||||
>${node}<v-text .str=${ZERO_WIDTH_FOR_EMBED_NODE}></v-text
|
||||
></span>`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor config: FootNoteNodeConfigProvider | undefined = undefined;
|
||||
|
||||
@property({ type: Object })
|
||||
accessor delta: DeltaInsert<AffineTextAttributes> = {
|
||||
insert: ZERO_WIDTH_FOR_EMPTY_LINE,
|
||||
attributes: {},
|
||||
};
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor std!: BlockStdScope;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import {
|
||||
css,
|
||||
html,
|
||||
LitElement,
|
||||
nothing,
|
||||
type TemplateResult,
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
export class FootNotePopupChip extends LitElement {
|
||||
static override styles = css`
|
||||
.popup-chip-container {
|
||||
display: flex;
|
||||
height: 22px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.prefix-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
color: ${unsafeCSSVarV2('icon/primary')};
|
||||
border-radius: 4px;
|
||||
|
||||
svg,
|
||||
img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: ${unsafeCSSVarV2('icon/primary')};
|
||||
}
|
||||
}
|
||||
|
||||
.popup-chip-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
color: ${unsafeCSSVarV2('text/primary')};
|
||||
font-size: var(--affine-font-sm);
|
||||
font-weight: 500;
|
||||
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||
}
|
||||
`;
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="popup-chip-container">
|
||||
${
|
||||
this.prefixIcon
|
||||
? html`<div class="prefix-icon">${this.prefixIcon}</div>`
|
||||
: nothing
|
||||
}
|
||||
<div class="popup-chip-label" title=${this.tooltip}>${this.label}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor prefixIcon: TemplateResult | undefined = undefined;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor label: string = '';
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor tooltip: string = '';
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import {
|
||||
getAttachmentFileIcon,
|
||||
LoadingIcon,
|
||||
WebIcon16,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import type { FootNote } from '@blocksuite/affine-model';
|
||||
import { ImageProxyService } from '@blocksuite/affine-shared/adapters';
|
||||
import {
|
||||
DocDisplayMetaProvider,
|
||||
LinkPreviewServiceIdentifier,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
|
||||
import { SignalWatcher, WithDisposable } from '@blocksuite/global/lit';
|
||||
import type { BlockStdScope } from '@blocksuite/std';
|
||||
import { computed, signal } from '@preact/signals-core';
|
||||
import { baseTheme } from '@toeverything/theme';
|
||||
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import type { FootNotePopupClickHandler } from './footnote-config';
|
||||
|
||||
export class FootNotePopup extends SignalWatcher(WithDisposable(LitElement)) {
|
||||
static override styles = css`
|
||||
.footnote-popup-container {
|
||||
border-radius: 8px;
|
||||
box-shadow: ${unsafeCSSVar('overlayPanelShadow')};
|
||||
background-color: ${unsafeCSSVarV2('layer/background/primary')};
|
||||
border: 0.5px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
|
||||
max-width: 260px;
|
||||
padding: 4px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
transition: 0.3s ease-in-out;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.footnote-popup-description {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
|
||||
font-size: var(--affine-font-xs);
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
private readonly _isLoading$ = signal(false);
|
||||
|
||||
private readonly _linkPreview$ = signal<{
|
||||
favicon: string | undefined;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}>({
|
||||
favicon: undefined,
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
});
|
||||
|
||||
private readonly _prefixIcon$ = computed(() => {
|
||||
const referenceType = this.footnote.reference.type;
|
||||
if (referenceType === 'doc') {
|
||||
const docId = this.footnote.reference.docId;
|
||||
if (!docId) {
|
||||
return undefined;
|
||||
}
|
||||
return this.std.get(DocDisplayMetaProvider).icon(docId).value;
|
||||
} else if (referenceType === 'attachment') {
|
||||
const fileType = this.footnote.reference.fileType;
|
||||
if (!fileType) {
|
||||
return undefined;
|
||||
}
|
||||
return getAttachmentFileIcon(fileType);
|
||||
} else if (referenceType === 'url') {
|
||||
if (this._isLoading$.value) {
|
||||
return LoadingIcon();
|
||||
}
|
||||
|
||||
const favicon = this._linkPreview$.value?.favicon;
|
||||
const imageSrc = favicon
|
||||
? this.imageProxyService.buildUrl(favicon)
|
||||
: undefined;
|
||||
return imageSrc ? html`<img src=${imageSrc} alt="favicon" />` : WebIcon16;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
private readonly _popupLabel$ = computed(() => {
|
||||
const referenceType = this.footnote.reference.type;
|
||||
let label = '';
|
||||
const { docId, fileName, url } = this.footnote.reference;
|
||||
switch (referenceType) {
|
||||
case 'doc':
|
||||
if (!docId) {
|
||||
return label;
|
||||
}
|
||||
label = this.std.get(DocDisplayMetaProvider).title(docId).value;
|
||||
break;
|
||||
case 'attachment':
|
||||
if (!fileName) {
|
||||
return label;
|
||||
}
|
||||
label = fileName;
|
||||
break;
|
||||
case 'url':
|
||||
if (!url) {
|
||||
return label;
|
||||
}
|
||||
label = this._linkPreview$.value?.title ?? url;
|
||||
break;
|
||||
}
|
||||
return label;
|
||||
});
|
||||
|
||||
private readonly _tooltip$ = computed(() => {
|
||||
const referenceType = this.footnote.reference.type;
|
||||
if (referenceType === 'url') {
|
||||
const title = this._linkPreview$.value?.title;
|
||||
const url = this.footnote.reference.url;
|
||||
return [title, url].filter(Boolean).join('\n') || '';
|
||||
}
|
||||
return this._popupLabel$.value;
|
||||
});
|
||||
|
||||
private readonly _onClick = () => {
|
||||
this.onPopupClick(this.footnote, this.abortController);
|
||||
this.abortController.abort();
|
||||
};
|
||||
|
||||
private readonly _initLinkPreviewData = () => {
|
||||
this._linkPreview$.value = {
|
||||
favicon: this.footnote.reference.favicon,
|
||||
title: this.footnote.reference.title,
|
||||
description: this.footnote.reference.description,
|
||||
};
|
||||
};
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this._initLinkPreviewData();
|
||||
|
||||
// If the reference is a url, and the url exists
|
||||
// and the link preview data is not already set, fetch the link preview data
|
||||
const isTitleAndDescriptionEmpty =
|
||||
!this._linkPreview$.value?.title &&
|
||||
!this._linkPreview$.value?.description;
|
||||
if (
|
||||
this.footnote.reference.type === 'url' &&
|
||||
this.footnote.reference.url &&
|
||||
isTitleAndDescriptionEmpty
|
||||
) {
|
||||
this._isLoading$.value = true;
|
||||
this.std
|
||||
.get(LinkPreviewServiceIdentifier)
|
||||
.query(this.footnote.reference.url)
|
||||
.then(data => {
|
||||
// update the local link preview data
|
||||
this._linkPreview$.value = {
|
||||
favicon: data.icon ?? undefined,
|
||||
title: data.title ?? undefined,
|
||||
description: data.description ?? undefined,
|
||||
};
|
||||
|
||||
// update the footnote attributes in the node with the link preview data
|
||||
// to avoid fetching the same data multiple times
|
||||
const footnote: FootNote = {
|
||||
...this.footnote,
|
||||
reference: {
|
||||
...this.footnote.reference,
|
||||
...(data.icon && { favicon: data.icon }),
|
||||
...(data.title && { title: data.title }),
|
||||
...(data.description && { description: data.description }),
|
||||
},
|
||||
};
|
||||
this.updateFootnoteAttributes(footnote);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => {
|
||||
this._isLoading$.value = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
const description = this._linkPreview$.value?.description;
|
||||
|
||||
return html`
|
||||
<div class="footnote-popup-container" @click=${this._onClick}>
|
||||
<footnote-popup-chip
|
||||
.prefixIcon=${this._prefixIcon$.value}
|
||||
.label=${this._popupLabel$.value}
|
||||
.tooltip=${this._tooltip$.value}
|
||||
></footnote-popup-chip>
|
||||
${
|
||||
description
|
||||
? html`
|
||||
<div class="footnote-popup-description">${description}</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
get imageProxyService() {
|
||||
return this.std.get(ImageProxyService);
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor footnote!: FootNote;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor std!: BlockStdScope;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor abortController!: AbortController;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor onPopupClick: FootNotePopupClickHandler | (() => void) = () => {};
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor updateFootnoteAttributes: (footnote: FootNote) => void = () => {};
|
||||
}
|
||||
4
blocksuite/affine/inlines/footnote/src/index.ts
Normal file
4
blocksuite/affine/inlines/footnote/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './adapters';
|
||||
export * from './footnote-node/footnote-config.js';
|
||||
export { AffineFootnoteNode } from './footnote-node/footnote-node.js';
|
||||
export * from './inline-spec';
|
||||
32
blocksuite/affine/inlines/footnote/src/inline-spec.ts
Normal file
32
blocksuite/affine/inlines/footnote/src/inline-spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { FootNoteSchema } from '@blocksuite/affine-model';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import { StdIdentifier } from '@blocksuite/std';
|
||||
import { InlineSpecExtension } from '@blocksuite/std/inline';
|
||||
import { html } from 'lit';
|
||||
import z from 'zod';
|
||||
|
||||
import { FootNoteNodeConfigIdentifier } from './footnote-node/footnote-config';
|
||||
|
||||
export const FootNoteInlineSpecExtension =
|
||||
InlineSpecExtension<AffineTextAttributes>('footnote', provider => {
|
||||
const std = provider.get(StdIdentifier);
|
||||
const config =
|
||||
provider.getOptional(FootNoteNodeConfigIdentifier) ?? undefined;
|
||||
return {
|
||||
name: 'footnote',
|
||||
schema: z.object({
|
||||
footnote: FootNoteSchema.optional().nullable().catch(undefined),
|
||||
}),
|
||||
match: delta => {
|
||||
return !!delta.attributes?.footnote;
|
||||
},
|
||||
renderer: ({ delta }) => {
|
||||
return html`<affine-footnote-node
|
||||
.delta=${delta}
|
||||
.std=${std}
|
||||
.config=${config}
|
||||
></affine-footnote-node>`;
|
||||
},
|
||||
embed: true,
|
||||
};
|
||||
});
|
||||
21
blocksuite/affine/inlines/footnote/src/store.ts
Normal file
21
blocksuite/affine/inlines/footnote/src/store.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
type StoreExtensionContext,
|
||||
StoreExtensionProvider,
|
||||
} from '@blocksuite/affine-ext-loader';
|
||||
|
||||
import {
|
||||
footnoteReferenceDeltaToMarkdownAdapterMatcher,
|
||||
FootnoteReferenceMarkdownPreprocessorExtension,
|
||||
markdownFootnoteReferenceToDeltaMatcher,
|
||||
} from './adapters';
|
||||
|
||||
export class FootnoteStoreExtension extends StoreExtensionProvider {
|
||||
override name = 'affine-footnote-inline';
|
||||
|
||||
override setup(context: StoreExtensionContext) {
|
||||
super.setup(context);
|
||||
context.register(markdownFootnoteReferenceToDeltaMatcher);
|
||||
context.register(footnoteReferenceDeltaToMarkdownAdapterMatcher);
|
||||
context.register(FootnoteReferenceMarkdownPreprocessorExtension);
|
||||
}
|
||||
}
|
||||
21
blocksuite/affine/inlines/footnote/src/view.ts
Normal file
21
blocksuite/affine/inlines/footnote/src/view.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
type ViewExtensionContext,
|
||||
ViewExtensionProvider,
|
||||
} from '@blocksuite/affine-ext-loader';
|
||||
|
||||
import { effects } from './effects';
|
||||
import { FootNoteInlineSpecExtension } from './inline-spec';
|
||||
|
||||
export class FootnoteViewExtension extends ViewExtensionProvider {
|
||||
override name = 'affine-footnote-inline';
|
||||
|
||||
override effect(): void {
|
||||
super.effect();
|
||||
effects();
|
||||
}
|
||||
|
||||
override setup(context: ViewExtensionContext) {
|
||||
super.setup(context);
|
||||
context.register(FootNoteInlineSpecExtension);
|
||||
}
|
||||
}
|
||||
18
blocksuite/affine/inlines/footnote/tsconfig.json
Normal file
18
blocksuite/affine/inlines/footnote/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{ "path": "../../components" },
|
||||
{ "path": "../../ext-loader" },
|
||||
{ "path": "../../model" },
|
||||
{ "path": "../../shared" },
|
||||
{ "path": "../../../framework/global" },
|
||||
{ "path": "../../../framework/std" },
|
||||
{ "path": "../../../framework/store" }
|
||||
]
|
||||
}
|
||||
25
blocksuite/affine/inlines/footnote/vitest.config.ts
Normal file
25
blocksuite/affine/inlines/footnote/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/footnote',
|
||||
},
|
||||
restoreMocks: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user