From edf46c67334ed8bd5adbb0be719ab0dd51ffa266 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Thu, 16 Jul 2026 22:30:25 +0800 Subject: [PATCH 5/7] Add portable callout page blocks --- src/components/Editor.css | 62 +++++++++++ src/components/editorSchema.tsx | 46 ++++++++ .../tolariaEditorFormatting.test.tsx | 58 ++++++++++ .../tolariaEditorFormattingConfig.ts | 101 +++++++++++++++++- src/utils/blockNoteDirectMarkdown.test.ts | 12 +++ src/utils/blockNoteDirectMarkdown.ts | 12 +++ src/utils/calloutMarkdown.ts | 70 ++++++++++++ src/utils/editorDurableMarkdown.test.ts | 17 +++ src/utils/editorDurableMarkdown.ts | 3 +- 9 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 src/utils/calloutMarkdown.ts diff --git a/src/components/Editor.css b/src/components/Editor.css index 24f0738..5db3fe1 100644 --- a/src/components/Editor.css +++ b/src/components/Editor.css @@ -80,6 +80,68 @@ line-height: 1.65; } +.tolaria-callout { + --tolaria-callout-accent: #2563eb; + --tolaria-callout-bg: color-mix(in srgb, #2563eb 9%, var(--bg-primary)); + display: grid; + grid-template-columns: 4px minmax(0, 1fr); + gap: 12px; + width: 100%; + margin: 0.45rem 0; + padding: 11px 13px 11px 0; + border: 1px solid color-mix(in srgb, var(--tolaria-callout-accent) 28%, var(--border)); + border-radius: 8px; + background: var(--tolaria-callout-bg); +} + +.tolaria-callout[data-kind="success"] { + --tolaria-callout-accent: #168a48; + --tolaria-callout-bg: color-mix(in srgb, #168a48 10%, var(--bg-primary)); +} + +.tolaria-callout[data-kind="warning"] { + --tolaria-callout-accent: #b7791f; + --tolaria-callout-bg: color-mix(in srgb, #b7791f 13%, var(--bg-primary)); +} + +.tolaria-callout[data-kind="danger"] { + --tolaria-callout-accent: #dc2626; + --tolaria-callout-bg: color-mix(in srgb, #dc2626 10%, var(--bg-primary)); +} + +.tolaria-callout__marker { + width: 4px; + min-height: 100%; + border-radius: 999px; + background: var(--tolaria-callout-accent); +} + +.tolaria-callout__body { + display: grid; + gap: 4px; + min-width: 0; +} + +.tolaria-callout__label { + color: var(--tolaria-callout-accent); + font-size: 11px; + font-weight: 700; + line-height: 1.2; + text-transform: uppercase; +} + +.tolaria-callout__content { + min-width: 0; + color: var(--text-primary); + font-size: 14px; + line-height: 1.6; +} + +.tolaria-callout__content:empty::before { + color: var(--text-muted); + content: 'Write a note'; +} + /* Breadcrumb bar: border can still react to the data attribute, but the breadcrumb filename/title stays visible at all times. */ .breadcrumb-bar { diff --git a/src/components/editorSchema.tsx b/src/components/editorSchema.tsx index 2984eff..7d62518 100644 --- a/src/components/editorSchema.tsx +++ b/src/components/editorSchema.tsx @@ -25,6 +25,11 @@ import { MERMAID_BLOCK_TYPE, mermaidFenceSource } from '../utils/mermaidMarkdown import { TLDRAW_BLOCK_TYPE, TLDRAW_DEFAULT_HEIGHT } from '../utils/tldrawMarkdown' import { MARKDOWN_HIGHLIGHT_STYLE } from '../utils/markdownHighlightMarkdown' import { GUANGHU_PORTAL_BLOCK_TYPE } from '../utils/guanghuPortalMarkdown' +import { + calloutKind, + TOLARIA_CALLOUT_BLOCK_TYPE, + type TolariaCalloutKind, +} from '../utils/calloutMarkdown' import type { VaultEntry } from '../types' import { createTolariaCodeBlockOptions } from './codeBlockOptions' import { NoteTitleIcon } from './NoteTitleIcon' @@ -350,6 +355,45 @@ const GuanghuPortalBlock = createReactBlockSpec( }, ) +const CALLOUT_LABELS: Record = { + danger: 'Danger', + info: 'Info', + success: 'Success', + warning: 'Warning', +} + +function TolariaCalloutRenderer({ + block, + contentRef, +}: { + block: { props: { kind?: string } } + contentRef: (node: HTMLElement | null) => void +}) { + const kind = calloutKind(block.props.kind) + return ( + + ) +} + +const TolariaCalloutBlock = createReactBlockSpec( + { + type: TOLARIA_CALLOUT_BLOCK_TYPE, + propSchema: { + kind: { default: 'info' }, + }, + content: 'inline', + }, + { + render: TolariaCalloutRenderer, + }, +) + export function mediaBlockPropsForPreviewRuntime( props: T, externalMediaPreview: boolean, @@ -450,6 +494,7 @@ const codeBlock = createCodeBlockSpec(createTolariaCodeBlockOptions()) const audioBlock = AudioBlockSpec() const mathBlock = MathBlock() const guanghuPortalBlock = GuanghuPortalBlock() +const tolariaCalloutBlock = TolariaCalloutBlock() const mermaidBlock = MermaidBlock() const tldrawBlock = TldrawBlock() const videoBlock = VideoBlockSpec() @@ -485,6 +530,7 @@ export const schema = BlockNoteSchema.create({ blockSpecs: { audio: audioBlock, guanghuPortalBlock, + [TOLARIA_CALLOUT_BLOCK_TYPE]: tolariaCalloutBlock, mathBlock, mermaidBlock, tldrawBlock, diff --git a/src/components/tolariaEditorFormatting.test.tsx b/src/components/tolariaEditorFormatting.test.tsx index 5c5143b..7bd06c2 100644 --- a/src/components/tolariaEditorFormatting.test.tsx +++ b/src/components/tolariaEditorFormatting.test.tsx @@ -7,7 +7,9 @@ vi.mock('../lib/telemetry', () => ({ })) import { + addItemsAfterKey, addItemsToMediaGroup, + createCalloutSlashMenuItems, createMathSlashMenuItem, filterTolariaFormattingToolbarItems, filterTolariaSlashMenuItems, @@ -18,6 +20,7 @@ import { import { trackEvent } from '../lib/telemetry' import { MATH_BLOCK_TYPE } from '../utils/mathMarkdown' import { mermaidFenceSource } from '../utils/mermaidMarkdown' +import { TOLARIA_CALLOUT_BLOCK_TYPE } from '../utils/calloutMarkdown' describe('tolariaEditorFormatting', () => { it('keeps the markdown-safe toolbar controls and block type select', () => { @@ -73,6 +76,7 @@ describe('tolariaEditorFormatting', () => { { key: 'heading_4', title: 'Heading 4', onItemClick: () => {} }, { key: 'bullet_list', title: 'Bullet List', subtext: 'Default list copy', onItemClick: () => {} }, { key: 'code_block', title: 'Code Block', subtext: 'Default code copy', onItemClick: () => {} }, + { key: 'tolaria_callout_info', title: 'Info callout', onItemClick: () => {} }, { key: 'heading_5', title: 'Heading 5', onItemClick: () => {} }, { key: 'heading_6', title: 'Heading 6', onItemClick: () => {} }, ] satisfies TolariaSlashMenuTestItem[]) @@ -82,12 +86,14 @@ describe('tolariaEditorFormatting', () => { 'heading_4', 'bullet_list', 'code_block', + 'tolaria_callout_info', ]) expect(items.map((item) => item.subtext)).toEqual([ undefined, undefined, undefined, undefined, + undefined, ]) }) @@ -224,6 +230,58 @@ describe('tolariaEditorFormatting', () => { ]) }) + it('places callout commands after quote blocks', () => { + type TolariaSlashMenuTestItem = { + key: string + title: string + group: string + onItemClick: () => void + } + + const items = addItemsAfterKey([ + { key: 'paragraph', title: 'Paragraph', group: 'Basic blocks', onItemClick: () => {} }, + { key: 'quote', title: 'Quote', group: 'Basic blocks', onItemClick: () => {} }, + { key: 'code_block', title: 'Code Block', group: 'Basic blocks', onItemClick: () => {} }, + ] satisfies TolariaSlashMenuTestItem[], 'quote', [ + { key: 'tolaria_callout_info', title: 'Info callout', group: 'Basic blocks', onItemClick: () => {} }, + ]) + + expect(items.map(item => item.key)).toEqual([ + 'paragraph', + 'quote', + 'tolaria_callout_info', + 'code_block', + ]) + }) + + it('keeps callout slash commands searchable and inserts an editable callout block', async () => { + const block = { id: 'active-block' } + const editor = { + getTextCursorPosition: () => ({ block }), + replaceBlocks: () => {}, + } + const replaceBlocks = vi.spyOn(editor, 'replaceBlocks') + const items = createCalloutSlashMenuItems(editor as never) + const warningItem = items.find(item => item.key === 'tolaria_callout_warning') + + expect(warningItem).toEqual(expect.objectContaining({ + title: 'Warning callout', + aliases: expect.arrayContaining(['warning', '警告']), + })) + + warningItem?.onItemClick() + + expect(replaceBlocks).toHaveBeenCalledWith([block], [{ + type: TOLARIA_CALLOUT_BLOCK_TYPE, + props: { kind: 'warning' }, + content: [{ + type: 'text', + text: 'Warning callout', + styles: {}, + }], + }]) + }) + it('creates a math slash command with a default display equation', () => { const block = { id: 'active-block' } const editor = { diff --git a/src/components/tolariaEditorFormattingConfig.ts b/src/components/tolariaEditorFormattingConfig.ts index b55dede..614cbf5 100644 --- a/src/components/tolariaEditorFormattingConfig.ts +++ b/src/components/tolariaEditorFormattingConfig.ts @@ -6,9 +6,11 @@ import { import { createElement, type ReactElement } from 'react' import { CodeBlock, + CheckCircle, File, FlowArrow, ImageSquare, + Info, ListBullets, ListChecks, ListNumbers, @@ -26,6 +28,8 @@ import { TextHFour, TextHFive, TextHSix, + Warning, + XCircle, Video, type Icon as PhosphorIcon, } from '@phosphor-icons/react' @@ -38,6 +42,10 @@ import { import { MATH_BLOCK_TYPE } from '../utils/mathMarkdown' import { MERMAID_BLOCK_TYPE, mermaidFenceSource } from '../utils/mermaidMarkdown' import { TLDRAW_BLOCK_TYPE, TLDRAW_DEFAULT_HEIGHT } from '../utils/tldrawMarkdown' +import { + TOLARIA_CALLOUT_BLOCK_TYPE, + type TolariaCalloutKind, +} from '../utils/calloutMarkdown' type TolariaSlashMenuItem = DefaultReactSuggestionItem & { key: string } type TolariaBlockTypeSelectItem = RichEditorBlockTypeDefinition & { @@ -55,6 +63,12 @@ type BlockSlashMenuItemConfig = { title: string type: string } +type CalloutSlashMenuItemConfig = { + aliases: string[] + key: string + kind: TolariaCalloutKind + title: string +} type TolariaSlashMenuLabels = { mathTitle: string } @@ -115,6 +129,10 @@ const TOLARIA_SLASH_MENU_ICONS: Partial> = { numbered_list: ListNumbers, paragraph: Paragraph, quote: Quotes, + tolaria_callout_danger: XCircle, + tolaria_callout_info: Info, + tolaria_callout_success: CheckCircle, + tolaria_callout_warning: Warning, table: Table, toggle_heading: TextHOne, toggle_heading_2: TextHTwo, @@ -164,6 +182,64 @@ function createMermaidSlashMenuItem( }) } +function createCalloutSlashMenuItem( + editor: Parameters[0], + config: CalloutSlashMenuItemConfig, +): TolariaSlashMenuItem { + const blockEditor = editor as unknown as SlashInsertEditor + + return { + key: config.key, + title: config.title, + aliases: config.aliases, + group: 'Basic blocks', + onItemClick: () => { + const block = blockEditor.getTextCursorPosition().block + blockEditor.replaceBlocks([block], [{ + type: TOLARIA_CALLOUT_BLOCK_TYPE, + props: { kind: config.kind }, + content: [{ + type: 'text', + text: config.title, + styles: {}, + }], + }]) + trackEvent('editor_callout_slash_command_used', { kind: config.kind }) + }, + } as TolariaSlashMenuItem +} + +export function createCalloutSlashMenuItems( + editor: Parameters[0], +): TolariaSlashMenuItem[] { + return [ + createCalloutSlashMenuItem(editor, { + key: 'tolaria_callout_info', + title: 'Info callout', + aliases: ['callout', 'note', 'info', 'tip', '提示', '高亮'], + kind: 'info', + }), + createCalloutSlashMenuItem(editor, { + key: 'tolaria_callout_success', + title: 'Success callout', + aliases: ['callout', 'success', 'done', 'ok', '完成', '成功'], + kind: 'success', + }), + createCalloutSlashMenuItem(editor, { + key: 'tolaria_callout_warning', + title: 'Warning callout', + aliases: ['callout', 'warning', 'alert', 'todo', '警告', '注意'], + kind: 'warning', + }), + createCalloutSlashMenuItem(editor, { + key: 'tolaria_callout_danger', + title: 'Danger callout', + aliases: ['callout', 'danger', 'risk', 'error', '风险', '危险'], + kind: 'danger', + }), + ] +} + export function createMathSlashMenuItem( editor: Parameters[0], labels: TolariaSlashMenuLabels = { mathTitle: 'Math' }, @@ -218,6 +294,23 @@ export function addItemsToMediaGroup( return nextItems } +export function addItemsAfterKey( + items: TolariaSlashMenuItem[], + insertAfterKey: string, + insertedItems: TolariaSlashMenuItem[], +): TolariaSlashMenuItem[] { + const nextItems = [...items] + const insertIndex = nextItems.findIndex((item) => item.key === insertAfterKey) + + if (insertIndex === -1) { + nextItems.unshift(...insertedItems) + return nextItems + } + + nextItems.splice(insertIndex + 1, 0, ...insertedItems) + return nextItems +} + function createTolariaSlashMenuIcon(Icon: PhosphorIcon) { return createElement( 'span', @@ -273,8 +366,14 @@ export function getTolariaSlashMenuItems( query: string, labels?: TolariaSlashMenuLabels, ) { + const defaultItems = getDefaultReactSlashMenuItems(editor) as TolariaSlashMenuItem[] + const itemsWithCallouts = addItemsAfterKey( + defaultItems, + 'quote', + createCalloutSlashMenuItems(editor), + ) const items = addItemsToMediaGroup( - getDefaultReactSlashMenuItems(editor) as TolariaSlashMenuItem[], + itemsWithCallouts, [ createMermaidSlashMenuItem(editor), createMathSlashMenuItem(editor, labels), diff --git a/src/utils/blockNoteDirectMarkdown.test.ts b/src/utils/blockNoteDirectMarkdown.test.ts index 524b29c..cabba60 100644 --- a/src/utils/blockNoteDirectMarkdown.test.ts +++ b/src/utils/blockNoteDirectMarkdown.test.ts @@ -5,6 +5,7 @@ import { serializeBlockNoteMarkdown, type DirectMarkdownCapableSerializer, } from './blockNoteDirectMarkdown' +import { TOLARIA_CALLOUT_BLOCK_TYPE } from './calloutMarkdown' import { serializeRichEditorBodyToMarkdown } from './richEditorMarkdown' function makeEditor(document: unknown[]): DirectMarkdownCapableSerializer & { document: unknown[] } { @@ -117,6 +118,17 @@ describe('BlockNote direct Markdown serialization', () => { ].join('\n')) }) + it('serializes Tolaria callout blocks as portable Markdown callouts', () => { + expect(blocksToMarkdownDirect([ + { + type: TOLARIA_CALLOUT_BLOCK_TYPE, + props: { kind: 'success' }, + content: [{ type: 'text', text: 'Package is ready', styles: {} }], + children: [], + }, + ]).markdown).toBe('> [!success] Package is ready') + }) + it('caches unchanged block objects across rich-editor body serialization', () => { const block = { type: 'paragraph', diff --git a/src/utils/blockNoteDirectMarkdown.ts b/src/utils/blockNoteDirectMarkdown.ts index c075831..2ac99a0 100644 --- a/src/utils/blockNoteDirectMarkdown.ts +++ b/src/utils/blockNoteDirectMarkdown.ts @@ -1,3 +1,5 @@ +import { calloutKind, TOLARIA_CALLOUT_BLOCK_TYPE } from './calloutMarkdown' + interface TextStyles { [style: string]: string | boolean | undefined } @@ -228,6 +230,15 @@ function quoteMarkdown(block: BlockLike): string { return text.split('\n').map(line => `> ${line}`).join('\n') } +function calloutMarkdown(block: BlockLike): string { + const kind = calloutKind(block.props?.kind) + const text = serializeInlineContent(contentArray(block.content)) || 'Write a note' + return `[!${kind}] ${text}` + .split('\n') + .map(line => `> ${line}`) + .join('\n') +} + function tableCellMarkdown(cell: TableCellValue): string { const text = typeof cell === 'string' ? cell @@ -273,6 +284,7 @@ const BLOCK_MARKDOWN_HANDLERS: Record = { heading: headingMarkdown, quote: quoteMarkdown, table: tableMarkdown, + [TOLARIA_CALLOUT_BLOCK_TYPE]: calloutMarkdown, } function blockMarkdownWithoutChildren(block: BlockLike, context: SerializeContext): string | null { diff --git a/src/utils/calloutMarkdown.ts b/src/utils/calloutMarkdown.ts new file mode 100644 index 0000000..b6881b6 --- /dev/null +++ b/src/utils/calloutMarkdown.ts @@ -0,0 +1,70 @@ +import type { BlockLike } from './durableMarkdownBlocks' + +export const TOLARIA_CALLOUT_BLOCK_TYPE = 'tolariaCallout' + +export const TOLARIA_CALLOUT_KINDS = ['info', 'success', 'warning', 'danger'] as const + +export type TolariaCalloutKind = typeof TOLARIA_CALLOUT_KINDS[number] + +const CALLOUT_MARKER_RE = /^\[!(info|success|warning|danger)\]\s*(.*)$/iu + +function isCalloutKind(value: unknown): value is TolariaCalloutKind { + return typeof value === 'string' && TOLARIA_CALLOUT_KINDS.includes(value as TolariaCalloutKind) +} + +function readTextOnlyContent(content: BlockLike['content']): string | null { + if (!Array.isArray(content) || content.length === 0) return null + + let text = '' + for (const item of content) { + if (item.type !== 'text' || typeof item.text !== 'string') return null + text += item.text + } + return text +} + +function calloutMatchFromQuote(block: BlockLike) { + if (block.type !== 'quote') return null + + const text = readTextOnlyContent(block.content) + if (text === null) return null + + const match = CALLOUT_MARKER_RE.exec(text) + if (!match) return null + + const kind = match[1]?.toLowerCase() + if (!isCalloutKind(kind)) return null + + return { + kind, + text: match[2] ?? '', + } +} + +export function injectTolariaCalloutBlocks(blocks: unknown[]): unknown[] { + return (blocks as BlockLike[]).map((block) => { + const match = calloutMatchFromQuote(block) + const children = Array.isArray(block.children) + ? injectTolariaCalloutBlocks(block.children) as BlockLike[] + : block.children + + if (!match) return { ...block, children } + + return { + ...block, + type: TOLARIA_CALLOUT_BLOCK_TYPE, + props: { + ...(block.props ?? {}), + kind: match.kind, + }, + content: match.text + ? [{ type: 'text', text: match.text, styles: {} }] + : [], + children, + } + }) +} + +export function calloutKind(value: unknown): TolariaCalloutKind { + return isCalloutKind(value) ? value : 'info' +} diff --git a/src/utils/editorDurableMarkdown.test.ts b/src/utils/editorDurableMarkdown.test.ts index f5a230d..4e6679e 100644 --- a/src/utils/editorDurableMarkdown.test.ts +++ b/src/utils/editorDurableMarkdown.test.ts @@ -6,6 +6,7 @@ import { preProcessDurableEditorMarkdown, serializeDurableEditorBlocks, } from './editorDurableMarkdown' +import { TOLARIA_CALLOUT_BLOCK_TYPE } from './calloutMarkdown' import { MERMAID_BLOCK_TYPE } from './mermaidMarkdown' import { TLDRAW_BLOCK_TYPE } from './tldrawMarkdown' @@ -70,4 +71,20 @@ describe('editor durable markdown blocks', () => { }, }) }) + + it('restores Markdown callouts as Tolaria page blocks', () => { + const blocks = injectDurableEditorMarkdownBlocks([ + { + type: 'quote', + content: [{ type: 'text', text: '[!warning] Check signing before sharing.', styles: {} }], + children: [], + }, + ]) as Array<{ type: string; props?: Record; content?: Array<{ text?: string }> }> + + expect(blocks[0]).toMatchObject({ + type: TOLARIA_CALLOUT_BLOCK_TYPE, + props: { kind: 'warning' }, + content: [{ text: 'Check signing before sharing.' }], + }) + }) }) diff --git a/src/utils/editorDurableMarkdown.ts b/src/utils/editorDurableMarkdown.ts index 6e6d30a..baa5670 100644 --- a/src/utils/editorDurableMarkdown.ts +++ b/src/utils/editorDurableMarkdown.ts @@ -16,6 +16,7 @@ import { serializeMathAwareBlocks } from './mathMarkdown' import { mermaidMarkdownCodec } from './mermaidMarkdown' import { tldrawMarkdownCodec } from './tldrawMarkdown' import { guanghuPortalMarkdownCodec } from './guanghuPortalMarkdown' +import { injectTolariaCalloutBlocks } from './calloutMarkdown' const EDITOR_DURABLE_MARKDOWN_CODECS = [ guanghuPortalMarkdownCodec, @@ -36,7 +37,7 @@ export function injectDurableEditorMarkdownBlocks(blocks: unknown[]): unknown[] blocks, codecs: EDITOR_DURABLE_MARKDOWN_CODECS, }) - return injectFileAttachmentBlocks(withDurableBlocks) + return injectTolariaCalloutBlocks(injectFileAttachmentBlocks(withDurableBlocks)) } export function serializeDurableEditorBlocks( -- 2.50.1 (Apple Git-155)