mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-09 21:25:45 +08:00
fix(callout): fix text merging issue when deleting callout sub-blocks
- Fix text content disappearing after deleting callout sub-blocks - Properly clone text content before deletion to prevent data loss - Ensure text merges correctly to previous block with formatting preserved - Improve cursor positioning after merge operation
This commit is contained in:
@@ -22,6 +22,7 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
|
||||
|
||||
.affine-callout-block-container {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
background-color: ${unsafeCSSVarV2('block/callout/background/grey')};
|
||||
@@ -29,7 +30,6 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
|
||||
|
||||
.affine-callout-emoji-container {
|
||||
margin-right: 10px;
|
||||
margin-top: 14px;
|
||||
user-select: none;
|
||||
font-size: 1.2em;
|
||||
width: 24px;
|
||||
@@ -37,6 +37,8 @@ export class CalloutBlockComponent extends CaptionedBlockComponent<CalloutBlockM
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.affine-callout-emoji:hover {
|
||||
cursor: pointer;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { CalloutBlockModel } from '@blocksuite/affine-model';
|
||||
import {
|
||||
CalloutBlockModel,
|
||||
ParagraphBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import {
|
||||
BlockSelection,
|
||||
@@ -6,13 +9,45 @@ import {
|
||||
TextSelection,
|
||||
} from '@blocksuite/std';
|
||||
|
||||
import { calloutToParagraphCommand } from './commands/callout-to-paragraph.js';
|
||||
import { splitCalloutCommand } from './commands/split-callout.js';
|
||||
|
||||
export const CalloutKeymapExtension = KeymapExtension(std => {
|
||||
return {
|
||||
Enter: ctx => {
|
||||
const text = std.selection.find(TextSelection);
|
||||
if (!text) return false;
|
||||
|
||||
const currentBlock = std.store.getBlock(text.from.blockId);
|
||||
if (!currentBlock) return false;
|
||||
|
||||
// Check if current block is a callout block
|
||||
let calloutBlock = currentBlock;
|
||||
if (!matchModels(currentBlock.model, [CalloutBlockModel])) {
|
||||
// If not, check if the parent is a callout block
|
||||
const parent = std.store.getParent(currentBlock.model);
|
||||
if (!parent || !matchModels(parent, [CalloutBlockModel])) {
|
||||
return false;
|
||||
}
|
||||
calloutBlock = std.store.getBlock(parent.id);
|
||||
if (!calloutBlock) return false;
|
||||
}
|
||||
|
||||
ctx.get('keyboardState').raw.preventDefault();
|
||||
std.command
|
||||
.chain()
|
||||
.pipe(splitCalloutCommand, {
|
||||
blockId: calloutBlock.model.id,
|
||||
inlineIndex: text.from.index,
|
||||
currentBlockId: text.from.blockId,
|
||||
})
|
||||
.run();
|
||||
return true;
|
||||
},
|
||||
Backspace: ctx => {
|
||||
const text = std.selection.find(TextSelection);
|
||||
if (text && text.isCollapsed() && text.from.index === 0) {
|
||||
const event = ctx.get('defaultState').event;
|
||||
event.preventDefault();
|
||||
|
||||
const block = std.store.getBlock(text.from.blockId);
|
||||
if (!block) return false;
|
||||
@@ -20,6 +55,22 @@ export const CalloutKeymapExtension = KeymapExtension(std => {
|
||||
if (!parent) return false;
|
||||
if (!matchModels(parent, [CalloutBlockModel])) return false;
|
||||
|
||||
// Check if current block is a paragraph inside callout
|
||||
if (matchModels(block.model, [ParagraphBlockModel])) {
|
||||
event.preventDefault();
|
||||
|
||||
std.command
|
||||
.chain()
|
||||
.pipe(calloutToParagraphCommand, {
|
||||
id: block.model.id,
|
||||
})
|
||||
.run();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback to selecting the callout block
|
||||
event.preventDefault();
|
||||
std.selection.setGroup('note', [
|
||||
std.selection.create(BlockSelection, {
|
||||
blockId: parent.id,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
CalloutBlockModel,
|
||||
ParagraphBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { focusTextModel } from '@blocksuite/affine-rich-text';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import type { Command } from '@blocksuite/std';
|
||||
import { BlockSelection } from '@blocksuite/std';
|
||||
import { Text } from '@blocksuite/store';
|
||||
|
||||
export const calloutToParagraphCommand: Command<
|
||||
{
|
||||
id: string;
|
||||
stopCapturing?: boolean;
|
||||
},
|
||||
{
|
||||
success: boolean;
|
||||
}
|
||||
> = (ctx, next) => {
|
||||
const { id, stopCapturing = true } = ctx;
|
||||
const std = ctx.std;
|
||||
const doc = std.store;
|
||||
const model = doc.getBlock(id)?.model;
|
||||
|
||||
if (!model || !matchModels(model, [ParagraphBlockModel])) return false;
|
||||
|
||||
const parent = doc.getParent(model);
|
||||
if (!parent || !matchModels(parent, [CalloutBlockModel])) return false;
|
||||
|
||||
if (stopCapturing) std.store.captureSync();
|
||||
|
||||
// Get current block index in callout
|
||||
const currentIndex = parent.children.indexOf(model);
|
||||
const hasText = model.text && model.text.length > 0;
|
||||
|
||||
// Find previous paragraph block in callout
|
||||
let previousBlock = null;
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const sibling = parent.children[i];
|
||||
if (matchModels(sibling, [ParagraphBlockModel])) {
|
||||
previousBlock = sibling;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (previousBlock && hasText) {
|
||||
// Clone current text content before any operations to prevent data loss
|
||||
const currentText = model.text || new Text();
|
||||
|
||||
// Get previous block text and merge index
|
||||
const previousText = previousBlock.text || new Text();
|
||||
const mergeIndex = previousText.length;
|
||||
|
||||
// Apply each delta from cloned current text to previous block to preserve formatting
|
||||
previousText.join(currentText);
|
||||
|
||||
// Remove current block after text has been merged
|
||||
doc.deleteBlock(model, {
|
||||
deleteChildren: false,
|
||||
});
|
||||
|
||||
// Focus at merge point in previous block
|
||||
focusTextModel(std, previousBlock.id, mergeIndex);
|
||||
} else if (previousBlock && !hasText) {
|
||||
// Move cursor to end of previous block
|
||||
doc.deleteBlock(model, {
|
||||
deleteChildren: false,
|
||||
});
|
||||
|
||||
const previousText = previousBlock.text || new Text();
|
||||
focusTextModel(std, previousBlock.id, previousText.length);
|
||||
} else {
|
||||
// No previous block, select the entire callout
|
||||
doc.deleteBlock(model, {
|
||||
deleteChildren: false,
|
||||
});
|
||||
|
||||
std.selection.setGroup('note', [
|
||||
std.selection.create(BlockSelection, {
|
||||
blockId: parent.id,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
return next({ success: true });
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
CalloutBlockModel,
|
||||
ParagraphBlockModel,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { focusTextModel } from '@blocksuite/affine-rich-text';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import type { Command, EditorHost } from '@blocksuite/std';
|
||||
|
||||
export const splitCalloutCommand: Command<{
|
||||
blockId: string;
|
||||
inlineIndex: number;
|
||||
currentBlockId: string;
|
||||
}> = (ctx, next) => {
|
||||
const { blockId, inlineIndex, currentBlockId, std } = ctx;
|
||||
const host = std.host as EditorHost;
|
||||
const doc = host.store;
|
||||
|
||||
const calloutModel = doc.getBlock(blockId)?.model;
|
||||
if (!calloutModel || !matchModels(calloutModel, [CalloutBlockModel])) {
|
||||
console.error(`block ${blockId} is not a callout block`);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentModel = doc.getBlock(currentBlockId)?.model;
|
||||
if (!currentModel) {
|
||||
console.error(`current block ${currentBlockId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
doc.captureSync();
|
||||
|
||||
if (matchModels(currentModel, [ParagraphBlockModel])) {
|
||||
// User is in a paragraph within the callout's children
|
||||
const afterText = currentModel.props.text.split(inlineIndex);
|
||||
|
||||
// Update the current paragraph's text to keep only the part before cursor
|
||||
doc.transact(() => {
|
||||
currentModel.props.text.delete(
|
||||
inlineIndex,
|
||||
currentModel.props.text.length - inlineIndex
|
||||
);
|
||||
});
|
||||
|
||||
// Create a new paragraph block after the current one
|
||||
const parent = doc.getParent(currentModel);
|
||||
if (parent) {
|
||||
const currentIndex = parent.children.indexOf(currentModel);
|
||||
const newParagraphId = doc.addBlock(
|
||||
'affine:paragraph',
|
||||
{
|
||||
text: afterText,
|
||||
},
|
||||
parent,
|
||||
currentIndex + 1
|
||||
);
|
||||
|
||||
if (newParagraphId) {
|
||||
host.updateComplete
|
||||
.then(() => {
|
||||
focusTextModel(std, newParagraphId);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If current block is not a paragraph, create a new paragraph in callout
|
||||
const newParagraphId = doc.addBlock(
|
||||
'affine:paragraph',
|
||||
{
|
||||
text: new Text(),
|
||||
},
|
||||
calloutModel
|
||||
);
|
||||
|
||||
if (newParagraphId) {
|
||||
host.updateComplete
|
||||
.then(() => {
|
||||
focusTextModel(std, newParagraphId);
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
Reference in New Issue
Block a user