Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions packages/blockly/core/dragging/block_drag_strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,7 +648,6 @@ export class BlockDragStrategy implements IDragStrategy {
const newCandidate =
this.getInitialCandidate() ?? this.getConnectionCandidate(delta);

this.connectionPreviewer?.hidePreview();
this.connectionCandidate = null;
if (!newCandidate) {
// Position above or below the first/last block.
Expand All @@ -657,13 +656,19 @@ export class BlockDragStrategy implements IDragStrategy {
currCandidate?.neighbour.getSourceBlock() ?? null;
let root = connectedBlock?.getRootBlock() ?? connectedBlock;
if (root === draggingBlock) root = connectedBlock;
if (!root) return;
if (!root) {
this.connectionPreviewer?.hidePreview();
return;
}

const blockRects = draggingBlock.workspace
.getTopBlocks()
.filter((block) => block !== draggingBlock.getRootBlock())
.map((block) => block.getBoundingRectangle());
if (!blockRects.length) return;
if (!blockRects.length) {
this.connectionPreviewer?.hidePreview();
return;
}

// If the block was previously connected, position the block near its previous connection.
const destinationX =
Expand Down Expand Up @@ -694,6 +699,7 @@ export class BlockDragStrategy implements IDragStrategy {
new Coordinate(destinationX, destinationY),
);
}
this.connectionPreviewer?.hidePreview();
return;
}
const candidate =
Expand Down
165 changes: 165 additions & 0 deletions packages/blockly/core/insertion_marker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* @license
* Copyright 2026 Raspberry Pi Foundation
* SPDX-License-Identifier: Apache-2.0
*/

import type {BlockSvg} from './block_svg.js';
import * as renderManagement from './render_management.js';
import {RenderedConnection} from './rendered_connection.js';
import {Coordinate} from './utils/coordinate.js';
import * as dom from './utils/dom.js';
import {Size} from './utils/size.js';
import {Svg} from './utils/svg.js';

/**
* Visual representation of a mid-drag block if it were to be connected.
*/
export class InsertionMarker {
/** The current size of the insertion marker, in workspace units. */
private size: Size = new Size(0, 0);

/** The DOM element representing the insertion marker. */
private marker?: SVGGElement;

/** The static connection to which the insertion marker is attached. */
private parentConnection?: RenderedConnection;

/**
* Returns the current size of the insertion marker in workspace units.
*/
getHeightWidth() {
return this.size;
}

/**
* Displays an insertion marker representing the block structure if
* `draggingConnection` were to be connected to `staticConnection`.
*
* @param staticConnection The proposed connection on the stationary block.
* @param draggingConnection The proposed connection on a block being dragged.
*/
show(
staticConnection: RenderedConnection,
draggingConnection: RenderedConnection,
) {
this.parentConnection = staticConnection;
this.parentConnection.attachInsertionMarker(this);

const connectingBlock = draggingConnection.getSourceBlock();

// Save all the existing connections pairs on the dragged block.
const oldConnections = new Map<
RenderedConnection,
RenderedConnection | null
>();
for (const connection of connectingBlock.getConnections_(false)) {
oldConnections.set(connection, connection.targetConnection);
connection.targetConnection = null;
}

// Temporarily connect the dragging and static connections and render the
// dragging block to steal its size and path for use by the insertion
// marker. Importantly, this does *not* call connect(), which causes DOM
// manipulation, fires events, etc – it just swaps the target connection.
draggingConnection.targetConnection = staticConnection;
connectingBlock.workspace.getRenderer().render(connectingBlock);
this.partiallyTighten(connectingBlock, draggingConnection);

this.marker = this.makeMarker(connectingBlock);

const bounds = connectingBlock.getBoundingRectangleWithoutChildren();
this.size = new Size(bounds.getWidth(), bounds.getHeight());

const blockOffset = staticConnection
.getSourceBlock()
.getRelativeToSurfaceXY();

const connectionOffset = Coordinate.difference(
staticConnection.getOffsetInBlock(),
draggingConnection.getOffsetInBlock(),
);

// Restore the state of the dragging block and rerender it.
for (const [source, target] of oldConnections.entries()) {
source.targetConnection = target;
}
connectingBlock.workspace.getRenderer().render(connectingBlock);
this.partiallyTighten(connectingBlock, draggingConnection);

// Move the insertion marker to abut the static connection and render the
// static block in case it needs to grow to accommodate the insertion
// marker.
this.marker.setAttribute(
'transform',
`translate(${blockOffset.x + connectionOffset.x}, ${blockOffset.y + connectionOffset.y})`,
);
staticConnection.getSourceBlock().queueRender();
renderManagement.triggerQueuedRenders();
}

/**
* Hides the insertion marker.
*/
hide() {
if (!this.parentConnection) return;

this.marker?.remove();
this.marker = undefined;
this.parentConnection.detachInsertionMarker();

this.parentConnection?.getSourceBlock().queueRender();
renderManagement.triggerQueuedRenders();
this.parentConnection = undefined;
}

/**
* Creates a new insertion marker corresponding to the given block.
*
* @param block The block whose path will be used for the insertion marker.
* @returns An SVG group representing an insertion marker.
*/
private makeMarker(block: BlockSvg) {
const newMarker = dom.createSvgElement(Svg.G, {
'transform': `translate(${block.relativeCoords.x}, ${block.relativeCoords.y})`,
'class': 'blocklyInsertionMarker',
});

const path = block.pathObject.svgPath;
dom.createSvgElement(
Svg.PATH,
{
'd': path.getAttribute('d') ?? '',
'class': 'blocklyPath',
},
newMarker,
);

block.workspace.getCanvas().appendChild(newMarker);

return newMarker;
}

/**
* Moves all connections on the given block adjacent to one another, excepting
* a specified connection. Otherwise identical to
* `BlockSvg.tightenChildrenEfficiently()`.
*
* @param block The block whose connections should be tightened.
* @param skip A connection to avoid tightening; generally that to which the
* insertion marker is being connected to, which may need to have a gap
* between it and its partner connection which the insertion marker will
* be spliced into.
*/
private partiallyTighten(block: BlockSvg, skip: RenderedConnection) {
for (const input of block.inputList) {
if (input === skip.getParentInput()) {
continue;
}
const conn = input.connection as RenderedConnection;
if (conn) conn.tightenEfficiently();
}
if (block.nextConnection && block.nextConnection !== skip)
block.nextConnection.tightenEfficiently();
}
}
52 changes: 46 additions & 6 deletions packages/blockly/core/insertion_marker_previewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@
import {BlockSvg} from './block_svg.js';
import {ConnectionType} from './connection_type.js';
import * as eventUtils from './events/utils.js';
import {InsertionMarker} from './insertion_marker.js';
import {IConnectionPreviewer} from './interfaces/i_connection_previewer.js';
import * as registry from './registry.js';
import * as renderManagement from './render_management.js';
import {RenderedConnection} from './rendered_connection.js';
import {Renderer as GerasRenderer} from './renderers/geras/renderer.js';
import {Renderer as ThrasosRenderer} from './renderers/thrasos/renderer.js';
import {Renderer as ZelosRenderer} from './renderers/zelos/renderer.js';
import * as blocks from './serialization/blocks.js';
import {WorkspaceSvg} from './workspace_svg.js';
Expand All @@ -26,8 +29,21 @@ export class InsertionMarkerPreviewer implements IConnectionPreviewer {

private staticConn: RenderedConnection | null = null;

private insertionMarker: InsertionMarker;

/**
* If set to true, uses a faster method for rendering insertion markers which
* will become the default in v14. This rendering method is enabled for the
* built-in Thrasos, Geras and Zelos renderers regardless of the state of this
* flag. Custom renderers will use the old rendering behavior unless this is
* set to true. This field will be removed in v14.
*/
static useFastInsertionMarkers = false;

constructor(draggedBlock: BlockSvg) {
this.workspace = draggedBlock.workspace;

this.insertionMarker = new InsertionMarker();
}

/**
Expand Down Expand Up @@ -62,7 +78,7 @@ export class InsertionMarkerPreviewer implements IConnectionPreviewer {

/**
* Display a connection preview where the draggedCon connects to the
* staticCon, and no block is being relaced.
* staticCon, and no block is being replaced.
*
* @param draggedConn The connection on the block stack being dragged.
* @param staticConn The connection not being dragged that we are
Expand All @@ -85,7 +101,11 @@ export class InsertionMarkerPreviewer implements IConnectionPreviewer {
// static connection, so that it doesn't disconnect unless that
// (+ a bit) has been exceeded.
if (this.shouldUseMarkerPreview(draggedConn, staticConn)) {
this.markerConn = this.previewMarker(draggedConn, staticConn);
if (this.shouldUseFastInsertionMarkers()) {
this.insertionMarker.show(staticConn, draggedConn);
} else {
this.markerConn = this.previewMarker(draggedConn, staticConn);
}
}

if (this.workspace.getRenderer().shouldHighlightConnection(staticConn)) {
Expand Down Expand Up @@ -236,10 +256,14 @@ export class InsertionMarkerPreviewer implements IConnectionPreviewer {
this.fadedBlock.fadeForReplacement(false);
this.fadedBlock = null;
}
if (this.markerConn) {
this.hideInsertionMarker(this.markerConn);
this.markerConn = null;
this.draggedConn = null;
if (this.shouldUseFastInsertionMarkers()) {
this.insertionMarker.hide();
} else {
if (this.markerConn) {
this.hideInsertionMarker(this.markerConn);
this.markerConn = null;
this.draggedConn = null;
}
}
} finally {
eventUtils.enable();
Expand Down Expand Up @@ -267,6 +291,22 @@ export class InsertionMarkerPreviewer implements IConnectionPreviewer {
dispose() {
this.hidePreview();
}

/**
* Returns whether or not new fast insertion marker rendering should be used.
* Defaults on for built-in renderers and off for custom renderers. Can be
* enabled for custom renderers by setting
* `InsertionMarkerPreviewer.useFastInsertionMarkers = true`.
*/
private shouldUseFastInsertionMarkers() {
const renderer = this.workspace.getRenderer();
return (
renderer.constructor === ThrasosRenderer ||
renderer.constructor === GerasRenderer ||
renderer.constructor === ZelosRenderer ||
InsertionMarkerPreviewer.useFastInsertionMarkers
);
}
}

registry.register(
Expand Down
38 changes: 38 additions & 0 deletions packages/blockly/core/rendered_connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {ConnectionType} from './connection_type.js';
import * as ContextMenu from './contextmenu.js';
import {ContextMenuRegistry} from './contextmenu_registry.js';
import * as eventUtils from './events/utils.js';
import type {InsertionMarker} from './insertion_marker.js';
import {IContextMenu} from './interfaces/i_contextmenu.js';
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
Expand Down Expand Up @@ -49,6 +50,7 @@ export class RenderedConnection
private readonly offsetInBlock: Coordinate;
private trackedState: TrackedState;
private highlighted: boolean = false;
private insertionMarker?: InsertionMarker;

/** Connection this connection connects to. Null if not connected. */
override targetConnection: RenderedConnection | null = null;
Expand Down Expand Up @@ -302,6 +304,15 @@ export class RenderedConnection
this.offsetInBlock,
target.offsetInBlock,
);

if (this.insertionMarker) {
if (this.type === ConnectionType.INPUT_VALUE) {
offset.x += this.insertionMarker.getHeightWidth().width;
} else {
offset.y += this.insertionMarker.getHeightWidth().height;
}
}

block.translate(offset.x, offset.y);
}

