diff --git a/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts b/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts index 6bbbdc39c5..98cafcf7dd 100644 --- a/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts +++ b/blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts @@ -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(); diff --git a/blocksuite/affine/blocks/code/src/clipboard/index.ts b/blocksuite/affine/blocks/code/src/clipboard/index.ts index 677ef941bf..07bef1573e 100644 --- a/blocksuite/affine/blocks/code/src/clipboard/index.ts +++ b/blocksuite/affine/blocks/code/src/clipboard/index.ts @@ -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('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() diff --git a/blocksuite/affine/blocks/database/src/adapters/utils.ts b/blocksuite/affine/blocks/database/src/adapters/utils.ts index 2d95046a29..728df3dd3e 100644 --- a/blocksuite/affine/blocks/database/src/adapters/utils.ts +++ b/blocksuite/affine/blocks/database/src/adapters/utils.ts @@ -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, diff --git a/blocksuite/affine/blocks/paragraph/src/adapters/markdown.ts b/blocksuite/affine/blocks/paragraph/src/adapters/markdown.ts index 4569a18b08..21a6f62256 100644 --- a/blocksuite/affine/blocks/paragraph/src/adapters/markdown.ts +++ b/blocksuite/affine/blocks/paragraph/src/adapters/markdown.ts @@ -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; } } diff --git a/blocksuite/affine/shared/src/commands/block-crud/get-selected-blocks.ts b/blocksuite/affine/shared/src/commands/block-crud/get-selected-blocks.ts index fa10ca6f4f..446a30dc0a 100644 --- a/blocksuite/affine/shared/src/commands/block-crud/get-selected-blocks.ts +++ b/blocksuite/affine/shared/src/commands/block-crud/get-selected-blocks.ts @@ -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; diff --git a/packages/common/realtime/src/index.ts b/packages/common/realtime/src/index.ts index 72915b727a..4a99e705af 100644 --- a/packages/common/realtime/src/index.ts +++ b/packages/common/realtime/src/index.ts @@ -171,7 +171,7 @@ export interface CurrentUserProfileSnapshot { emailVerified: boolean; hasPassword: boolean | null; avatarUrl: string | null; - features?: string[]; + features: string[]; } export interface UserSettingsSnapshot { diff --git a/packages/frontend/core/src/modules/cloud/entities/user-feature.ts b/packages/frontend/core/src/modules/cloud/entities/user-feature.ts index ee6dce836d..c23596c0b5 100644 --- a/packages/frontend/core/src/modules/cloud/entities/user-feature.ts +++ b/packages/frontend/core/src/modules/cloud/entities/user-feature.ts @@ -46,7 +46,7 @@ export class UserFeature extends Entity { if (account?.id !== accountId) return; return { userId: account.id, - features: account.info?.features?.map(feature => + features: (account.info?.features ?? []).map(feature => mapRealtimeEnum(FeatureType, feature, 'user feature') ), }; diff --git a/packages/frontend/core/src/modules/cloud/stores/auth.spec.ts b/packages/frontend/core/src/modules/cloud/stores/auth.spec.ts new file mode 100644 index 0000000000..cb4e84ef89 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/stores/auth.spec.ts @@ -0,0 +1,111 @@ +import { AuthProvider } from '@affine/core/modules/cloud/provider/auth'; +import { FetchService } from '@affine/core/modules/cloud/services/fetch'; +import { GraphQLService } from '@affine/core/modules/cloud/services/graphql'; +import { ServerService } from '@affine/core/modules/cloud/services/server'; +import { AuthStore } from '@affine/core/modules/cloud/stores/auth'; +import { GlobalState, NbstoreService } from '@affine/core/modules/storage'; +import { Framework } from '@toeverything/infra'; +import { describe, expect, test, vi } from 'vitest'; + +function createStore({ + fetch, + request, +}: { + fetch: (input: string, init?: RequestInit) => Promise; + request: (op: string, input: object) => Promise; +}) { + const framework = new Framework(); + framework.service(FetchService, { fetch } as any); + framework.service(GraphQLService, {} as any); + framework.impl(GlobalState, {} as any); + framework.service(ServerService, { + server: { id: 'test-server' }, + } as any); + framework.impl(AuthProvider, {} as any); + framework.service(NbstoreService, { + realtime: { request }, + } as any); + framework.store(AuthStore, [ + FetchService, + GraphQLService, + GlobalState, + ServerService, + AuthProvider, + NbstoreService, + ]); + return framework.provider().get(AuthStore); +} + +describe('AuthStore', () => { + test('loads account profile from realtime after auth session bootstrap', async () => { + const authMethods = { + password: { bound: true }, + oauth: { bound: false, providers: [] }, + passkey: { bound: false, count: 0 }, + }; + const fetch = vi.fn(async (input: string) => { + if (input === '/api/auth/session') { + return { + json: async () => ({ user: { id: 'u1' } }), + } as Response; + } + if (input === '/api/auth/methods') { + return { + ok: true, + json: async () => authMethods, + } as Response; + } + throw new Error(`Unexpected request: ${input}`); + }); + const request = vi.fn(async () => ({ + user: { + id: 'u1', + email: 'u1@affine.pro', + name: 'User', + emailVerified: true, + hasPassword: true, + avatarUrl: null, + features: ['Admin'], + }, + })); + const store = createStore({ fetch, request }); + + await expect(store.fetchSession()).resolves.toEqual({ + user: { + id: 'u1', + email: 'u1@affine.pro', + name: 'User', + emailVerified: true, + hasPassword: true, + avatarUrl: null, + features: ['Admin'], + authMethods, + }, + }); + expect(request).toHaveBeenCalledWith('user.profile.get', {}); + }); + + test('rejects mismatched realtime profile and auth session', async () => { + const fetch = vi.fn(async () => { + return { + json: async () => ({ user: { id: 'u1' } }), + } as Response; + }); + const request = vi.fn(async () => ({ + user: { + id: 'u2', + email: 'u2@affine.pro', + name: 'User', + emailVerified: true, + hasPassword: true, + avatarUrl: null, + features: [], + }, + })); + const store = createStore({ fetch, request }); + + await expect(store.fetchSession()).rejects.toThrow( + 'Realtime user profile does not match auth session' + ); + }); +}); diff --git a/packages/frontend/core/src/modules/cloud/stores/auth.ts b/packages/frontend/core/src/modules/cloud/stores/auth.ts index 75c9afe8f4..bf853faab2 100644 --- a/packages/frontend/core/src/modules/cloud/stores/auth.ts +++ b/packages/frontend/core/src/modules/cloud/stores/auth.ts @@ -5,6 +5,7 @@ import { updateUserProfileMutation, uploadAvatarMutation, } from '@affine/graphql'; +import type { CurrentUserProfileSnapshot } from '@affine/realtime'; import { Store } from '@toeverything/infra'; import type { GlobalState, NbstoreService } from '../../storage'; @@ -14,19 +15,12 @@ import type { FetchService } from '../services/fetch'; import type { GraphQLService } from '../services/graphql'; import type { ServerService } from '../services/server'; -export interface AccountProfile { - id: string; - email: string; - name: string; - hasPassword: boolean; +export interface AccountProfile extends CurrentUserProfileSnapshot { authMethods?: { password: { bound: boolean }; oauth: { bound: boolean; providers: string[] }; passkey: { bound: boolean; count: number }; }; - avatarUrl: string | null; - emailVerified: string | null; - features?: string[]; } export class AuthStore extends Store { @@ -68,9 +62,10 @@ export class AuthStore extends Store { id: user.id, email: user.email, name: user.name, - hasPassword: Boolean(user.hasPassword), + hasPassword: user.hasPassword, avatarUrl: user.avatarUrl, - emailVerified: user.emailVerified ? 'true' : null, + emailVerified: user.emailVerified, + features: [], }, }, }); @@ -85,24 +80,30 @@ export class AuthStore extends Store { } async fetchSession() { - const { user } = await this.fetchService + const session = await this.fetchAuthSession(); + if (!session.user) return { user: null }; + + const { user } = await this.nbstoreService.realtime.request( + 'user.profile.get', + {} + ); + if (!user || user.id !== session.user.id) { + throw new Error('Realtime user profile does not match auth session'); + } + const authMethods = await this.fetchAuthMethods(); + return { user: { ...user, authMethods } }; + } + + private async fetchAuthSession(): Promise<{ user: { id: string } | null }> { + return await this.fetchService .fetch('/api/auth/session', { cache: 'no-store' }) .then(res => res.json()); - const authMethods = user - ? await this.fetchService - .fetch('/api/auth/methods') - .then(res => (res.ok ? res.json() : undefined)) - : undefined; - return { - user: user - ? { - ...user, - hasPassword: Boolean(user.hasPassword), - authMethods, - emailVerified: user.emailVerified ? 'true' : null, - } - : null, - }; + } + + private async fetchAuthMethods() { + return await this.fetchService + .fetch('/api/auth/methods') + .then(res => (res.ok ? res.json() : undefined)); } async signInMagicLink(email: string, token: string) {