Implement robust world-based positioning for Blend Mode menu

Redesigned the positioning system for the Blend Mode menu, inspired by the "Custom Output Area" logic. The menu now anchors precisely to the top-right corner of the viewport and stays in place during panning and zooming.
This commit is contained in:
Dariusz L
2025-07-30 13:06:01 +02:00
parent 03950b1787
commit e3cef041c9
8 changed files with 338 additions and 72 deletions

View File

@@ -47,6 +47,7 @@ const log = createModuleLogger('Canvas');
export class Canvas {
batchPreviewManagers: BatchPreviewManager[];
canvas: HTMLCanvasElement;
canvasContainer: HTMLDivElement | null;
canvasIO: CanvasIO;
canvasInteractions: CanvasInteractions;
canvasLayers: CanvasLayers;
@@ -84,6 +85,7 @@ export class Canvas {
offscreenCanvas: HTMLCanvasElement;
offscreenCtx: CanvasRenderingContext2D | null;
onHistoryChange: ((historyInfo: { canUndo: boolean; canRedo: boolean; }) => void) | undefined;
onViewportChange: (() => void) | null;
onStateChange: (() => void) | undefined;
pendingBatchContext: any;
pendingDataCheck: number | null;
@@ -105,6 +107,7 @@ export class Canvas {
this.layers = [];
this.onStateChange = callbacks.onStateChange;
this.onHistoryChange = callbacks.onHistoryChange;
this.onViewportChange = null;
this.lastMousePosition = {x: 0, y: 0};
this.viewport = {
@@ -119,6 +122,7 @@ export class Canvas {
});
this.offscreenCanvas = offscreenCanvas;
this.offscreenCtx = offscreenCtx;
this.canvasContainer = null;
this.dataInitialized = false;
this.pendingDataCheck = null;

View File

@@ -83,6 +83,8 @@ export class CanvasInteractions {
this.canvas.viewport.zoom = newZoom;
this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom);
this.canvas.onViewportChange?.();
}
private renderAndSave(shouldSave: boolean = false): void {
@@ -134,6 +136,33 @@ export class CanvasInteractions {
this.canvas.canvas.addEventListener('contextmenu', this.handleContextMenu.bind(this) as EventListener);
}
/**
* Sprawdza czy punkt znajduje się w obszarze któregokolwiek z zaznaczonych layerów
*/
isPointInSelectedLayers(worldX: number, worldY: number): boolean {
for (const layer of this.canvas.canvasSelection.selectedLayers) {
if (!layer.visible) continue;
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
// Przekształć punkt do lokalnego układu współrzędnych layera
const dx = worldX - centerX;
const dy = worldY - centerY;
const rad = -layer.rotation * Math.PI / 180;
const rotatedX = dx * Math.cos(rad) - dy * Math.sin(rad);
const rotatedY = dx * Math.sin(rad) + dy * Math.cos(rad);
// Sprawdź czy punkt jest wewnątrz prostokąta layera
if (Math.abs(rotatedX) <= layer.width / 2 &&
Math.abs(rotatedY) <= layer.height / 2) {
return true;
}
}
return false;
}
resetInteractionState(): void {
this.interaction.mode = 'none';
this.interaction.resizeHandle = null;
@@ -186,9 +215,10 @@ export class CanvasInteractions {
if (e.button === 2) { // Prawy przycisk myszy
this.preventEventDefaults(e);
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(coords.world.x, coords.world.y);
if (clickedLayerResult && this.canvas.canvasSelection.selectedLayers.includes(clickedLayerResult.layer)) {
this.canvas.canvasLayers.showBlendModeMenu(coords.view.x, coords.view.y);
// Sprawdź czy kliknięto w obszarze któregokolwiek z zaznaczonych layerów (niezależnie od przykrycia)
if (this.isPointInSelectedLayers(coords.world.x, coords.world.y)) {
// Nowa logika przekazuje tylko współrzędne świata, menu pozycjonuje się samo
this.canvas.canvasLayers.showBlendModeMenu(coords.world.x, coords.world.y);
}
return;
}
@@ -712,6 +742,7 @@ export class CanvasInteractions {
this.canvas.viewport.y -= dy / this.canvas.viewport.zoom;
this.interaction.panStart = {x: e.clientX, y: e.clientY};
this.canvas.render();
this.canvas.onViewportChange?.();
}
dragLayers(worldCoords: Point): void {

View File

@@ -29,6 +29,9 @@ export class CanvasLayers {
public internalClipboard: Layer[];
public clipboardPreference: ClipboardPreference;
private distanceFieldCache: WeakMap<HTMLImageElement, Map<number, HTMLCanvasElement>>;
private blendMenuElement: HTMLDivElement | null = null;
private blendMenuWorldX: number = 0;
private blendMenuWorldY: number = 0;
constructor(canvas: Canvas) {
this.canvas = canvas;
@@ -654,15 +657,89 @@ export class CanvasLayers {
return null;
}
showBlendModeMenu(x: number, y: number): void {
private currentCloseMenuListener: ((e: MouseEvent) => void) | null = null;
updateBlendModeMenuPosition(): void {
if (!this.blendMenuElement) return;
const screenX = (this.blendMenuWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
const screenY = (this.blendMenuWorldY - this.canvas.viewport.y) * this.canvas.viewport.zoom;
this.blendMenuElement.style.transform = `translate(${screenX}px, ${screenY}px)`;
}
showBlendModeMenu(worldX: number, worldY: number): void {
if (this.canvas.canvasSelection.selectedLayers.length === 0) {
return;
}
// Find which selected layer is at the click position (topmost visible layer at that position)
let selectedLayer: Layer | null = null;
const visibleSelectedLayers = this.canvas.canvasSelection.selectedLayers.filter((layer: Layer) => layer.visible);
if (visibleSelectedLayers.length === 0) {
return;
}
// Sort by zIndex descending and find the first one that contains the click point
const sortedLayers = visibleSelectedLayers.sort((a: Layer, b: Layer) => b.zIndex - a.zIndex);
for (const layer of sortedLayers) {
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
// Transform click point to layer's local coordinates
const dx = worldX - centerX;
const dy = worldY - centerY;
const rad = -layer.rotation * Math.PI / 180;
const rotatedX = dx * Math.cos(rad) - dy * Math.sin(rad);
const rotatedY = dx * Math.sin(rad) + dy * Math.cos(rad);
const withinX = Math.abs(rotatedX) <= layer.width / 2;
const withinY = Math.abs(rotatedY) <= layer.height / 2;
// Check if click is within layer bounds
if (withinX && withinY) {
selectedLayer = layer;
break;
}
}
// If no layer found at click position, fall back to topmost visible selected layer
if (!selectedLayer) {
selectedLayer = sortedLayers[0];
}
// At this point selectedLayer is guaranteed to be non-null
if (!selectedLayer) {
return;
}
// Remove any existing event listener first
if (this.currentCloseMenuListener) {
document.removeEventListener('mousedown', this.currentCloseMenuListener);
this.currentCloseMenuListener = null;
}
this.closeBlendModeMenu();
// Calculate position in WORLD coordinates (top-right of viewport)
const viewLeft = this.canvas.viewport.x;
const viewTop = this.canvas.viewport.y;
const viewWidth = this.canvas.canvas.width / this.canvas.viewport.zoom;
// Position near top-right corner
this.blendMenuWorldX = viewLeft + viewWidth - (250 / this.canvas.viewport.zoom); // 250px from right edge
this.blendMenuWorldY = viewTop + (10 / this.canvas.viewport.zoom); // 10px from top edge
const menu = document.createElement('div');
this.blendMenuElement = menu;
menu.id = 'blend-mode-menu';
menu.style.cssText = `
position: fixed;
left: ${x}px;
top: ${y}px;
position: absolute;
top: 0;
left: 0;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
@@ -688,10 +765,13 @@ export class CanvasLayers {
`;
const titleText = document.createElement('span');
titleText.textContent = 'Blend Mode';
titleText.textContent = `Blend Mode: ${selectedLayer.name}`;
titleText.style.cssText = `
flex: 1;
cursor: move;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const closeButton = document.createElement('button');
@@ -747,14 +827,12 @@ export class CanvasLayers {
blendAreaSlider.min = '0';
blendAreaSlider.max = '100';
const selectedLayerForBlendArea = this.canvas.canvasSelection.selectedLayers[0];
blendAreaSlider.value = selectedLayerForBlendArea?.blendArea?.toString() ?? '0';
blendAreaSlider.value = selectedLayer?.blendArea?.toString() ?? '0';
blendAreaSlider.oninput = () => {
if (selectedLayerForBlendArea) {
if (selectedLayer) {
const newValue = parseInt(blendAreaSlider.value, 10);
selectedLayerForBlendArea.blendArea = newValue;
log.info(`Blend Area changed to: ${newValue}% for layer: ${selectedLayerForBlendArea.id}`);
selectedLayer.blendArea = newValue;
this.canvas.render();
}
};
@@ -770,17 +848,17 @@ export class CanvasLayers {
let isDragging = false;
let dragOffset = { x: 0, y: 0 };
// Drag logic needs to update world coordinates, not screen coordinates
const handleMouseMove = (e: MouseEvent) => {
if (isDragging) {
const newX = e.clientX - dragOffset.x;
const newY = e.clientY - dragOffset.y;
const maxX = window.innerWidth - menu.offsetWidth;
const maxY = window.innerHeight - menu.offsetHeight;
menu.style.left = Math.max(0, Math.min(newX, maxX)) + 'px';
menu.style.top = Math.max(0, Math.min(newY, maxY)) + 'px';
const dx = e.movementX / this.canvas.viewport.zoom;
const dy = e.movementY / this.canvas.viewport.zoom;
this.blendMenuWorldX += dx;
this.blendMenuWorldY += dy;
this.updateBlendModeMenuPosition();
}
};
const handleMouseUp = () => {
if (isDragging) {
isDragging = false;
@@ -788,11 +866,9 @@ export class CanvasLayers {
document.removeEventListener('mouseup', handleMouseUp);
}
};
titleBar.addEventListener('mousedown', (e: MouseEvent) => {
isDragging = true;
dragOffset.x = e.clientX - parseInt(menu.style.left, 10);
dragOffset.y = e.clientY - parseInt(menu.style.top, 10);
e.preventDefault();
e.stopPropagation();
document.addEventListener('mousemove', handleMouseMove);
@@ -822,6 +898,12 @@ export class CanvasLayers {
}
option.onclick = () => {
// Re-check selected layer at the time of click
const currentSelectedLayer = this.canvas.canvasSelection.selectedLayers[0];
if (!currentSelectedLayer) {
return;
}
// Hide only the opacity sliders within other blend mode containers
content.querySelectorAll<HTMLDivElement>('.blend-mode-container').forEach(c => {
const opacitySlider = c.querySelector<HTMLInputElement>('input[type="range"]');
@@ -837,17 +919,21 @@ export class CanvasLayers {
slider.style.display = 'block';
option.style.backgroundColor = '#3a3a3a';
if (selectedLayer) {
selectedLayer.blendMode = mode.name;
this.canvas.render();
}
currentSelectedLayer.blendMode = mode.name;
this.canvas.render();
};
slider.addEventListener('input', () => {
if (selectedLayer) {
selectedLayer.opacity = parseInt(slider.value, 10) / 100;
this.canvas.render();
// Re-check selected layer at the time of slider input
const currentSelectedLayer = this.canvas.canvasSelection.selectedLayers[0];
if (!currentSelectedLayer) {
return;
}
const newOpacity = parseInt(slider.value, 10) / 100;
currentSelectedLayer.opacity = newOpacity;
this.canvas.render();
});
slider.addEventListener('change', async () => {
@@ -883,22 +969,48 @@ export class CanvasLayers {
e.stopPropagation();
});
const container = this.canvas.canvas.parentElement || document.body;
container.appendChild(menu);
if (!this.canvas.canvasContainer) {
log.error("Canvas container not found, cannot append blend mode menu.");
return;
}
this.canvas.canvasContainer.appendChild(menu);
this.updateBlendModeMenuPosition();
// Add listener for viewport changes
this.canvas.onViewportChange = () => this.updateBlendModeMenuPosition();
const closeMenu = (e: MouseEvent) => {
if (e.target instanceof Node && !menu.contains(e.target) && !isDragging) {
this.closeBlendModeMenu();
document.removeEventListener('mousedown', closeMenu);
if (this.currentCloseMenuListener) {
document.removeEventListener('mousedown', this.currentCloseMenuListener);
this.currentCloseMenuListener = null;
}
}
};
setTimeout(() => document.addEventListener('mousedown', closeMenu), 0);
// Store the listener reference so we can remove it later
this.currentCloseMenuListener = closeMenu;
setTimeout(() => {
document.addEventListener('mousedown', closeMenu);
}, 0);
}
closeBlendModeMenu(): void {
const menu = document.getElementById('blend-mode-menu');
if (menu && menu.parentNode) {
menu.parentNode.removeChild(menu);
log.info("=== BLEND MODE MENU CLOSING ===");
if (this.blendMenuElement && this.blendMenuElement.parentNode) {
log.info("Removing blend mode menu from DOM");
this.blendMenuElement.parentNode.removeChild(this.blendMenuElement);
this.blendMenuElement = null;
} else {
log.info("Blend mode menu not found or already removed");
}
// Remove viewport change listener
if (this.canvas.onViewportChange) {
this.canvas.onViewportChange = null;
}
}

View File

@@ -815,6 +815,8 @@ $el("label.clipboard-switch.mask-switch", {
}
}, [canvas.canvas]) as HTMLDivElement;
canvas.canvasContainer = canvasContainer;
const layersPanelContainer = $el("div.painterLayersPanelContainer", {
style: {
position: "absolute",