Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions apps/desktop/src/main/sync/blocknote-converter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3384,3 +3384,93 @@ describe('a link mention through the main serializer', () => {
expect(result).toBe(markdown)
})
})

describe('text alignment survives the markdown round trip (#1937)', () => {
const blocksToMd = async (blocks: NonNullable<Awaited<ReturnType<typeof markdownToBlocks>>>) => {
const doc = new Y.Doc()
const fragment = doc.getXmlFragment(CRDT_FRAGMENT_NAME)
blocksToYFragment(blocks, fragment)
return yDocToMarkdown(doc)
}

const crdtRoundTrip = async (md: string): Promise<string | null> => {
const doc = new Y.Doc()
await markdownToYFragment(md, doc.getXmlFragment(CRDT_FRAGMENT_NAME))
return yDocToMarkdown(doc)
}

it.each(['center', 'right', 'justify'])(
'reads and writes a %s-aligned paragraph',
async (alignment) => {
const md = `<!-- align:${alignment} -->\nCentred`

const blocks = await markdownToBlocks(md)

expect(blocks![0]).toMatchObject({ type: 'paragraph', props: { textAlignment: alignment } })
expect(await blocksToMd(blocks!)).toBe(md)
}
)

it('reads and writes a right-aligned heading', async () => {
const md = '<!-- align:right -->\n## Title'

const blocks = await markdownToBlocks(md)

expect(blocks![0]).toMatchObject({
type: 'heading',
props: { level: 2, textAlignment: 'right' }
})
expect(await blocksToMd(blocks!)).toBe(md)
})

it('reads and writes a centred callout', async () => {
const md = '<!-- align:center -->\n> [!info]\n> Heads up'

const blocks = await markdownToBlocks(md)

expect(blocks![0]).toMatchObject({
type: 'callout',
props: { type: 'info', textAlignment: 'center' }
})
expect(await blocksToMd(blocks!)).toBe(md)
})

it('writes no marker for the default alignment', async () => {
const blocks = await markdownToBlocks('Centred')

expect(blocks![0]).toMatchObject({ props: { textAlignment: 'left' } })
expect(await blocksToMd(blocks!)).toBe('Centred')
})

it('writes no marker for a block whose props say left', async () => {
const blocks = [
{
type: 'paragraph',
props: { textAlignment: 'left' },
content: [{ type: 'text', text: 'Plain', styles: {} }],
children: []
}
] as unknown as NonNullable<Awaited<ReturnType<typeof markdownToBlocks>>>

expect(await blocksToMd(blocks)).toBe('Plain')
})

// ProseMirror's computeAttrs drops any attribute the schema does not declare,
// so this is the proof that `textAlignment` (a BlockNote defaultProp) needs no
// editor-schema change to survive the CRDT hop.
it('keeps the marker across the CRDT path', async () => {
const md = '<!-- align:center -->\nCentred'

const once = await crdtRoundTrip(md)

expect(once).toBe(md)
expect(await crdtRoundTrip(once!)).toBe(md)
})

it.each(['<!-- align:left -->\nText', '<!-- todo -->\nText'])(
'leaves %j on the unrecognised-comment path, which drops it',
async (md) => {
expect(await crdtRoundTrip(md)).toBe('Text')
}
)
})
119 changes: 41 additions & 78 deletions apps/desktop/src/main/sync/blocknote-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,16 @@ import {
} from '@memry/shared/task-block'
import {
type BlockColors,
type TableCellColors,
applyTableCellColors,
extractTableCellColors,
BLOCK_COLORS_LINE_REGEX,
TABLE_CELL_COLORS_LINE_REGEX,
hasNonDefaultColors,
parseBlockColorsMarker,
parseTableCellColorsMarker,
serializeBlockColorsMarker,
serializeTableCellColorsMarker
serializeBlockColorsMarker
} from '@memry/shared/block-colors'
import {
type MarkedBlock,
type SidecarPatch,
parseSidecarMarkerLine,
sidecarMarkerLines
} from '@memry/shared/block-markers'
import {
applyInlineColorTokens,
extractInlineColorRuns,
Expand Down Expand Up @@ -586,7 +585,7 @@ async function parseMarkdownWithoutToggles(

for (const seg of segments) {
if (seg.type === 'content') {
blocks.push(...(await parseContentWithColorMarkers(editor, seg.text)))
blocks.push(...(await parseContentWithMarkers(editor, seg.text)))
} else {
for (let i = 0; i < seg.extraLines; i++) {
blocks.push(createEmptyParagraph())
Expand All @@ -597,26 +596,23 @@ async function parseMarkdownWithoutToggles(
return blocks
}

async function parseContentWithColorMarkers(
async function parseContentWithMarkers(
editor: ServerBlockNoteEditor,
text: string
): Promise<Block[]> {
const blocks: Block[] = []
let buffer: string[] = []
let pendingColors: BlockColors | null = null
let pendingTableColors: TableCellColors | null = null
let pending: SidecarPatch[] = []

const applyPending = (block: Block | undefined): void => {
if (block) for (const apply of pending) apply(block as unknown as MarkedBlock)
pending = []
}

const flushBuffer = async (): Promise<void> => {
if (buffer.length === 0) return
const parsed = await parseMarkdownChunkPreservingNesting(editor, buffer.join('\n'))
if (pendingColors && parsed[0]) {
parsed[0].props = { ...parsed[0].props, ...pendingColors }
}
if (pendingTableColors && parsed[0]) {
applyTableCellColors(parsed[0].content, pendingTableColors)
}
pendingColors = null
pendingTableColors = null
applyPending(parsed[0])
blocks.push(...parsed)
buffer = []
}
Expand All @@ -635,19 +631,18 @@ async function parseContentWithColorMarkers(
// and its bytes stay untouched, which is what keeps `> [!note]` in an
// Obsidian vault byte-identical through Memry.
if (!insideFence) {
// A colors marker sits directly above the block it colors, so a claim
// right after one is still a paragraph start.
const atParagraphStart =
i === 0 ||
lines[i - 1].trim() === '' ||
(buffer.length === 0 && (pendingColors !== null || pendingTableColors !== null))
const afterSidecarMarker = buffer.length === 0 && pending.length > 0
const atParagraphStart = i === 0 || lines[i - 1].trim() === '' || afterSidecarMarker
const claimed = await parseCalloutRunAt(editor, lines, i, atParagraphStart)
if (claimed) {
await flushBuffer()
const props = { type: claimed.type, ...(pendingColors ?? {}) }
pendingColors = null
pendingTableColors = null
blocks.push({ type: 'callout', props, content: claimed.content } as unknown as Block)
const callout = {
type: 'callout',
props: { type: claimed.type },
content: claimed.content
} as unknown as Block
applyPending(callout)
blocks.push(callout)
for (let consumed = i + 1; consumed < claimed.end; consumed++) {
fence.consume(lines[consumed])
}
Expand All @@ -658,15 +653,14 @@ async function parseContentWithColorMarkers(
const quoted = atParagraphStart ? await parseQuoteRunAt(editor, lines, i) : null
if (quoted) {
await flushBuffer()
const props = { ...(pendingColors ?? {}) }
pendingColors = null
pendingTableColors = null
blocks.push({
const quote = {
type: 'quote',
props,
props: {},
content: quoted.content,
children: quoted.children
} as unknown as Block)
} as unknown as Block
applyPending(quote)
blocks.push(quote)
for (let consumed = i + 1; consumed < quoted.end; consumed++) {
fence.consume(lines[consumed])
}
Expand All @@ -676,35 +670,20 @@ async function parseContentWithColorMarkers(
}

// Deliberately NOT fence-guarded: this branch predates custom-block parsing
// and guarding it would drop a colour marker that follows a fence this
// and guarding it would drop a sidecar marker that follows a fence this
// tracker read differently, which is data loss on a path #1432 never
// touched. The renderer's twin (markdown-utils.ts) is unguarded too.
if (BLOCK_COLORS_LINE_REGEX.test(trimmed)) {
const colors = parseBlockColorsMarker(trimmed)
if (colors) {
await flushBuffer()
pendingColors = colors
continue
}
}

// Same rule, one level down: the colors of the individual cells of the
// table that follows. `flushBuffer` returns early on an empty buffer, so
// the two markers can sit on consecutive lines without clearing each other.
if (TABLE_CELL_COLORS_LINE_REGEX.test(trimmed)) {
const cellColors = parseTableCellColorsMarker(trimmed)
if (cellColors) {
await flushBuffer()
pendingTableColors = cellColors
continue
}
const patch = parseSidecarMarkerLine(trimmed)
if (patch) {
await flushBuffer()
pending.push(patch)
continue
}

const marker = insideFence ? null : parseCustomBlockMarkerLine(line)
if (marker) {
await flushBuffer()
pendingColors = null
pendingTableColors = null
pending = []
blocks.push(marker)
continue
}
Expand Down Expand Up @@ -849,22 +828,6 @@ function parseHttpUrl(url: string): URL | null {
}
}

/**
* The marker lines a block needs in front of it to keep the colors markdown
* cannot carry: its own text/background color, and — for a table — the colors
* of its individual cells. Empty for everything else, which is what keeps the
* bytes of every note without a colored block exactly as they were.
*/
function colorMarkerLines(block: Block): string[] {
const lines: string[] = []
if (hasNonDefaultColors(block.props as BlockColors)) {
lines.push(serializeBlockColorsMarker(block.props as BlockColors))
}
const cellColors = extractTableCellColors(block.content)
if (cellColors) lines.push(serializeTableCellColorsMarker(cellColors))
return lines
}

async function blocksToMarkdownPreserving(
editor: ServerBlockNoteEditor,
blocks: Block[]
Expand All @@ -889,7 +852,7 @@ async function blocksToMarkdownPreserving(
}

for (const block of blocks) {
const colorMarkers = colorMarkerLines(block)
const markers = sidecarMarkerLines(block as unknown as MarkedBlock)

if ((block.type as string) === 'taskBlock') {
// BlockNote can't serialize a taskBlock (it's content:'none'), so emit the
Expand All @@ -914,7 +877,7 @@ async function blocksToMarkdownPreserving(
const quoted = await serializeQuote(editor, block)
segments.push({
type: 'content',
text: colorMarkers.length > 0 ? `${colorMarkers.join('\n')}\n${quoted}` : quoted
text: markers.length > 0 ? `${markers.join('\n')}\n${quoted}` : quoted
})
} else if (isEmptyParagraph(block)) {
if (contentGroup.length > 0) {
Expand All @@ -923,7 +886,7 @@ async function blocksToMarkdownPreserving(
contentGroup = []
}
emptyCount++
} else if (colorMarkers.length > 0) {
} else if (markers.length > 0) {
if (contentGroup.length > 0) {
const md = await serializeBlocks(editor, contentGroup as PartialBlock[])
segments.push({ type: 'content', text: md.trim() })
Expand All @@ -936,7 +899,7 @@ async function blocksToMarkdownPreserving(
const blockMd = await serializeBlocks(editor, [block] as PartialBlock[])
segments.push({
type: 'content',
text: `${colorMarkers.join('\n')}\n${blockMd.trim()}`
text: `${markers.join('\n')}\n${blockMd.trim()}`
})
} else if (hasMarkerSerializedChildren(block)) {
if (contentGroup.length > 0) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
title: Text alignment round-trip corpus
---

A paragraph nobody has aligned

<!-- align:center -->
Centred paragraph

<!-- align:right -->
## Right-aligned heading

<!-- align:justify -->
Justified paragraph
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,28 @@ const SOURCE_TYPE_BY_VISUAL_TYPE = {
note: 'note'
} as const

/**
* The instant these tests pretend it is, on today's real local date.
*
* `use-today` snapshots the local date into module scope at import and re-reads the wall clock
* for its first subscriber. A clock faked onto any other date therefore arrives as a midnight
* rollover, which moves `todayCalendarRange`, moves the query key with it, and makes the widget
* fetch a second day on mount. Only the time of day is pinned, and from local fields rather than
* a UTC instant, which far enough from UTC would name a different day.
*/
const NOW = new Date()
NOW.setHours(9, 30, 0, 0)

function todayAtHour(hour: number): string {
const at = new Date(NOW)
at.setHours(hour, 0, 0, 0)
return at.toISOString()
}

function projectionItem(
id: string,
title: string,
hourUtc: number,
hour: number,
visualType: keyof typeof SOURCE_TYPE_BY_VISUAL_TYPE
): CalendarProjectionItem {
return {
Expand All @@ -43,8 +61,8 @@ function projectionItem(
sourceId: id,
title,
descriptionPreview: null,
startAt: `2026-08-31T${String(hourUtc).padStart(2, '0')}:00:00.000Z`,
endAt: `2026-08-31T${String(hourUtc + 1).padStart(2, '0')}:00:00.000Z`,
startAt: todayAtHour(hour),
endAt: todayAtHour(hour + 1),
isAllDay: false,
timezone: 'UTC',
visualType,
Expand Down Expand Up @@ -101,7 +119,7 @@ function renderApp(): { showBoard: (visible: boolean) => void } {

describe('home calendar widget stays current', () => {
beforeEach(() => {
vi.setSystemTime(new Date('2026-08-31T09:30:00.000Z'))
vi.setSystemTime(NOW)
listeners.clear()
server.items = [projectionItem('e1', 'Standup', 10, 'event')]
mockGetRange.mockReset()
Expand Down
Loading
Loading