mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-02 02:00:49 +08:00
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:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user