fix(editor): import & save logic (#15098)

fix #15080
fix #15085
fix #15031
fix #15094


#### PR Dependency Tree


* **PR #15098** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
  * Improved code-block paste behavior for plain-text insertion
  * Fixed block selection ordering to reflect document model
  * Made table cell formatting resilient to conversion errors
  * Ensured user feature list is consistently returned as an array

* **Refactor**
  * Streamlined authentication session fetch and profile enrichment flow

* **Tests**
  * Added tests for markdown blockquote list preservation
  * Added authentication session validation tests
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-06-10 22:43:31 +08:00
committed by GitHub
parent 6faebcabd3
commit 07a08e6d4d
9 changed files with 392 additions and 65 deletions
@@ -270,6 +270,54 @@ Hello world
expect(meta?.tags).toEqual(['a', 'b']);
});
test('preserves list text inside blockquotes without list blocks', async () => {
const markdown = `> **Shopping List:**
> - Apples
> - Bananas
> - Oranges
`;
const mdAdapter = new MarkdownAdapter(createJob(), provider);
const snapshot = await mdAdapter.toDocSnapshot({
file: markdown,
assets: new AssetsManager({ blob: new MemoryBlobCRUD() }),
});
expect(simplifyBlockForSnapshot(snapshot.blocks, new Map())).toMatchObject({
children: [
{
flavour: 'affine:note',
children: [
{
flavour: 'affine:paragraph',
type: 'quote',
delta: [
{ insert: 'Shopping List:' },
{ insert: '\n' },
{ insert: '- ' },
{ insert: 'Apples' },
{ insert: '\n' },
{ insert: '- ' },
{ insert: 'Bananas' },
{ insert: '\n' },
{ insert: '- ' },
{ insert: 'Oranges' },
],
},
],
},
],
});
const exported = await mdAdapter.fromDocSnapshot({
snapshot,
assets: new AssetsManager({ blob: new MemoryBlobCRUD() }),
});
expect(exported.file).toContain('> **Shopping List:**');
expect(exported.file).toContain('> \\- Apples');
expect(exported.file).toContain('> \\- Bananas');
expect(exported.file).toContain('> \\- Oranges');
});
test('imports obsidian vault fixtures', async () => {
const schema = new Schema().register(AffineSchemas);
const collection = new TestWorkspace();
@@ -1,4 +1,5 @@
import { deleteTextCommand } from '@blocksuite/affine-inline-preset';
import type { RichText } from '@blocksuite/affine-rich-text';
import {
HtmlAdapter,
pasteMiddleware,
@@ -18,6 +19,7 @@ import {
LifeCycleWatcher,
LifeCycleWatcherIdentifier,
StdIdentifier,
TextSelection,
type UIEventHandler,
} from '@blocksuite/std';
import type { ExtensionType } from '@blocksuite/store';
@@ -103,6 +105,30 @@ export class CodeBlockClipboardController extends LifeCycleWatcher {
const e = ctx.get('clipboardState').raw;
e.preventDefault();
const textSelection = this.std.selection.find(TextSelection);
const plainText = e.clipboardData
?.getData('text/plain')
?.replace(/\r?\n|\r/g, '\n');
const selectedBlockId = textSelection?.from.blockId;
const codeBlock = selectedBlockId
? this.std.store.getBlock(selectedBlockId)?.model
: null;
if (plainText && codeBlock?.flavour === 'affine:code' && selectedBlockId) {
const richText = this.std.view
.getBlock(selectedBlockId)
?.querySelector<RichText>('rich-text');
const inlineEditor = richText?.inlineEditor;
const inlineRange = inlineEditor?.getInlineRange();
if (inlineEditor && inlineRange) {
inlineEditor.insertText(inlineRange, plainText);
inlineEditor.setInlineRange({
index: inlineRange.index + plainText.length,
length: 0,
});
return true;
}
}
this.std.store.captureSync();
this.std.command
.chain()
@@ -54,9 +54,9 @@ type Cell = {
value: string | { delta: DeltaInsert[] };
};
export const processTable = (
columns: ColumnDataType[],
children: BlockSnapshot[],
cells: SerializedCells
columns: ColumnDataType[] = [],
children: BlockSnapshot[] = [],
cells: SerializedCells = {}
): Table => {
const table: Table = {
headers: columns,
@@ -90,13 +90,17 @@ export const processTable = (
return;
}
let value: string | { delta: DeltaInsert[] };
if (isDelta(cell.value)) {
value = cell.value;
} else {
value = property.config.rawValue.toString({
value: cell.value,
data: col.data,
});
try {
if (isDelta(cell.value)) {
value = cell.value;
} else {
value = property.config.rawValue.toString({
value: cell.value,
data: col.data,
});
}
} catch {
value = '';
}
row.cells.push({
value,
@@ -5,10 +5,11 @@ import {
IN_PARAGRAPH_NODE_CONTEXT_KEY,
isCalloutNode,
type MarkdownAST,
type MarkdownDeltaConverter,
} from '@blocksuite/affine-shared/adapters';
import type { DeltaInsert } from '@blocksuite/store';
import type { BlockSnapshot, DeltaInsert } from '@blocksuite/store';
import { nanoid } from '@blocksuite/store';
import type { Heading } from 'mdast';
import type { Blockquote, Heading, List, ListItem } from 'mdast';
/**
* Extend the HeadingData type to include the collapsed property
@@ -24,6 +25,131 @@ const PARAGRAPH_MDAST_TYPE = new Set(['paragraph', 'heading', 'blockquote']);
const isParagraphMDASTType = (node: MarkdownAST) =>
PARAGRAPH_MDAST_TYPE.has(node.type);
const joinDeltaLines = (
lines: DeltaInsert[][],
prefix?: string
): DeltaInsert[] => {
const deltas: DeltaInsert[] = [];
lines.forEach(line => {
if (deltas.length) deltas.push({ insert: '\n' });
if (prefix) deltas.push({ insert: prefix });
deltas.push(...line);
});
return deltas;
};
const flattenListItemToDelta = (
node: ListItem,
deltaConverter: MarkdownDeltaConverter,
prefix: string,
depth: number
): DeltaInsert[] => {
const firstParagraph = node.children[0];
const lines: DeltaInsert[][] = [];
if (firstParagraph?.type === 'paragraph') {
lines.push([
{ insert: prefix },
...deltaConverter.astToDelta(firstParagraph),
]);
} else {
lines.push([{ insert: prefix.trimEnd() }]);
}
node.children
.slice(firstParagraph?.type === 'paragraph' ? 1 : 0)
.forEach(child => {
const delta = flattenMarkdownBlockToDelta(
child as MarkdownAST,
deltaConverter,
depth + 1
);
if (delta.length) {
lines.push(delta);
}
});
return joinDeltaLines(lines);
};
const flattenMarkdownBlockToDelta = (
node: MarkdownAST,
deltaConverter: MarkdownDeltaConverter,
depth = 0
): DeltaInsert[] => {
switch (node.type) {
case 'paragraph':
case 'heading':
return deltaConverter.astToDelta(node);
case 'list': {
const list = node as List;
return joinDeltaLines(
list.children.map((item, index) => {
const order = (list.start ?? 1) + index;
const prefix =
' '.repeat(depth) + (list.ordered ? `${order}. ` : '- ');
return flattenListItemToDelta(item, deltaConverter, prefix, depth);
})
);
}
case 'blockquote':
return flattenBlockquoteToDelta(node as Blockquote, deltaConverter);
default:
return 'children' in node
? joinDeltaLines(
(node.children as MarkdownAST[]).map(child =>
flattenMarkdownBlockToDelta(child, deltaConverter, depth)
)
)
: [];
}
};
const flattenBlockquoteToDelta = (
node: Blockquote,
deltaConverter: MarkdownDeltaConverter
) =>
joinDeltaLines(
node.children.map(child =>
flattenMarkdownBlockToDelta(child as MarkdownAST, deltaConverter)
)
);
const getSnapshotTextDelta = (node: BlockSnapshot): DeltaInsert[] => {
const text = (node.props.text ?? { delta: [] }) as {
delta: DeltaInsert[];
};
return text.delta;
};
const flattenSnapshotBlockToDelta = (
node: BlockSnapshot,
depth = 0
): DeltaInsert[] => {
if (node.flavour === 'affine:list') {
const type = node.props.type;
const order = (node.props.order as number | undefined) ?? 1;
const prefix =
' '.repeat(depth) + (type === 'numbered' ? `${order}. ` : '- ');
return joinDeltaLines([
[{ insert: prefix }, ...getSnapshotTextDelta(node)],
...node.children.map(child =>
flattenSnapshotBlockToDelta(child, depth + 1)
),
]);
}
return joinDeltaLines([
getSnapshotTextDelta(node),
...node.children.map(child => flattenSnapshotBlockToDelta(child, depth)),
]);
};
const flattenQuoteSnapshotToDelta = (
text: DeltaInsert[],
children: BlockSnapshot[]
) =>
joinDeltaLines([
text,
...children.map(child => flattenSnapshotBlockToDelta(child)),
]);
export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
{
flavour: ParagraphBlockSchema.model.flavour,
@@ -93,7 +219,10 @@ export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
type: 'quote',
text: {
'$blocksuite:internal:text$': true,
delta: deltaConverter.astToDelta(o.node),
delta: flattenBlockquoteToDelta(
o.node as Blockquote,
deltaConverter
),
},
},
children: [],
@@ -160,6 +289,10 @@ export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
break;
}
case 'quote': {
const quoteDelta = flattenQuoteSnapshotToDelta(
text.delta,
o.node.children
);
walkerContext
.openNode(
{
@@ -171,12 +304,13 @@ export const paragraphBlockMarkdownAdapterMatcher: BlockMarkdownAdapterMatcher =
.openNode(
{
type: 'paragraph',
children: deltaConverter.deltaToAST(text.delta),
children: deltaConverter.deltaToAST(quoteDelta),
},
'children'
)
.closeNode()
.closeNode();
walkerContext.skipAllChildren();
break;
}
}
@@ -129,32 +129,35 @@ export const getSelectedBlocksCommand: Command<
dirtyResult = dirtyResult.filter(ctx.filter);
}
const getModelPath = (el: BlockComponent) => {
const path: number[] = [];
let model = el.model;
while (model) {
const parent = ctx.std.store.getParent(model.id);
if (!parent) break;
path.unshift(parent.children.findIndex(child => child.id === model.id));
model = parent;
}
return path;
};
const compareByModelPath = (a: BlockComponent, b: BlockComponent) => {
if (a === b) return 0;
const aPath = getModelPath(a);
const bPath = getModelPath(b);
const length = Math.min(aPath.length, bPath.length);
for (let i = 0; i < length; i++) {
const diff = aPath[i] - bPath[i];
if (diff !== 0) return diff;
}
return aPath.length - bPath.length;
};
// remove duplicate elements
const result: BlockComponent[] = dirtyResult
.filter((el, index) => dirtyResult.indexOf(el) === index)
// sort by document position
.sort((a, b) => {
if (a === b) {
return 0;
}
const position = a.compareDocumentPosition(b);
if (
position & Node.DOCUMENT_POSITION_FOLLOWING ||
position & Node.DOCUMENT_POSITION_CONTAINED_BY
) {
return -1;
}
if (
position & Node.DOCUMENT_POSITION_PRECEDING ||
position & Node.DOCUMENT_POSITION_CONTAINS
) {
return 1;
}
return 0;
});
// sort by model tree position, which is the order used for paste/export
.sort(compareByModelPath);
if (result.length === 0) return;