Skip to content

Commit fae8b7f

Browse files
committed
release: merge develop into rv/v12.2.0
2 parents 8015956 + 5747fee commit fae8b7f

54 files changed

Lines changed: 2083 additions & 1777 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

core/block_svg.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -299,8 +299,19 @@ export class BlockSvg
299299
}
300300

301301
const oldXY = this.getRelativeToSurfaceXY();
302+
const focusedNode = getFocusManager().getFocusedNode();
303+
const restoreFocus = this.getSvgRoot().contains(
304+
focusedNode?.getFocusableElement() ?? null,
305+
);
302306
if (newParent) {
303307
(newParent as BlockSvg).getSvgRoot().appendChild(svgRoot);
308+
// appendChild() clears focus state, so re-focus the previously focused
309+
// node in case it was this block and would otherwise lose its focus. Once
310+
// Element.moveBefore() has better browser support, it should be used
311+
// instead.
312+
if (restoreFocus && focusedNode) {
313+
getFocusManager().focusNode(focusedNode);
314+
}
304315
} else if (oldParent) {
305316
// If we are losing a parent, we want to move our DOM element to the
306317
// root of the workspace. Try to insert it before any top-level
@@ -319,6 +330,13 @@ export class BlockSvg
319330
canvas.insertBefore(svgRoot, draggingBlockElement);
320331
} else {
321332
canvas.appendChild(svgRoot);
333+
// appendChild() clears focus state, so re-focus the previously focused
334+
// node in case it was this block and would otherwise lose its focus. Once
335+
// Element.moveBefore() has better browser support, it should be used
336+
// instead.
337+
if (restoreFocus && focusedNode) {
338+
getFocusManager().focusNode(focusedNode);
339+
}
322340
}
323341
this.translate(oldXY.x, oldXY.y);
324342
}
@@ -849,10 +867,30 @@ export class BlockSvg
849867
Tooltip.dispose();
850868
ContextMenu.hide();
851869

