Описание проблемы
У меня есть настраиваемый элемент управления удалением, который появляется в правом верхнем углу выбранных текстовых объектов. Этот элемент управления отлично работает для текстовых объектов, добавленных во время инициализации холста, но когда я программно добавляю новые текстовые объекты и пытаюсь их удалить, нажатие кнопки удаления приводит к отмене выбора объекта и исчезновению объекта, а не к его удалению.
Пример кода
Вот минимальный воспроизводимый пример:
Код: Выделить всё
import { Canvas, Textbox, Control, type FabricObject } from 'fabric';
const canvasRef = ref(null);
const canvas = ref(null);
// Delete icon as data URL
const deleteIcon = 'data:image/svg+xml,...'; // SVG data URL
let deleteImg = null;
onMounted(() => {
deleteImg = document.createElement('img');
deleteImg.src = deleteIcon;
canvas.value = new Canvas(canvasRef.value, {
width: 800,
height: 800,
});
// Initial text - DELETE WORKS ✅
const initialText = new Textbox('Hello, World!', {
left: 200,
top: 200,
width: 250,
fontSize: 40,
editable: true,
hasControls: true,
hasBorders: true,
});
canvas.value.add(initialText);
initialText.controls = initialText.controls || {};
initialText.controls.deleteControl = createDeleteControl();
initialText.setCoords();
// Event listener for selection
canvas.value.on('selection:created', () => {
const activeObject = canvas.value.getActiveObject();
if (activeObject && activeObject.type === 'textbox') {
activeObject.controls = activeObject.controls || {};
activeObject.controls.deleteControl = createDeleteControl();
activeObject.setCoords();
canvas.value.renderAll();
}
});
});
// Create delete control
const createDeleteControl = () => {
return new Control({
x: 0.5,
y: -0.5,
offsetY: -16,
offsetX: 16,
cursorStyle: 'pointer',
mouseUpHandler: deleteObject,
render: renderDeleteIcon,
});
};
// Delete handler
const deleteObject = (_eventData, transform) => {
const canvas = transform.target.canvas;
if (canvas) {
canvas.remove(transform.target);
canvas.discardActiveObject();
canvas.requestRenderAll();
}
};
// Render delete icon
const renderDeleteIcon = (ctx, left, top, _styleOverride, fabricObject) => {
if (!deleteImg) return;
const size = 24;
ctx.save();
ctx.translate(left, top);
ctx.rotate((fabricObject.angle || 0) * Math.PI / 180);
ctx.drawImage(deleteImg, -size / 2, -size / 2, size, size);
ctx.restore();
};
// Add new text programmatically - DELETE DOESN'T WORK ❌
const handleAddText = () => {
const newText = new Textbox('New Text', {
left: 400,
top: 400,
width: 250,
fontSize: 40,
editable: true,
hasControls: true,
hasBorders: true,
});
// Add to canvas
canvas.value.add(newText);
// Select it
canvas.value.setActiveObject(newText);
// Add delete control
newText.controls = newText.controls || {};
newText.controls.deleteControl = createDeleteControl();
newText.setCoords();
canvas.value.renderAll();
};
- Нажатие кнопки удаления на любом текстовом объекте (исходном или добавленном программно) должно удалить объект.
- Элемент удаления должен оставаться работоспособным независимо от того, когда объект был добавлен.
- Кнопка «Удалить» работает для изначально добавленных текстовых объектов
- Кнопка «Удалить» на добавленных программно текстовых объектах вызывает отмену выбора вместо удаления
- MouseUpHandler в элементе управления удалением, похоже, не срабатывает для добавленных программно объектов
- Нет ошибок или предупреждений консоли
- Операции изменения порядка: добавлен объект на холст → выбран его → добавлен элемент управления → заданы координаты → отображен.
- Прослушиватели событий: добавлены обработчики select:created и select:updated для повторного добавления элементов управления.
- Отложенное добавление элемента управления: используется setTimeout для добавления элементов управления после срабатывания событий выбора.
- Несколько вызовов рендеринга: вызывается renderAll() несколько раз после добавления элементов управления.
- Проверка элементов управления: проверяется правильность прикрепления элементов управления (они находятся в объекте элементов управления).
- Обработчики событий мыши: попробовал mouseDownHandler, возвращающий true, чтобы предотвратить отмену выбора по умолчанию.
- Событие уровня холста. перехват: добавлен обработчик холста mouse:down для обнаружения кликов в области управления удалением.
- Fabric.js: 6.9.0
- Vue: 3.x
- Nuxt: 3.x
- Элемент управления удалением визуально отображается правильно как на исходном, так и на новом текстовых объектах.
- Элемент управления появляется в элементах управления объекта. объект.
- Журналы консоли показывают, что элемент управления создан с соответствующими обработчиками.
- Похоже, проблема заключается в том, что событие щелчка элемента управления удалением перехватывается или обработчик не создается. называется
- Возможна проблема с z-индексом или распространением событий, но я не знаю, как ее отладить в Fabric.js.
Существует ли известная проблема с настраиваемыми элементами управления для программно добавленных объектов в Fabric.js 6.x? Как правильно обеспечить работу пользовательских элементов управления для динамически добавляемых объектов? Есть ли какие-либо проблемы со временем или шаги инициализации, которые мне не хватает?
Вот полный код:
Код: Выделить всё
Canvas Item Controller
Add New Text
v-if="selectedTextObject"
class="space-y-4"
>
Selected Text
Font Color
Font Size: {{ selectedFontSize }}px
12px
72px
v-else
class="text-center text-gray-500 py-8"
>
Select a text object to edit
Debug Info
[h4]
Selected:
[/h4]
{{ selectedObjectInfo }}
[h4]
All Objects:
[/h4]
{{ allObjectsInfo }}
import { Canvas, Textbox, FabricImage, Control, type FabricObject } from 'fabric';
const canvasRef = ref(null);
const canvas = ref(null);
const selectedObjectInfo = ref('None');
const allObjectsInfo = ref('[]');
const selectedTextObject = ref(null);
const selectedTextColor = ref('#000000');
const selectedFontSize = ref(40);
// Delete icon SVG
const deleteIcon = 'data:image/svg+xml,%3C%3Fxml version=\'1.0\' encoding=\'utf-8\'%3F%3E%3C!DOCTYPE svg PUBLIC \'-//W3C//DTD SVG 1.1//EN\' \'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\'%3E%3Csvg version=\'1.1\' id=\'Ebene_1\' xmlns=\'http://www.w3.org/2000/svg\' xmlns:xlink=\'http://www.w3.org/1999/xlink\' x=\'0px\' y=\'0px\' width=\'595.275px\' height=\'595.275px\' viewBox=\'200 215 230 470\' xml:space=\'preserve\'%3E%3Ccircle style=\'fill:%23F44336;\' cx=\'299.76\' cy=\'439.067\' r=\'218.516\'/%3E%3Cg%3E%3Crect x=\'267.162\' y=\'307.978\' transform=\'matrix(0.7071 -0.7071 0.7071 0.7071 -222.6202 340.6915)\' style=\'fill:white;\' width=\'65.545\' height=\'262.18\'/%3E%3Crect x=\'266.988\' y=\'308.153\' transform=\'matrix(0.7071 0.7071 -0.7071 0.7071 398.3889 -83.3116)\' style=\'fill:white;\' width=\'65.544\' height=\'262.179\'/%3E%3C/g%3E%3C/svg%3E';
let deleteImg: HTMLImageElement | null = null;
// Load delete icon image
onMounted(() => {
deleteImg = document.createElement('img');
deleteImg.src = deleteIcon;
});
// Delete object function
const deleteObject = (_eventData: unknown, transform: { target: FabricObject & { canvas?: Canvas } }) => {
const canvas = transform.target.canvas;
if (canvas) {
canvas.remove(transform.target);
canvas.discardActiveObject();
canvas.requestRenderAll();
updateDebugInfo();
updateSelectedText();
}
};
// Render delete icon
const renderDeleteIcon = (ctx: CanvasRenderingContext2D, left: number, top: number, _styleOverride: unknown, fabricObject: FabricObject) => {
if (!deleteImg) return;
const size = 24;
ctx.save();
ctx.translate(left, top);
ctx.rotate((fabricObject.angle || 0) * Math.PI / 180);
ctx.drawImage(deleteImg, -size / 2, -size / 2, size, size);
ctx.restore();
};
// Create delete control
const createDeleteControl = () => {
return new Control({
x: 0.5,
y: -0.5,
offsetY: -16,
offsetX: 16,
cursorStyle: 'pointer',
mouseUpHandler: deleteObject,
render: renderDeleteIcon,
});
};
const initCanvas = () => {
if (!canvasRef.value) return;
const fabricCanvas = new Canvas(canvasRef.value, {
width: 800,
height: 800,
backgroundColor: '#ffffff',
});
// Add text element using Textbox - width-based resizing, fontSize stays constant
const textObj = new Textbox('Hello, World!', {
left: 200,
top: 200,
width: 250,
fontSize: 40,
fontFamily: 'Arial',
fill: '#000000',
selectable: true,
editable: true, // Explicitly enable inline editing
hasControls: true,
hasBorders: true,
lockScalingX: false, // Allow horizontal scaling (width change)
lockScalingY: true, // Prevent vertical scaling - only width changes
lockRotation: false,
splitByGrapheme: true, // Ensures text wraps correctly
});
fabricCanvas.add(textObj);
// Add delete control after adding to canvas (preserve existing controls)
textObj.controls = textObj.controls || {};
textObj.controls.deleteControl = createDeleteControl();
textObj.setCoords();
// Add image element
FabricImage.fromURL('/test-img.png', { crossOrigin: 'anonymous' })
.then((img: FabricImage) => {
img.set({
left: 400,
top: 400,
scaleX: 0.3,
scaleY: 0.3,
selectable: true,
hasControls: true,
hasBorders: true,
lockScalingX: false,
lockScalingY: false,
lockRotation: false,
});
fabricCanvas.add(img);
fabricCanvas.renderAll();
updateDebugInfo();
})
.catch((error) => {
console.error('Failed to load image:', error);
});
// Track object changes
fabricCanvas.on('object:modified', () => {
updateDebugInfo();
});
fabricCanvas.on('object:moving', () => {
updateDebugInfo();
});
fabricCanvas.on('object:scaling', () => {
updateDebugInfo();
});
fabricCanvas.on('object:rotating', () => {
updateDebugInfo();
});
fabricCanvas.on('selection:created', () => {
// Add delete control to selected text objects
const activeObject = fabricCanvas.getActiveObject();
if (activeObject && activeObject.type === 'textbox') {
activeObject.controls = activeObject.controls || {};
activeObject.controls.deleteControl = createDeleteControl();
activeObject.setCoords();
fabricCanvas.renderAll();
}
updateSelectedText();
updateDebugInfo();
});
fabricCanvas.on('selection:updated', () => {
// Add delete control to selected text objects
const activeObject = fabricCanvas.getActiveObject();
if (activeObject && activeObject.type === 'textbox') {
activeObject.controls = activeObject.controls || {};
activeObject.controls.deleteControl = createDeleteControl();
activeObject.setCoords();
fabricCanvas.renderAll();
}
updateSelectedText();
updateDebugInfo();
});
fabricCanvas.on('selection:cleared', () => {
selectedTextObject.value = null;
updateDebugInfo();
});
canvas.value = fabricCanvas;
updateDebugInfo();
};
const handleAddText = () => {
if (!canvas.value) return;
const centerX = (canvas.value.width || 800) / 2;
const centerY = (canvas.value.height || 800) / 2;
const textObj = new Textbox('New Text', {
left: centerX,
top: centerY,
width: 250,
fontSize: 40,
fontFamily: 'Arial',
fill: '#000000',
selectable: true,
editable: true, // Explicitly enable inline editing
hasControls: true,
hasBorders: true,
lockScalingX: false, // Allow horizontal scaling (width change)
lockScalingY: true, // Prevent vertical scaling - only width changes
lockRotation: false,
splitByGrapheme: true, // Ensures text wraps correctly
});
// 1. Add to canvas first (Fabric.js initializes default controls)
canvas.value.add(textObj);
// 2. Then select it (ensures selection state is set)
canvas.value.setActiveObject(textObj);
// 3. Then add delete control (preserve existing controls)
textObj.controls = textObj.controls || {};
textObj.controls.deleteControl = createDeleteControl();
// 4. Update coordinates after adding control
textObj.setCoords();
canvas.value.renderAll();
updateDebugInfo();
updateSelectedText();
};
const handleColorChange = (color: string) => {
if (!selectedTextObject.value) return;
selectedTextColor.value = color;
selectedTextObject.value.set('fill', color);
canvas.value?.renderAll();
updateDebugInfo();
};
const handleFontSizeChange = (size: number) => {
if (!selectedTextObject.value) return;
selectedFontSize.value = size;
selectedTextObject.value.set('fontSize', size);
canvas.value?.renderAll();
updateDebugInfo();
};
const updateSelectedText = () => {
if (!canvas.value) {
selectedTextObject.value = null;
return;
}
const activeObject = canvas.value.getActiveObject();
if (activeObject && (activeObject.type === 'textbox' || activeObject.type === 'i-text' || activeObject.type === 'text')) {
selectedTextObject.value = activeObject as Textbox;
selectedTextColor.value = selectedTextObject.value.fill as string || '#000000';
selectedFontSize.value = selectedTextObject.value.fontSize || 40;
}
else {
selectedTextObject.value = null;
}
};
const updateDebugInfo = () => {
if (!canvas.value) return;
const activeObject = canvas.value.getActiveObject();
const objects = canvas.value.getObjects();
// Update selected object info
if (activeObject) {
const obj = activeObject as FabricObject;
const isText = obj.type === 'textbox' || obj.type === 'i-text' || obj.type === 'text';
const textObj = isText ? obj as Textbox : null;
selectedObjectInfo.value = JSON.stringify({
type: obj.type,
left: obj.left,
top: obj.top,
width: 'width' in obj ? (obj as { width?: number }).width : undefined,
height: 'height' in obj ? (obj as { height?: number }).height : undefined,
scaleX: obj.scaleX,
scaleY: obj.scaleY,
angle: obj.angle,
...(textObj
? {
text: textObj.text,
fontSize: textObj.fontSize,
fontFamily: textObj.fontFamily,
fill: textObj.fill,
}
: {}),
}, null, 2);
}
else {
selectedObjectInfo.value = 'None';
}
// Update all objects info
allObjectsInfo.value = JSON.stringify(
objects.map((obj) => {
const fabricObj = obj as FabricObject;
const isText = fabricObj.type === 'textbox' || fabricObj.type === 'i-text' || fabricObj.type === 'text';
const textObj = isText ? fabricObj as Textbox : null;
return {
type: fabricObj.type,
left: fabricObj.left,
top: fabricObj.top,
width: 'width' in fabricObj ? (fabricObj as { width?: number }).width : undefined,
height: 'height' in fabricObj ? (fabricObj as { height?: number }).height : undefined,
scaleX: fabricObj.scaleX,
scaleY: fabricObj.scaleY,
angle: fabricObj.angle,
...(textObj
? {
text: textObj.text,
fontSize: textObj.fontSize,
}
: {}),
};
}),
null,
2,
);
};
onMounted(() => {
nextTick(() => {
initCanvas();
});
});
onUnmounted(() => {
if (canvas.value) {
canvas.value.dispose();
}
});
Подробнее здесь: https://stackoverflow.com/questions/798 ... -added-tex