Merge branch 'main' into main

This commit is contained in:
github-actions[bot]
2026-04-29 15:25:20 +00:00
committed by GitHub
9 changed files with 448 additions and 62 deletions

View File

@@ -4,6 +4,7 @@ import { createArrowLigaturesExtension } from './arrowLigaturesExtension'
function createFixture() {
let beforeInputListener: ((event: InputEvent) => void) | null = null
const transaction = { insertText: vi.fn(() => transaction) }
const paragraphNode = { type: { name: 'paragraph', spec: {} } }
const view = {
dispatch: vi.fn(),
state: {
@@ -13,6 +14,10 @@ function createFixture() {
selection: {
from: 2,
to: 2,
$from: {
depth: 0,
node: vi.fn(() => paragraphNode),
},
},
tr: transaction,
},
@@ -119,6 +124,21 @@ describe('createArrowLigaturesExtension', () => {
expect(fixture.view.dispatch).not.toHaveBeenCalled()
})
it('does not replace arrows while typing inside code blocks', () => {
const fixture = createFixture()
fixture.mount()
fixture.view.state.doc.textBetween.mockReturnValue('-')
fixture.view.state.selection.$from.node.mockReturnValue({
type: { name: 'codeBlock', spec: { code: true } },
})
const event = fixture.fireInput()
expect(event.preventDefault).not.toHaveBeenCalled()
expect(fixture.transaction.insertText).not.toHaveBeenCalled()
expect(fixture.view.dispatch).not.toHaveBeenCalled()
})
it('ignores composing input so IME text is not rewritten', () => {
const fixture = createFixture()
fixture.mount()

View File

@@ -3,62 +3,104 @@ import { resolveArrowLigatureInput } from '../utils/arrowLigatures'
const PREFIX_CONTEXT_LENGTH = 2
interface CodeContextSelection {
$from?: {
depth: number
node: (depth?: number) => {
type?: {
name?: string
spec?: { code?: boolean }
}
}
}
}
function isInsertedCharacter(event: InputEvent): event is InputEvent & { data: string } {
return event.inputType === 'insertText' && typeof event.data === 'string'
}
function isCodeContext(selection: CodeContextSelection): boolean {
const position = selection.$from
if (!position) return false
for (let depth = position.depth; depth >= 0; depth--) {
const type = position.node(depth).type
if (type?.spec?.code || type?.name === 'codeBlock') return true
}
return false
}
function hasWritableCursor(selection: { from: number; to: number }): boolean {
return selection.from === selection.to
}
function isComposingInput({
event,
view,
}: {
event: InputEvent
view: { composing?: boolean }
}): boolean {
return event.isComposing || Boolean(view.composing)
}
export const createArrowLigaturesExtension = createExtension(({ editor }) => {
let literalAsciiCursor: number | null = null
const handleBeforeInput = (event: InputEvent) => {
if (!isInsertedCharacter(event)) {
return
}
const view = editor._tiptapEditor?.view ?? editor.prosemirrorView
if (!view) {
return
}
if (isComposingInput({ event, view })) {
return
}
const { from } = view.state.selection
if (!hasWritableCursor(view.state.selection)) {
return
}
if (isCodeContext(view.state.selection)) {
literalAsciiCursor = null
return
}
const beforeText = view.state.doc.textBetween(
Math.max(0, from - PREFIX_CONTEXT_LENGTH),
from,
'',
'',
)
const resolution = resolveArrowLigatureInput({
beforeText,
cursor: from,
inputText: event.data,
literalAsciiCursor,
})
literalAsciiCursor = resolution.nextLiteralAsciiCursor
if (!resolution.change) {
return
}
event.preventDefault()
view.dispatch(
view.state.tr.insertText(
resolution.change.insert,
resolution.change.from,
resolution.change.to,
),
)
}
return {
key: 'arrowLigatures',
mount: ({ dom, signal }) => {
const handleBeforeInput = (event: InputEvent) => {
if (!isInsertedCharacter(event)) {
return
}
const view = editor._tiptapEditor?.view ?? editor.prosemirrorView
if (!view) {
return
}
if (event.isComposing || view.composing) {
return
}
const { from, to } = view.state.selection
if (from !== to) {
return
}
const beforeText = view.state.doc.textBetween(
Math.max(0, from - PREFIX_CONTEXT_LENGTH),
from,
'',
'',
)
const resolution = resolveArrowLigatureInput({
beforeText,
cursor: from,
inputText: event.data,
literalAsciiCursor,
})
literalAsciiCursor = resolution.nextLiteralAsciiCursor
if (!resolution.change) {
return
}
event.preventDefault()
view.dispatch(
view.state.tr.insertText(
resolution.change.insert,
resolution.change.from,
resolution.change.to,
),
)
}
dom.addEventListener('beforeinput', handleBeforeInput as EventListener, {
capture: true,
signal,

View File

@@ -0,0 +1,25 @@
import { BlockNoteEditor } from '@blocknote/core'
import { describe, expect, it } from 'vitest'
import { MERMAID_BLOCK_TYPE } from '../utils/mermaidMarkdown'
import { schema } from './editorSchema'
describe('editor schema Mermaid parsing', () => {
it('parses fenced Mermaid Markdown as a rendered Mermaid block', async () => {
const editor = BlockNoteEditor.create({ schema })
const blocks = await editor.tryParseMarkdownToBlocks([
'```mermaid',
'graph TD',
'A --> B',
'```',
].join('\n'))
expect(blocks[0]).toMatchObject({
type: MERMAID_BLOCK_TYPE,
props: {
diagram: 'graph TD\nA --> B\n',
source: '```mermaid\ngraph TD\nA --> B\n```',
},
})
})
})

View File

@@ -5,7 +5,7 @@ import { createReactBlockSpec, createReactInlineContentSpec } from '@blocknote/r
import { resolveWikilinkColor as resolveColor } from '../utils/wikilinkColors'
import { resolveEntry } from '../utils/wikilink'
import { MATH_BLOCK_TYPE, MATH_INLINE_TYPE, renderMathToHtml } from '../utils/mathMarkdown'
import { MERMAID_BLOCK_TYPE } from '../utils/mermaidMarkdown'
import { MERMAID_BLOCK_TYPE, mermaidFenceSource } from '../utils/mermaidMarkdown'
import type { VaultEntry } from '../types'
import { NoteTitleIcon } from './NoteTitleIcon'
import { MermaidDiagram } from './MermaidDiagram'
@@ -106,6 +106,32 @@ const MathBlock = createReactBlockSpec(
},
)
function readCodeElementLanguage(code: Element): string | null {
const language = code.getAttribute('data-language')
?? Array.from(code.classList)
.find(className => className.startsWith('language-'))
?.replace(/^language-/u, '')
if (!language) return null
return language.trim().split(/\s+/u)[0]?.toLowerCase() ?? null
}
function readMermaidPreElement(element: HTMLElement): { source: string; diagram: string } | undefined {
if (element.tagName !== 'PRE') return undefined
if (element.childElementCount !== 1 || element.firstElementChild?.tagName !== 'CODE') return undefined
const code = element.firstElementChild
if (readCodeElementLanguage(code) !== 'mermaid') return undefined
const diagram = code.textContent?.endsWith('\n')
? code.textContent
: `${code.textContent ?? ''}\n`
return {
diagram,
source: mermaidFenceSource({ diagram }),
}
}
const MermaidBlock = createReactBlockSpec(
{
type: MERMAID_BLOCK_TYPE,
@@ -116,6 +142,8 @@ const MermaidBlock = createReactBlockSpec(
content: 'none',
},
{
runsBefore: ['codeBlock'],
parse: readMermaidPreElement,
render: (props) => (
<MermaidDiagram
diagram={props.block.props.diagram}
@@ -140,8 +168,8 @@ export const schema = BlockNoteSchema.create({
},
}).extend({
blockSpecs: {
codeBlock,
mathBlock,
mermaidBlock,
codeBlock,
},
})

View File

@@ -35,10 +35,10 @@ function typeSequence(view: EditorView, inputs: readonly string[]) {
})
}
function createView(container: HTMLDivElement) {
function createView(container: HTMLDivElement, content = '') {
const ref = { current: container }
const { result } = renderHook(() =>
useCodeMirror(ref, '', noopCallbacks),
useCodeMirror(ref, content, noopCallbacks),
)
return result.current.current!
}
@@ -90,4 +90,22 @@ describe('useCodeMirror arrow ligatures', () => {
})
expect(view.state.doc.toString()).toBe('→')
})
it('keeps Mermaid arrows literal while typing inside fenced code', () => {
const content = [
'```mermaid',
'flowchart TD',
'A --',
'```',
].join('\n')
const cursor = content.indexOf('A --') + 'A --'.length
const view = createView(container, content)
act(() => {
view.dispatch({ selection: { anchor: cursor } })
})
typeSequence(view, ['>'])
expect(view.state.doc.toString()).toContain('A -->')
})
})

View File

@@ -18,6 +18,11 @@ const RAW_EDITOR_COLORS = {
gutterBorder: 'var(--border-subtle)',
gutterText: 'var(--text-muted)',
}
interface MarkdownFence {
character: '`' | '~'
length: number
}
export interface CodeMirrorCallbacks {
onDocChange: (doc: string) => void
onCursorActivity: (view: EditorView) => void
@@ -25,6 +30,41 @@ export interface CodeMirrorCallbacks {
onEscape: () => boolean
}
function readMarkdownFence(line: string): MarkdownFence | null {
const match = /^( {0,3})(`{3,}|~{3,})/.exec(line)
if (!match) return null
const fence = match[2]
return {
character: fence[0] as MarkdownFence['character'],
length: fence.length,
}
}
function isClosingMarkdownFence(line: string, opening: MarkdownFence): boolean {
const match = /^( {0,3})(`{3,}|~{3,})[ \t]*$/.exec(line)
if (!match) return false
const fence = match[2]
return fence[0] === opening.character && fence.length >= opening.length
}
function isInsideMarkdownFence(markdownBeforeCursor: string): boolean {
const lines = markdownBeforeCursor.split(/\r?\n/)
let opening: MarkdownFence | null = null
for (const line of lines) {
if (opening) {
if (isClosingMarkdownFence(line, opening)) opening = null
continue
}
opening = readMarkdownFence(line)
}
return opening !== null
}
function buildBaseTheme() {
return EditorView.theme({
'&': {
@@ -83,6 +123,11 @@ function buildArrowLigaturesExtension() {
let literalAsciiCursor: number | null = null
return EditorView.inputHandler.of((view, from, _to, text) => {
if (isInsideMarkdownFence(view.state.doc.sliceString(0, from))) {
literalAsciiCursor = null
return false
}
const beforeText = view.state.doc.sliceString(Math.max(0, from - 2), from)
const resolution = resolveArrowLigatureInput({
beforeText,

View File

@@ -56,6 +56,57 @@ describe('mermaid markdown round-trip', () => {
].join('\n\n'))
})
it('injects parsed Mermaid code blocks into dedicated diagram blocks', () => {
const [block] = injectMermaidInBlocks([{
type: 'codeBlock',
props: { language: 'mermaid' },
content: [{ type: 'text', text: 'flowchart LR\n A --> B', styles: {} }],
children: [],
}]) as Array<{
type: string
props: { source: string; diagram: string }
}>
expect(block.type).toBe(MERMAID_BLOCK_TYPE)
expect(block.props.source).toBe('```mermaid\nflowchart LR\n A --> B\n```')
expect(block.props.diagram).toBe('flowchart LR\n A --> B\n')
})
it('injects Mermaid-looking text code blocks when the parser drops the language', () => {
const [block] = injectMermaidInBlocks([{
type: 'codeBlock',
props: { language: 'text' },
content: [{
type: 'text',
text: [
"%%{init: {'theme':'base'}}%%",
'flowchart TD',
' A --> B',
].join('\n'),
styles: {},
}],
children: [],
}]) as Array<{
type: string
props: { source: string; diagram: string }
}>
expect(block.type).toBe(MERMAID_BLOCK_TYPE)
expect(block.props.source).toContain('```mermaid\n')
expect(block.props.diagram).toContain('flowchart TD\n A --> B\n')
})
it('keeps ordinary text code blocks unchanged', () => {
const [block] = injectMermaidInBlocks([{
type: 'codeBlock',
props: { language: 'text' },
content: [{ type: 'text', text: 'const chart = "flowchart TD"', styles: {} }],
children: [],
}]) as Array<{ type: string }>
expect(block.type).toBe('codeBlock')
})
it('leaves non-Mermaid and unclosed fences as normal Markdown', () => {
const markdown = [
'```ts',

View File

@@ -63,6 +63,10 @@ interface DiagramSource {
diagram: string
}
interface CodeBlockSource {
block: BlockLike
}
function lineEnding({ line }: MarkdownLine): string {
if (line.endsWith('\r\n')) return '\r\n'
return line.endsWith('\n') ? '\n' : ''
@@ -185,10 +189,69 @@ function buildMermaidBlock({ block, payload }: { block: BlockLike; payload: Merm
}
}
export function mermaidFenceSource({ diagram }: DiagramSource): string {
const body = diagram.endsWith('\n') ? diagram : `${diagram}\n`
return `\`\`\`mermaid\n${body}\`\`\``
}
function readCodeBlockLanguage({ block }: CodeBlockSource): string | null {
const language = block.props?.language
if (typeof language !== 'string') return null
return language.trim().split(/\s+/)[0]?.toLowerCase() ?? null
}
function readInlineText(content: InlineItem[] | undefined): string | null {
if (!Array.isArray(content)) return null
return content.map((item) => (
item.type === 'text' && typeof item.text === 'string' ? item.text : ''
)).join('')
}
function looksLikeMermaidDiagram(diagram: string): boolean {
const firstStatement = diagram
.split(/\r?\n/)
.map(line => line.trim())
.find(line => line.length > 0 && !line.startsWith('%%'))
return typeof firstStatement === 'string'
&& /^(?:flowchart|graph|sequenceDiagram|classDiagram|stateDiagram(?:-v2)?|erDiagram|journey|gantt|pie|mindmap|timeline|quadrantChart|requirementDiagram|gitGraph|C4Context|C4Container|C4Component|C4Dynamic|sankey-beta|xychart-beta)\b/.test(firstStatement)
}
function shouldInjectCodeBlockAsMermaid({
diagram,
language,
}: {
diagram: string
language: string | null
}): boolean {
if (language === 'mermaid') return true
if (language !== null && language !== 'text' && language !== 'plain' && language !== 'plaintext') return false
return looksLikeMermaidDiagram(diagram)
}
function readMermaidCodeBlock({ block }: CodeBlockSource): MermaidPayload | null {
if (block.type !== 'codeBlock') return null
const diagram = readInlineText(block.content)
if (diagram === null) return null
if (!shouldInjectCodeBlockAsMermaid({ diagram, language: readCodeBlockLanguage({ block }) })) return null
const normalizedDiagram = diagram.endsWith('\n') ? diagram : `${diagram}\n`
return {
diagram: normalizedDiagram,
source: mermaidFenceSource({ diagram: normalizedDiagram }),
}
}
function injectMermaidInBlock(block: BlockLike): BlockLike {
const payload = readMermaidPayload(block.content)
if (payload) return buildMermaidBlock({ block, payload })
const codeBlockPayload = readMermaidCodeBlock({ block })
if (codeBlockPayload) return buildMermaidBlock({ block, payload: codeBlockPayload })
const children = Array.isArray(block.children) ? block.children.map(injectMermaidInBlock) : block.children
return { ...block, children }
}
@@ -199,16 +262,11 @@ function isMermaidBlock(block: BlockLike): boolean {
&& typeof block.props?.diagram === 'string'
}
function fallbackMermaidSource({ diagram }: DiagramSource): string {
const body = diagram.endsWith('\n') ? diagram : `${diagram}\n`
return `\`\`\`mermaid\n${body}\`\`\``
}
function mermaidMarkdown(block: BlockLike): string {
const source = block.props?.source
if (source) return source
return fallbackMermaidSource({ diagram: block.props?.diagram ?? '' })
return mermaidFenceSource({ diagram: block.props?.diagram ?? '' })
}
export function injectMermaidInBlocks(blocks: unknown[]): unknown[] {

View File

@@ -1,4 +1,4 @@
import { expect, test, type Page } from '@playwright/test'
import { expect, test, type Locator, type Page } from '@playwright/test'
import fs from 'fs'
import path from 'path'
import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault'
@@ -19,6 +19,21 @@ const SECOND_DIAGRAM = [
' Alice->>Bob: Hello',
'```',
].join('\n')
const REPORTED_THEME_DIAGRAM = [
'```mermaid',
"%%{init: {'theme':'base','themeVariables':{'primaryColor':'#dbe7ff','primaryTextColor':'#0b1220','primaryBorderColor':'#1f3a8a','lineColor':'#1f3a8a','secondaryColor':'#fff4d6','tertiaryColor':'#ffe0e0','fontSize':'14px'}}}%%",
'flowchart TD',
' A(["Employee clocks in"]) --> B{"Linked to a planned shift?"}',
'',
' B -- "Yes" --> C["Other path<br/>for scheduled shift"]',
' C --> D["Contract path<br/>for scheduled shift"]',
'',
' B -- "No (unscheduled)" --> E["other path<br/>for unscheduled clocking"]',
'',
' classDef path fill:#fff4d6,stroke:#7a5b00,stroke-width:2px,color:#3a2c00;',
' class C,D,E,F path;',
'```',
].join('\n')
const SYSTEM_OVERVIEW_DIAGRAM = [
'```mermaid',
'flowchart TD',
@@ -77,6 +92,15 @@ const INVALID_DIAGRAM = [
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(90_000)
tempVaultDir = createFixtureVaultCopy()
fs.writeFileSync(
path.join(tempVaultDir, 'note', 'mermaid-reported.md'),
[
'# Mermaid Reported',
'',
REPORTED_THEME_DIAGRAM,
'',
].join('\n'),
)
await openFixtureVault(page, tempVaultDir)
})
@@ -162,10 +186,72 @@ async function countLargeBlackFilledShapes(page: Page, diagramIndex: number): Pr
})
}
function mermaidSvg(page: Page, diagramIndex: number): Locator {
return page.locator('[data-testid="mermaid-diagram-viewport"] svg').nth(diagramIndex)
}
function mermaidNode(page: Page, diagramIndex: number, text: string): Locator {
return mermaidSvg(page, diagramIndex).locator('.node').filter({ hasText: text }).first()
}
async function computedCss(locator: Locator, property: 'color' | 'fill' | 'stroke'): Promise<string> {
return locator.evaluate((element, styleProperty) => (
getComputedStyle(element).getPropertyValue(styleProperty)
), property)
}
async function labelFitsNode(node: Locator): Promise<boolean> {
return node.evaluate((element) => {
const shape = element.querySelector<SVGGraphicsElement>('rect, polygon, circle, ellipse, path')!
const label = element.querySelector<HTMLElement>('.nodeLabel')!
const shapeBox = shape.getBoundingClientRect()
const labelBox = label.getBoundingClientRect()
return [
labelBox.width <= shapeBox.width + 2,
labelBox.height <= shapeBox.height + 2,
].every(Boolean)
})
}
async function readReportedDiagramMetrics(page: Page, diagramIndex: number) {
const svg = mermaidSvg(page, diagramIndex)
const scheduledNode = mermaidNode(page, diagramIndex, 'Other path')
const scheduledShape = scheduledNode.locator('rect, polygon, circle, ellipse, path').first()
const scheduledLabel = scheduledNode.locator('.nodeLabel').first()
return {
connectorStroke: await computedCss(svg.locator('.flowchart-link').first(), 'stroke'),
markerFill: await computedCss(svg.locator('marker path').first(), 'fill'),
scheduledFill: await computedCss(scheduledShape, 'fill'),
scheduledStroke: await computedCss(scheduledShape, 'stroke'),
scheduledTextColor: await computedCss(scheduledLabel, 'color'),
labelsFit: [
await labelFitsNode(scheduledNode),
await labelFitsNode(mermaidNode(page, diagramIndex, 'Contract path')),
await labelFitsNode(mermaidNode(page, diagramIndex, 'unscheduled clocking')),
].every(Boolean),
text: await svg.evaluate((element) => element.textContent ?? ''),
}
}
function readNoteBFile(): string {
return fs.readFileSync(path.join(tempVaultDir, 'note', 'note-b.md'), 'utf8')
}
test('Mermaid diagrams render when opening saved notes directly', async ({ page }) => {
await openNote(page, 'Mermaid Reported')
await expectRenderedDiagramCount(page, 1)
await expect.poll(() => readReportedDiagramMetrics(page, 0)).toMatchObject({
connectorStroke: 'rgb(31, 58, 138)',
markerFill: 'rgb(31, 58, 138)',
scheduledFill: 'rgb(255, 244, 214)',
scheduledStroke: 'rgb(122, 91, 0)',
scheduledTextColor: 'rgb(58, 44, 0)',
labelsFit: true,
text: expect.stringContaining('Linked to a planned shift?'),
})
})
test('Mermaid diagrams render, fall back, and round-trip through raw mode', async ({ page }) => {
await openNote(page, 'Note B')
await toggleRawMode(page, '.cm-content')
@@ -175,6 +261,8 @@ test('Mermaid diagrams render, fall back, and round-trip through raw mode', asyn
${FIRST_DIAGRAM}
${REPORTED_THEME_DIAGRAM}
${INVALID_DIAGRAM}
${SECOND_DIAGRAM}
@@ -186,19 +274,29 @@ ${SYSTEM_OVERVIEW_DIAGRAM}
await expect.poll(readNoteBFile).toContain(FIRST_DIAGRAM)
await toggleRawMode(page, '.bn-editor')
await expectRenderedDiagramCount(page, 3)
await expect.poll(() => countLargeBlackFilledShapes(page, 2)).toBe(0)
await expectRenderedDiagramCount(page, 4)
await expect.poll(() => countLargeBlackFilledShapes(page, 3)).toBe(0)
await expect.poll(() => readReportedDiagramMetrics(page, 1)).toMatchObject({
connectorStroke: 'rgb(31, 58, 138)',
markerFill: 'rgb(31, 58, 138)',
scheduledFill: 'rgb(255, 244, 214)',
scheduledStroke: 'rgb(122, 91, 0)',
scheduledTextColor: 'rgb(58, 44, 0)',
labelsFit: true,
text: expect.stringContaining('Linked to a planned shift?'),
})
await expect(page.locator('[data-testid="mermaid-diagram-error"]')).toHaveCount(1)
await expect(page.locator('[data-testid="mermaid-diagram-error"]')).toContainText('not a diagram')
await page.locator('[data-testid="mermaid-diagram"]').nth(2).hover()
await page.getByRole('button', { name: 'Open Mermaid diagram' }).nth(2).click()
await page.locator('[data-testid="mermaid-diagram"]').nth(3).hover()
await page.getByRole('button', { name: 'Open Mermaid diagram' }).nth(3).click()
await expect(page.locator('[data-testid="mermaid-diagram-dialog-viewport"] svg')).toBeVisible()
await page.keyboard.press('Escape')
await toggleRawMode(page, '.cm-content')
const rawAfterRichMode = await getRawEditorContent(page)
expect(rawAfterRichMode).toContain(FIRST_DIAGRAM)
expect(rawAfterRichMode).toContain(REPORTED_THEME_DIAGRAM)
expect(rawAfterRichMode).toContain(INVALID_DIAGRAM)
expect(rawAfterRichMode).toContain(SECOND_DIAGRAM)
expect(rawAfterRichMode).toContain(SYSTEM_OVERVIEW_DIAGRAM)
@@ -207,7 +305,7 @@ ${SYSTEM_OVERVIEW_DIAGRAM}
await expect.poll(readNoteBFile).toContain(UPDATED_FIRST_DIAGRAM)
await toggleRawMode(page, '.bn-editor')
await expectRenderedDiagramCount(page, 3)
await expectRenderedDiagramCount(page, 4)
await expect(page.locator('[data-testid="mermaid-diagram-viewport"]').first()).toContainText('Published')
await openNote(page, 'Note C')
@@ -216,6 +314,7 @@ ${SYSTEM_OVERVIEW_DIAGRAM}
const reopenedRaw = await getRawEditorContent(page)
expect(reopenedRaw).toContain(UPDATED_FIRST_DIAGRAM)
expect(reopenedRaw).toContain(REPORTED_THEME_DIAGRAM)
expect(reopenedRaw).toContain(INVALID_DIAGRAM)
expect(reopenedRaw).toContain(SECOND_DIAGRAM)
expect(reopenedRaw).toContain(SYSTEM_OVERVIEW_DIAGRAM)