Expand Down Expand Up @@ -744,6 +755,33 @@ export class RenderedConnection
ShadowRoot | HTMLDocument;
return root.getElementById(this.id) as SVGPathElement | null;
}

/**
* Associates the given insertion marker with this connection.
*
* @internal
*/
attachInsertionMarker(marker: InsertionMarker) {
this.insertionMarker = marker;
}

/**
* Removes the insertion marker associated with this connection, if any.
*
* @internal
*/
detachInsertionMarker() {
this.insertionMarker = undefined;
}

/**
* Returns the insertion marker associated with this connection, if any.
*
* @internal
*/
getInsertionMarker() {
return this.insertionMarker;
}
}

export namespace RenderedConnection {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,15 @@ export class ExternalValueInput extends InputConnection {
constructor(constants: ConstantProvider, input: Input) {
super(constants, input);
this.type |= Types.EXTERNAL_VALUE_INPUT;
if (!this.connectedBlock) {

if (!this.connectedBlockHeight) {
this.height = this.shape.height as number;
} else {
const insertionMarker = this.connectionModel.getInsertionMarker();
if (insertionMarker) {
this.connectedBlockHeight = insertionMarker.getHeightWidth().height;
}

this.height =
this.connectedBlockHeight -
this.constants_.TAB_OFFSET_FROM_TOP -
Expand Down
Loading
Loading