852-
// If this block was focused, focus its parent or workspace instead.
870+
// If this block (or a descendant) was focused, focus its parent or
871+
// workspace instead.
853872
const focusManager = getFocusManager();
854-
if (focusManager.getFocusedNode() === this) {
855-
const parent = this.getParent();
873+
if (
874+
this.getSvgRoot().contains(
875+
focusManager.getFocusedNode()?.getFocusableElement() ?? null,
876+
)
877+
) {
878+
let parent: BlockSvg | undefined | null = this.getParent();
879+
if (!parent) {
880+
// In some cases, blocks are disconnected from their parents before
881+
// being deleted. Attempt to infer if there was a parent by checking
882+
// for a connection within a radius of 0. Even if this wasn't a parent,
883+
// it must be adjacent to this block and so is as good an option as any
884+
// to focus after deleting.
885+
const connection = this.outputConnection ?? this.previousConnection;
886+
if (connection) {
887+
const targetConnection = connection.closest(
888+
0,
889+
new Coordinate(0, 0),
890+
).connection;
891+
parent = targetConnection?.getSourceBlock();
892+
}
893+
}
856894
if (parent) {
857895
focusManager.focusNode(parent);
858896
} else {

core/clipboard.ts

Lines changed: 110 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import {BlockCopyData, BlockPaster} from './clipboard/block_paster.js';
1010
import * as registry from './clipboard/registry.js';
1111
import type {ICopyData, ICopyable} from './interfaces/i_copyable.js';
12+
import {isSelectable} from './interfaces/i_selectable.js';
1213
import * as globalRegistry from './registry.js';
1314
import {Coordinate} from './utils/coordinate.js';
1415
import {WorkspaceSvg} from './workspace_svg.js';
@@ -18,18 +19,119 @@ let stashedCopyData: ICopyData | null = null;
1819

1920
let stashedWorkspace: WorkspaceSvg | null = null;
2021

22+
let stashedCoordinates: Coordinate | undefined = undefined;
23+
2124
/**
22-
* Private version of copy for stubbing in tests.
25+
* Copy a copyable item, and record its data and the workspace it was
26+
* copied from.
27+
*
28+
* This function does not perform any checks to ensure the copy
29+
* should be allowed, e.g. to ensure the block is deletable. Such
30+
* checks should be done before calling this function.
31+
*
32+
* Note that if the copyable item is not an `ISelectable` or its
33+
* `workspace` property is not a `WorkspaceSvg`, the copy will be
34+
* successful, but there will be no saved workspace data. This will
35+
* impact the ability to paste the data unless you explictily pass
36+
* a workspace into the paste method.
37+
*
38+
* @param toCopy item to copy.
39+
* @param location location to save as a potential paste location.
40+
* @returns the copied data if copy was successful, otherwise null.
2341
*/
24-
function copyInternal<T extends ICopyData>(toCopy: ICopyable<T>): T | null {
42+
export function copy<T extends ICopyData>(
43+
toCopy: ICopyable<T>,
44+
location?: Coordinate,
45+
): T | null {
2546
const data = toCopy.toCopyData();
2647
stashedCopyData = data;
27-
stashedWorkspace = (toCopy as any).workspace ?? null;
48+
if (isSelectable(toCopy) && toCopy.workspace instanceof WorkspaceSvg) {
49+
stashedWorkspace = toCopy.workspace;
50+
} else {
51+
stashedWorkspace = null;
52+
}
53+
54+
stashedCoordinates = location;
2855
return data;
2956
}
3057

3158
/**
32-
* Paste a pasteable element into the workspace.
59+
* Gets the copy data for the last item copied. This is useful if you
60+
* are implementing custom copy/paste behavior. If you want the default
61+
* behavior, just use the copy and paste methods directly.
62+
*
63+
* @returns copy data for the last item copied, or null if none set.
64+
*/
65+
export function getLastCopiedData() {
66+
return stashedCopyData;
67+
}
68+
69+
/**
70+
* Sets the last copied item. You should call this method if you implement
71+
* custom copy behavior, so that other callers are working with the correct
72+
* data. This method is called automatically if you use the built-in copy
73+
* method.
74+
*
75+
* @param copyData copy data for the last item copied.
76+
*/
77+
export function setLastCopiedData(copyData: ICopyData) {
78+
stashedCopyData = copyData;
79+
}
80+
81+
/**
82+
* Gets the workspace that was last copied from. This is useful if you
83+
* are implementing custom copy/paste behavior and want to paste on the
84+
* same workspace that was copied from. If you want the default behavior,
85+
* just use the copy and paste methods directly.
86+
*
87+
* @returns workspace that was last copied from, or null if none set.
88+
*/
89+
export function getLastCopiedWorkspace() {
90+
return stashedWorkspace;
91+
}
92+
93+
/**
94+
* Sets the workspace that was last copied from. You should call this method
95+
* if you implement custom copy behavior, so that other callers are working
96+
* with the correct data. This method is called automatically if you use the
97+
* built-in copy method.
98+
*
99+
* @param workspace workspace that was last copied from.
100+
*/
101+
export function setLastCopiedWorkspace(workspace: WorkspaceSvg) {
102+
stashedWorkspace = workspace;
103+
}
104+
105+
/**
106+
* Gets the location that was last copied from. This is useful if you
107+
* are implementing custom copy/paste behavior. If you want the
108+
* default behavior, just use the copy and paste methods directly.
109+
*
110+
* @returns last saved location, or null if none set.
111+
*/
112+
export function getLastCopiedLocation() {
113+
return stashedCoordinates;
114+
}
115+
116+
/**
117+
* Sets the location that was last copied from. You should call this method
118+
* if you implement custom copy behavior, so that other callers are working
119+
* with the correct data. This method is called automatically if you use the
120+
* built-in copy method.
121+
*
122+
* @param location last saved location, which can be used to paste at.
123+
*/
124+
export function setLastCopiedLocation(location: Coordinate) {
125+
stashedCoordinates = location;
126+
}
127+
128+
/**
129+
* Paste a pasteable element into the given workspace.
130+
*
131+
* This function does not perform any checks to ensure the paste
132+
* is allowed, e.g. that the workspace is rendered or the block
133+
* is pasteable. Such checks should be done before calling this
134+
* function.
33135
*
34136
* @param copyData The data to paste into the workspace.
35137
* @param workspace The workspace to paste the data into.
@@ -43,7 +145,7 @@ export function paste<T extends ICopyData>(
43145
): ICopyable<T> | null;
44146

45147
/**
46-
* Pastes the last copied ICopyable into the workspace.
148+
* Pastes the last copied ICopyable into the last copied-from workspace.
47149
*
48150
* @returns the pasted thing if the paste was successful, null otherwise.
49151
*/
@@ -65,7 +167,7 @@ export function paste<T extends ICopyData>(
65167
): ICopyable<ICopyData> | null {
66168
if (!copyData || !workspace) {
67169
if (!stashedCopyData || !stashedWorkspace) return null;
68-
return pasteFromData(stashedCopyData, stashedWorkspace);
170+
return pasteFromData(stashedCopyData, stashedWorkspace, stashedCoordinates);
69171
}
70172
return pasteFromData(copyData, workspace, coordinate);
71173
}
@@ -85,31 +187,11 @@ function pasteFromData<T extends ICopyData>(
85187
): ICopyable<T> | null {
86188
workspace = workspace.isMutator
87189
? workspace
88-
: (workspace.getRootWorkspace() ?? workspace);
190+
: // Use the parent workspace if it exists (e.g. for pasting into flyouts)
191+
(workspace.options.parentWorkspace ?? workspace);
89192
return (globalRegistry
90193
.getObject(globalRegistry.Type.PASTER, copyData.paster, false)
91194
?.paste(copyData, workspace, coordinate) ?? null) as ICopyable<T> | null;
92195
}
93196

94-
/**
95-
* Private version of duplicate for stubbing in tests.
96-
*/
97-
function duplicateInternal<
98-
U extends ICopyData,
99-
T extends ICopyable<U> & IHasWorkspace,
100-
>(toDuplicate: T): T | null {
101-
const data = toDuplicate.toCopyData();
102-
if (!data) return null;
103-
return paste(data, toDuplicate.workspace) as T;
104-
}
105-
106-
interface IHasWorkspace {
107-
workspace: WorkspaceSvg;
108-
}
109-
110-
export const TEST_ONLY = {
111-
duplicateInternal,
112-
copyInternal,
113-
};
114-
115197
export {BlockCopyData, BlockPaster, registry};

core/comments.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7+
export {CollapseCommentBarButton} from './comments/collapse_comment_bar_button.js';
8+
export {CommentBarButton} from './comments/comment_bar_button.js';
79
export {CommentEditor} from './comments/comment_editor.js';
810
export {CommentView} from './comments/comment_view.js';
11+
export {DeleteCommentBarButton} from './comments/delete_comment_bar_button.js';
912
export {RenderedWorkspaceComment} from './comments/rendered_workspace_comment.js';
1013
export {WorkspaceComment} from './comments/workspace_comment.js';
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import * as browserEvents from '../browser_events.js';
8+
import * as touch from '../touch.js';
9+
import * as dom from '../utils/dom.js';
10+
import {Svg} from '../utils/svg.js';
11+
import type {WorkspaceSvg} from '../workspace_svg.js';
12+
import {CommentBarButton} from './comment_bar_button.js';
13+
14+
/**
15+
* Magic string appended to the comment ID to create a unique ID for this button.
16+
*/
17+
export const COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER =
18+
'_collapse_bar_button';
19+
20+
/**
21+
* Button that toggles the collapsed state of a comment.
22+
*/
23+
export class CollapseCommentBarButton extends CommentBarButton {
24+
/**
25+
* Opaque ID used to unbind event handlers during disposal.
26+
*/
27+
private readonly bindId: browserEvents.Data;
28+
29+
/**
30+
* SVG image displayed on this button.
31+
*/
32+
protected override readonly icon: SVGImageElement;
33+
34+
/**
35+
* Creates a new CollapseCommentBarButton instance.
36+
*
37+
* @param id The ID of this button's parent comment.
38+
* @param workspace The workspace this button's parent comment is displayed on.
39+
* @param container An SVG group that this button should be a child of.
40+
*/
41+
constructor(
42+
protected readonly id: string,
43+
protected readonly workspace: WorkspaceSvg,
44+
protected readonly container: SVGGElement,
45+
) {
46+
super(id, workspace, container);
47+
48+
this.icon = dom.createSvgElement(
49+
Svg.IMAGE,
50+
{
51+
'class': 'blocklyFoldoutIcon',
52+
'href': `${this.workspace.options.pathToMedia}foldout-icon.svg`,
53+
'id': `${this.id}${COMMENT_COLLAPSE_BAR_BUTTON_FOCUS_IDENTIFIER}`,
54+
},
55+
this.container,
56+
);
57+
this.bindId = browserEvents.conditionalBind(
58+
this.icon,
59+
'pointerdown',
60+
this,
61+
this.performAction.bind(this),
62+
);
63+
}
64+
65+
/**
66+
* Disposes of this button.
67+
*/
68+
dispose() {
69+
browserEvents.unbind(this.bindId);
70+
}
71+
72+
/**
73+
* Adjusts the positioning of this button within its container.
74+
*/
75+
override reposition() {
76+
const margin = this.getMargin();
77+
this.icon.setAttribute('y', `${margin}`);
78+
this.icon.setAttribute('x', `${margin}`);
79+
}
80+
81+
/**
82+
* Toggles the collapsed state of the parent comment.
83+
*
84+
* @param e The event that triggered this action.
85+
*/
86+
override performAction(e?: Event) {
87+
touch.clearTouchIdentifier();
88+
89+
const comment = this.getParentComment();
90+
comment.view.bringToFront();
91+
if (e && e instanceof PointerEvent && browserEvents.isRightButton(e)) {
92+
e.stopPropagation();
93+
return;
94+
}
95+
96+
comment.setCollapsed(!comment.isCollapsed());
97+
this.workspace.hideChaff();
98+
99+
e?.stopPropagation();
100+
}
101+
}

0 commit comments

Comments
 (0)