Skip to content

Commit deac87c

Browse files
committed
fix a rather annoying collaboration behavior
1 parent a5cc6f7 commit deac87c

4 files changed

Lines changed: 216 additions & 35 deletions

File tree

‎src/addons/addons/collaboration/helpers/blocklyApplier.js‎

Lines changed: 96 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as constants from './constants.js';
2+
import * as recorder from './recorder.js';
23

34
/*
45
Reconciles individual blocks in the live Blockly workspace against the VM
@@ -8,6 +9,11 @@ hits anything it can't safely patch so the caller can fall back to a full
89
vm.emitWorkspaceUpdate() rebuild.
910
*/
1011

12+
function bail(where, blockId, extra) {
13+
recorder.record('applier.bail', Object.assign({where, block: blockId}, extra || {}));
14+
return false;
15+
}
16+
1117
function isObscuredShadow(target, vmBlock, blockId) {
1218
if (!vmBlock.shadow) return false;
1319
const parent = vmBlock.parent ? target.blocks.getBlock(vmBlock.parent) : null;
@@ -173,10 +179,10 @@ function reconcileState(Blockly, workspace, target, blockId) {
173179
return !!vmBlock.shadow;
174180
}
175181

176-
if (!!vmBlock.shadow !== wsBlock.isShadow()) return false;
182+
if (!!vmBlock.shadow !== wsBlock.isShadow()) return bail('shadowness', blockId);
177183

178-
if (!reconcileFields(target, vmBlock, wsBlock)) return false;
179-
if (!reconcileConnection(workspace, target, vmBlock, wsBlock, blockId)) return false;
184+
if (!reconcileFields(target, vmBlock, wsBlock)) return bail('fields', blockId);
185+
if (!reconcileConnection(workspace, target, vmBlock, wsBlock, blockId)) return bail('connection', blockId);
180186

181187
if (vmBlock.topLevel && !wsBlock.getParent()) {
182188
const x = Number(vmBlock.x);
@@ -198,15 +204,100 @@ export function reconcileBlocks(target, blockIds) {
198204
Blockly.Events.disable();
199205
try {
200206
for (const blockId of blockIds) {
201-
if (!reconcileExistence(Blockly, workspace, target, blockId)) return false;
207+
if (!reconcileExistence(Blockly, workspace, target, blockId)) return bail('existence', blockId);
202208
}
203209
for (const blockId of blockIds) {
204210
if (!reconcileState(Blockly, workspace, target, blockId)) return false;
205211
}
206212
return true;
207213
} catch (e) {
208214
if (constants.debugging) console.warn('Collaboration: incremental block sync failed, falling back to full refresh.', e);
209-
return false;
215+
return bail('threw', null, {message: String(e && e.message || e)});
216+
} finally {
217+
Blockly.Events.enable();
218+
}
219+
}
220+
221+
function commentText(wsComment) {
222+
if (typeof wsComment.getText === 'function') return wsComment.getText();
223+
if (typeof wsComment.getContent === 'function') return wsComment.getContent();
224+
return null;
225+
}
226+
227+
function commentMinimized(wsComment) {
228+
if (typeof wsComment.isMinimized === 'function') return !!wsComment.isMinimized();
229+
return !!wsComment.isMinimized_;
230+
}
231+
232+
function createComment(Blockly, workspace, vmComment) {
233+
if (vmComment.blockId) {
234+
const block = workspace.getBlockById(vmComment.blockId);
235+
if (!block) return null;
236+
if (block.comment) return block.comment.id === vmComment.id ? block.comment : null;
237+
block.setCommentText(String(vmComment.text == null ? '' : vmComment.text), vmComment.id,
238+
Number(vmComment.x), Number(vmComment.y), !!vmComment.minimized);
239+
return block.comment || null;
240+
}
241+
if (typeof vmComment.toXML !== 'function') return null;
242+
const dom = Blockly.Xml.textToDom(`<xml>${vmComment.toXML()}</xml>`);
243+
const commentDom = dom.firstElementChild || dom.firstChild;
244+
if (!commentDom) return null;
245+
return Blockly.WorkspaceCommentSvg.fromXml(commentDom, workspace);
246+
}
247+
248+
export function reconcileComments(target, commentIds) {
249+
const Blockly = constants.mutableRefs.BlocklyInstance;
250+
const workspace = constants.editorWorkspace();
251+
if (!workspace || !target || !target.comments) return false;
252+
// The VM holds a comment's position as an LTR workspace reports it; a mirrored workspace is
253+
// rebuilt, as it was.
254+
if (workspace.RTL) return bail('comments-rtl', null);
255+
Blockly.Events.disable();
256+
try {
257+
for (const id of commentIds) {
258+
const vmComment = target.comments[id];
259+
let wsComment = workspace.getCommentById(id);
260+
if (!vmComment) {
261+
if (wsComment) wsComment.dispose();
262+
continue;
263+
}
264+
if (!wsComment) {
265+
wsComment = createComment(Blockly, workspace, vmComment);
266+
if (!wsComment) return bail('comment-create', id);
267+
}
268+
if (vmComment.blockId) {
269+
const block = workspace.getBlockById(vmComment.blockId);
270+
if (!block || block.comment !== wsComment) return bail('comment-block', id);
271+
}
272+
273+
const text = String(vmComment.text == null ? '' : vmComment.text);
274+
// Not while this person is typing in it: their text is published when they blur, and
275+
// the room settles it then. Overwriting the textarea under them is the report.
276+
const typingHere = !!wsComment.textarea_ && document.activeElement === wsComment.textarea_;
277+
if (!typingHere && commentText(wsComment) !== text) wsComment.setText(text);
278+
279+
const minimized = !!vmComment.minimized;
280+
if (commentMinimized(wsComment) !== minimized) wsComment.setMinimized(minimized);
281+
282+
const width = Number(vmComment.width);
283+
const height = Number(vmComment.height);
284+
if (!minimized && isFinite(width) && isFinite(height)) {
285+
const size = wsComment.getHeightWidth();
286+
if (Math.abs(size.width - width) > 0.5 || Math.abs(size.height - height) > 0.5) {
287+
wsComment.setSize(width, height);
288+
}
289+
}
290+
291+
const x = Number(vmComment.x);
292+
const y = Number(vmComment.y);
293+
if (isFinite(x) && isFinite(y)) {
294+
const xy = wsComment.getXY();
295+
if (Math.abs(xy.x - x) > 0.5 || Math.abs(xy.y - y) > 0.5) wsComment.moveTo(x, y);
296+
}
297+
}
298+
return true;
299+
} catch (e) {
300+
return bail('comments-threw', null, {message: String(e && e.message || e)});
210301
} finally {
211302
Blockly.Events.enable();
212303
}

‎src/addons/addons/collaboration/helpers/observeHandlers.js‎

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -321,33 +321,42 @@ export function sharedMonitors (event) {
321321
}
322322
}
323323

324-
export function sharedComments (event){
325-
let needsWorkspaceRefresh = false;
324+
/*
325+
Applies a comments change to the VM and says which comments of which target it touched, so the
326+
scheduler can patch exactly those on the workspace (blocklyApplier.reconcileComments) instead of
327+
rebuilding it.
328+
*/
329+
export function sharedComments (event) {
330+
const touched = new Map();
331+
const note = (targetId, commentId) => {
332+
if (!touched.has(targetId)) touched.set(targetId, []);
333+
touched.get(targetId).push(commentId);
334+
};
326335
const path = event.path;
327336

328337
if (path.length === 0) {
329338
event.changes.keys.forEach((change, targetId) => {
330-
if (change.action === 'add') {
331-
const target = constants.mutableRefs.vm.runtime.getTargetById(targetId);
332-
if (!target) return;
333-
const yTargetCommentMap = event.target.get(targetId);
334-
yTargetCommentMap.forEach((yCommentMap, commentId) => {
335-
const commentData = helper.deserializeCommentFromYjs(yCommentMap);
336-
if (!target.comments[commentId]) {
337-
target.createComment(
338-
commentData.id, commentData.blockId, commentData.text,
339-
commentData.x, commentData.y, commentData.width,
340-
commentData.height, commentData.minimized, true
341-
);
342-
needsWorkspaceRefresh = true;
343-
}
344-
});
345-
}
339+
if (change.action === 'delete') return;
340+
const target = constants.mutableRefs.vm.runtime.getTargetById(targetId);
341+
if (!target) return;
342+
const yTargetCommentMap = event.target.get(targetId);
343+
if (!yTargetCommentMap) return;
344+
yTargetCommentMap.forEach((yCommentMap, commentId) => {
345+
const commentData = helper.deserializeCommentFromYjs(yCommentMap);
346+
if (!target.comments[commentId]) {
347+
target.createComment(
348+
commentData.id, commentData.blockId, commentData.text,
349+
commentData.x, commentData.y, commentData.width,
350+
commentData.height, commentData.minimized, true
351+
);
352+
note(targetId, commentId);
353+
}
354+
});
346355
});
347356
} else if (path.length === 1) {
348357
const targetId = path[0];
349358
const target = constants.mutableRefs.vm.runtime.getTargetById(targetId);
350-
if (!target) return needsWorkspaceRefresh;
359+
if (!target) return touched;
351360

352361
event.changes.keys.forEach((change, commentId) => {
353362
if (change.action === 'add' || change.action === 'update') {
@@ -374,17 +383,17 @@ export function sharedComments (event){
374383
}
375384
delete target.comments[commentId];
376385
}
386+
note(targetId, commentId);
377387
});
378-
needsWorkspaceRefresh = true;
379388
} else if (path.length === 2) {
380389
const [targetId, commentId] = path;
381390
const target = constants.mutableRefs.vm.runtime.getTargetById(targetId);
382-
if (!target || !target.comments[commentId]) return needsWorkspaceRefresh;
391+
if (!target || !target.comments[commentId]) return touched;
383392

384393
event.changes.keys.forEach((change, key) => {
385394
target.comments[commentId][key] = event.target.get(key);
386395
});
387-
needsWorkspaceRefresh = true;
396+
note(targetId, commentId);
388397
}
389-
return needsWorkspaceRefresh;
398+
return touched;
390399
}

‎src/addons/addons/collaboration/helpers/refreshScheduler.js‎

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import * as constants from './constants.js';
22
import * as applier from './blocklyApplier.js';
33
import * as helper from './helper.js';
4+
import * as recorder from './recorder.js';
45

56
const FLUSH_DELAY_MS = 80;
67
const MAX_WAIT_MS = 600;
78
const DRAG_RETRY_MS = 200;
89

910
const dirtyBlocksByTarget = new Map();
11+
const dirtyCommentsByTarget = new Map();
1012
const repairPending = new Set();
1113
let fullRefreshPending = false;
1214
let toolboxRefreshPending = false;
@@ -17,13 +19,55 @@ function getWorkspace() {
1719
return constants.editorWorkspace();
1820
}
1921

22+
function holdInHand(Blockly, workspace) {
23+
const held = {selectedId: Blockly.selected ? Blockly.selected.id : null, field: null};
24+
const owner = Blockly.WidgetDiv.owner_;
25+
const input = Blockly.FieldTextInput ? Blockly.FieldTextInput.htmlInput_ : null;
26+
if (Blockly.WidgetDiv.isVisible() && input && owner && owner.sourceBlock_ && owner.name &&
27+
owner.sourceBlock_.workspace === workspace) {
28+
held.field = {
29+
blockId: owner.sourceBlock_.id,
30+
name: owner.name,
31+
value: input.value,
32+
start: input.selectionStart,
33+
end: input.selectionEnd
34+
};
35+
}
36+
return held;
37+
}
38+
39+
function giveBack(Blockly, workspace, held) {
40+
if (!workspace || !held) return;
41+
if (held.selectedId && (!Blockly.selected || Blockly.selected.id !== held.selectedId)) {
42+
const block = workspace.getBlockById(held.selectedId);
43+
if (block && typeof block.select === 'function') block.select();
44+
}
45+
if (!held.field) return;
46+
const block = workspace.getBlockById(held.field.blockId);
47+
const field = block ? block.getField(held.field.name) : null;
48+
if (!field || typeof field.showEditor_ !== 'function' || Blockly.WidgetDiv.isVisible()) return;
49+
try {
50+
field.showEditor_();
51+
const input = Blockly.FieldTextInput.htmlInput_;
52+
if (!input) return;
53+
if (input.value !== held.field.value) input.value = held.field.value;
54+
const length = input.value.length;
55+
const start = Math.min(held.field.start == null ? length : held.field.start, length);
56+
const end = Math.min(held.field.end == null ? length : held.field.end, length);
57+
input.setSelectionRange(start, end);
58+
} catch (e) {
59+
}
60+
}
61+
2062
export function rebuildWorkspace() {
2163
const vm = constants.mutableRefs.vm;
2264
if (!vm) return;
65+
const Blockly = constants.mutableRefs.BlocklyInstance;
2366
const workspace = getWorkspace();
2467
const targetBefore = vm.editingTarget ? vm.editingTarget.id : null;
2568
const undoStack = workspace ? workspace.undoStack_.slice() : null;
2669
const redoStack = workspace ? workspace.redoStack_.slice() : null;
70+
const held = (workspace && Blockly) ? holdInHand(Blockly, workspace) : null;
2771

2872
vm.emitWorkspaceUpdate();
2973

@@ -32,6 +76,7 @@ export function rebuildWorkspace() {
3276
workspace.undoStack_ = undoStack;
3377
workspace.redoStack_ = redoStack;
3478
}
79+
if (held && targetAfter === targetBefore) giveBack(Blockly, getWorkspace(), held);
3580
}
3681

3782
function claimsChild(parent, childId) {
@@ -82,7 +127,25 @@ export function queueBlockSync(targetId, blockIds) {
82127
arm();
83128
}
84129

130+
export function queueCommentSync(targetId, commentIds) {
131+
if (!targetId || !commentIds || commentIds.length === 0) return;
132+
const editingTarget = constants.mutableRefs.vm?.editingTarget;
133+
if (!editingTarget || editingTarget.id !== targetId) return;
134+
let set = dirtyCommentsByTarget.get(targetId);
135+
if (!set) {
136+
set = new Set();
137+
dirtyCommentsByTarget.set(targetId, set);
138+
}
139+
commentIds.forEach(id => set.add(id));
140+
arm();
141+
}
142+
85143
export function queueFullRefresh() {
144+
if (!fullRefreshPending && recorder.isRecording()) {
145+
recorder.record('refresh.queued', {
146+
via: new Error().stack.split('\n').slice(2, 5).map(line => line.trim()).join(' < ')
147+
});
148+
}
86149
fullRefreshPending = true;
87150
arm();
88151
}
@@ -113,6 +176,7 @@ function flush() {
113176
}
114177

115178
firstQueuedAt = 0;
179+
const why = {queued: fullRefreshPending, repairs: repairPending.size, attachments: false, applier: false, comments: false};
116180
let needFull = fullRefreshPending;
117181
fullRefreshPending = false;
118182
const toRepair = [...repairPending];
@@ -121,6 +185,8 @@ function flush() {
121185
toolboxRefreshPending = false;
122186
const dirty = new Map(dirtyBlocksByTarget);
123187
dirtyBlocksByTarget.clear();
188+
const dirtyComments = new Map(dirtyCommentsByTarget);
189+
dirtyCommentsByTarget.clear();
124190

125191
const previousGroup = Blockly.Events.getGroup();
126192
Blockly.Events.setGroup('yjs-remote-sync');
@@ -143,14 +209,28 @@ function flush() {
143209
if (!needFull && dirty.size > 0 && editingTarget) {
144210
const dirtyIds = dirty.get(editingTarget.id);
145211
if (dirtyIds && dirtyIds.size > 0) {
146-
if (attachmentsDisagree(editingTarget, dirtyIds) ||
147-
!applier.reconcileBlocks(editingTarget, dirtyIds)) {
212+
if (attachmentsDisagree(editingTarget, dirtyIds)) {
213+
why.attachments = true;
214+
needFull = true;
215+
} else if (!applier.reconcileBlocks(editingTarget, dirtyIds)) {
216+
why.applier = true;
148217
needFull = true;
149218
}
150219
}
151220
}
152221

222+
if (!needFull && dirtyComments.size > 0 && editingTarget) {
223+
const commentIds = dirtyComments.get(editingTarget.id);
224+
if (commentIds && commentIds.size > 0 && !applier.reconcileComments(editingTarget, commentIds)) {
225+
why.comments = true;
226+
needFull = true;
227+
}
228+
}
229+
153230
if (needFull && editingTarget) {
231+
recorder.record('refresh.full', Object.assign(why, {
232+
dirty: editingTarget && dirty.get(editingTarget.id) ? [...dirty.get(editingTarget.id)] : []
233+
}));
154234
const repairedByTarget = new Map();
155235
vm.runtime.targets.forEach(target => {
156236
if (!target.isOriginal) return;
@@ -187,4 +267,5 @@ export function reset() {
187267
fullRefreshPending = false;
188268
toolboxRefreshPending = false;
189269
dirtyBlocksByTarget.clear();
270+
dirtyCommentsByTarget.clear();
190271
}

‎src/addons/addons/collaboration/userscript.js‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -455,12 +455,12 @@ function attachYjsProvider() {
455455
return;
456456
}
457457

458-
if (OH.sharedComments(event)) {
459-
const editingTargetId = constants.mutableRefs.vm.editingTarget?.id;
460-
if (!targetId || targetId === editingTargetId) {
461-
scheduler.queueFullRefresh();
458+
const editingTargetId = constants.mutableRefs.vm.editingTarget?.id;
459+
OH.sharedComments(event).forEach((commentIds, touchedTargetId) => {
460+
if (touchedTargetId === editingTargetId) {
461+
scheduler.queueCommentSync(touchedTargetId, commentIds);
462462
}
463-
}
463+
});
464464
});
465465
} catch (e) {
466466
console.warn('[collaboration] comment sync failed', e);

0 commit comments

Comments
 (0)