Merge branch 'main' into main
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
25
src/components/editorSchema.mermaid.test.ts
Normal file
25
src/components/editorSchema.mermaid.test.ts
Normal 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```',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 -->')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user