From c0bc7a207d8bf1362affe0f0296e9f10d5163222 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 9 May 2026 07:24:07 +0900 Subject: [PATCH 01/11] fix(dncl): correct multi-arg display, suppress furigana in DNCL, hide @ruby metadata bubbles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ruby→Blocks: chained `+` of `operator_join` results now produces another `operator_join` instead of `operator_add`, so DNCL `表示する(i, " ", Data[i])` actually shows all three arguments. - DNCL mode: furigana annotations are skipped when the editor shows Japanese pseudo-code (initial state respects localStorage; render paths guard on `dnclModeRef`; mode toggle clears/restores). - Block workspace: Blockly v12 renders attached collapsed comments as visible bars, which cluttered the workspace with `@ruby:*` metadata (e.g. 11 bars stacked above the first block for a 10-element array literal). Hide metadata-only comments via injected CSS, and reorder user-merged comments to put user text first so the collapsed preview bar shows the user's text. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../scratch-gui/src/containers/ruby-tab.jsx | 21 +++++- .../comment-handler.js | 18 +++-- .../operators-math.js | 7 ++ .../lib/scratch-blocks-comment-icon-patch.js | 75 +++++++++++++++++++ 4 files changed, 115 insertions(+), 6 deletions(-) diff --git a/packages/scratch-gui/src/containers/ruby-tab.jsx b/packages/scratch-gui/src/containers/ruby-tab.jsx index 8a8a3b91fee..22dd94cbe92 100644 --- a/packages/scratch-gui/src/containers/ruby-tab.jsx +++ b/packages/scratch-gui/src/containers/ruby-tab.jsx @@ -136,6 +136,12 @@ const RubyTab = (props) => { const urlRubyMode = getUrlParams().rubyMode; if (urlRubyMode === 'furigana') return true; if (urlRubyMode === 'ruby' || urlRubyMode === 'dncl') return false; + // DNCL mode shows Japanese pseudo-code, not Ruby — furigana annotations + // are meaningless and visually distracting in that view. Force off. + if (typeof window !== 'undefined' && window.localStorage && + window.localStorage.getItem(DNCL_MODE_KEY) === 'true') { + return false; + } return loadBool(FURIGANA_ENABLED_KEY, true); }); const [autoCorrectEnabled, setAutoCorrectEnabled] = useState(() => loadBool(AUTO_CORRECT_ENABLED_KEY, true)); @@ -251,6 +257,9 @@ const RubyTab = (props) => { const renderFurigana = () => { if (!editorRef.current || !monacoRef.current) return; + // Furigana annotations target Ruby source; in DNCL mode the editor + // shows Japanese pseudo-code, so suppress rendering entirely. + if (dnclModeRef.current) return; const code = editorRef.current.getValue() || ''; const prism = getPrism(); if (prism) { @@ -262,6 +271,7 @@ const RubyTab = (props) => { } else { loadPrism().then((loadedPrism) => { if (!furiganaEnabledRef.current) return; + if (dnclModeRef.current) return; if (!editorRef.current || !monacoRef.current) return; const currentCode = editorRef.current.getValue() || ''; const t0 = performance.now(); @@ -280,7 +290,7 @@ const RubyTab = (props) => { const delay = Math.max(50, furiganaLastMsRef.current * 2); furiganaDebounceTimerRef.current = setTimeout(() => { furiganaDebounceTimerRef.current = null; - if (furiganaEnabledRef.current) { + if (furiganaEnabledRef.current && !dnclModeRef.current) { renderFurigana(); } }, delay); @@ -639,6 +649,15 @@ const RubyTab = (props) => { isModeSwitchRef.current = false; setDnclMode(enabling); onSetDnclMode(enabling); + + // Furigana is meaningless in DNCL view (Japanese pseudo-code, not Ruby). + // Clear annotations on enable; restore them on disable when the user + // had furigana on. + if (enabling) { + furiganaRendererRef.current?.clear(editorRef.current); + } else if (furiganaEnabledRef.current) { + renderFurigana(); + } }, [vm, rubyCode.target, intl, rubyVersion, dnclValidationErrorMessage, onSetDnclMode]); const handleToggleFurigana = useCallback(() => { diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/comment-handler.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/comment-handler.js index c34b9f1088b..d32f32cbf7d 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/comment-handler.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/comment-handler.js @@ -234,21 +234,29 @@ const CommentHandler = { return; } - const inlineMarker = isInline ? '@ruby:comment_position:inline\n' : ''; + // Place the user text FIRST, with any `@ruby:` metadata appended on + // following lines. Blockly v12 renders the collapsed comment as a + // single-line bar showing the first line — putting user text first + // keeps that bar readable. The Ruby generator's scrub_() filters + // by full-line equality / `startsWith('@ruby:')`, so line order + // doesn't affect round-tripping. (See ruby-generator/scrub.js.) + const inlineMarker = isInline ? '@ruby:comment_position:inline' : ''; if (block.comment) { // Block already has a comment - merge const existingComment = this._context.comments[block.comment]; if (existingComment) { - // Put user text before metadata lines const lines = existingComment.text.split('\n'); const metadataLines = lines.filter(l => l.startsWith('@ruby:')); - const newText = `${inlineMarker}${userText}\n${metadataLines.join('\n')}`; - existingComment.text = newText; + if (isInline && !metadataLines.includes(inlineMarker)) { + metadataLines.unshift(inlineMarker); + } + const trailing = metadataLines.length > 0 ? `\n${metadataLines.join('\n')}` : ''; + existingComment.text = `${userText}${trailing}`; } } else { // Create new comment for this block - const commentText = isInline ? `${inlineMarker}${userText}` : userText; + const commentText = isInline ? `${userText}\n${inlineMarker}` : userText; block.comment = this._createComment(commentText, block.id); } } diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/operators-math.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/operators-math.js index ef9421bc6f3..9f83eb50c1b 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/operators-math.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/operators-math.js @@ -54,6 +54,13 @@ const OperatorsMath = { if (variable && variable.dataType === 'string') return null; } + // Skip when receiver is itself a string-result block (operator_join): + // a chained `+` like `(a.to_s + " ") + b.to_s` is string concatenation, + // not arithmetic. Defer to the dedicated string `+` handler. + if (operator === '+' && converter._isBlock(receiver) && receiver.opcode === 'operator_join') { + return null; + } + // Boolean values are not valid operands for numeric arithmetic if (converter._isTrue(rh) || converter._isFalse(rh)) return null; diff --git a/packages/scratch-gui/src/lib/scratch-blocks-comment-icon-patch.js b/packages/scratch-gui/src/lib/scratch-blocks-comment-icon-patch.js index a746f49c178..782e4aae524 100644 --- a/packages/scratch-gui/src/lib/scratch-blocks-comment-icon-patch.js +++ b/packages/scratch-gui/src/lib/scratch-blocks-comment-icon-patch.js @@ -36,6 +36,66 @@ * `Smalruby: toXML modernization` block in comment.js for details. */ +/** + * Inject a global CSS rule that hides Blockly comment SVG nodes carrying + * the Smalruby metadata marker (`data-smalruby-meta="true"`). The rule is + * appended once to so it covers comments created later (the patched + * icon below stamps the marker after fireCreateEvent / setText). + * + * Smalruby's converter attaches comments such as `@ruby:method:to_s` to + * blocks so the generator can round-trip them back to Ruby. In Blockly + * v11 these were tiny icons — invisible noise. Blockly v12 renders the + * collapsed state as a horizontal bar with the comment text, which clutters + * the workspace (e.g. an array literal of 10 elements produces 11 stacked + * bars). The comment data must remain on the block for round-tripping; + * only the visual is suppressed. + */ +const ensureMetaCommentHideStyle = function () { + if (typeof document === 'undefined') return; + if (document.getElementById('smalruby-hide-meta-comments')) return; + const style = document.createElement('style'); + style.id = 'smalruby-hide-meta-comments'; + style.textContent = 'g.blocklyComment[data-smalruby-meta="true"]{display:none!important;}'; + document.head.appendChild(style); +}; + +/** + * Stamp `data-smalruby-meta="true"` onto the bubble's SVG group when the + * comment text is Smalruby internal metadata (`@ruby:` prefix). The CSS + * rule installed by `ensureMetaCommentHideStyle` does the actual hiding. + * Safe to call multiple times — sets / clears the attribute based on + * the current text. + * @param {object} bubble - The Blockly comment bubble + * @param {string} text - The current comment text + */ +const isMetadataOnly = function (text) { + if (typeof text !== 'string' || text.length === 0) return false; + // A comment is metadata-only when every non-empty line starts with `@ruby:`. + // User comments may be merged with an inline marker (e.g. + // `@ruby:comment_position:inline\n`) — those lines after the + // marker do NOT start with `@ruby:`, so the comment stays visible. + const lines = text.split('\n').map((l) => l.trim()).filter((l) => l.length > 0); + if (lines.length === 0) return false; + return lines.every((l) => l.startsWith('@ruby:')); +}; + +const applyMetaMarkerToBubble = function (bubble, text) { + if (!bubble) return; + const isMeta = isMetadataOnly(text); + const candidates = []; + if (typeof bubble.getSvgRoot === 'function') candidates.push(bubble.getSvgRoot()); + if (bubble.svgRoot_) candidates.push(bubble.svgRoot_); + if (bubble.svgRoot) candidates.push(bubble.svgRoot); + for (const el of candidates) { + if (!el || !el.setAttribute) continue; + if (isMeta) { + el.setAttribute('data-smalruby-meta', 'true'); + } else { + el.removeAttribute('data-smalruby-meta'); + } + } +}; + /** * Install the ScratchCommentIcon patch. Idempotent. * Survives minification because: @@ -47,6 +107,7 @@ * @param {object} ScratchBlocks - the scratch-blocks module (Blockly v12 + scratch additions) */ export const installCommentIconPatch = function (ScratchBlocks) { + ensureMetaCommentHideStyle(); if (!ScratchBlocks || !ScratchBlocks.registry || !ScratchBlocks.icons) return; const Type = ScratchBlocks.registry.Type; const IconType = ScratchBlocks.icons.IconType; @@ -149,12 +210,26 @@ export const installCommentIconPatch = function (ScratchBlocks) { const CollapseEvent = Events.get('block_comment_collapse'); if (CollapseEvent) Events.fire(new CollapseEvent(bubble, true)); } + applyMetaMarkerToBubble(bubble, text); } catch (e) { // eslint-disable-next-line no-console console.warn('[smalruby] PatchedCommentIcon.fireCreateEvent state refire failed:', e); } return result; } + + // Re-apply the meta marker on text change so a comment that is + // edited away from `@ruby:...` becomes visible (and vice versa). + setText(text) { + const result = super.setText(text); + try { + const bubble = typeof this.getBubble === 'function' ? this.getBubble() : null; + applyMetaMarkerToBubble(bubble, text); + } catch (_e) { + // non-fatal + } + return result; + } } PatchedCommentIcon.__smalrubyCommentIconPatched = true; From 287da833c71f356ede7b1d1d0a16a8a07483aa04 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 9 May 2026 07:30:56 +0900 Subject: [PATCH 02/11] style: apply prettier formatting --- packages/scratch-gui/src/containers/ruby-tab.jsx | 7 +++++-- .../src/lib/scratch-blocks-comment-icon-patch.js | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/scratch-gui/src/containers/ruby-tab.jsx b/packages/scratch-gui/src/containers/ruby-tab.jsx index 22dd94cbe92..03b75e411fc 100644 --- a/packages/scratch-gui/src/containers/ruby-tab.jsx +++ b/packages/scratch-gui/src/containers/ruby-tab.jsx @@ -138,8 +138,11 @@ const RubyTab = (props) => { if (urlRubyMode === 'ruby' || urlRubyMode === 'dncl') return false; // DNCL mode shows Japanese pseudo-code, not Ruby — furigana annotations // are meaningless and visually distracting in that view. Force off. - if (typeof window !== 'undefined' && window.localStorage && - window.localStorage.getItem(DNCL_MODE_KEY) === 'true') { + if ( + typeof window !== 'undefined' && + window.localStorage && + window.localStorage.getItem(DNCL_MODE_KEY) === 'true' + ) { return false; } return loadBool(FURIGANA_ENABLED_KEY, true); diff --git a/packages/scratch-gui/src/lib/scratch-blocks-comment-icon-patch.js b/packages/scratch-gui/src/lib/scratch-blocks-comment-icon-patch.js index 782e4aae524..4e7f8154633 100644 --- a/packages/scratch-gui/src/lib/scratch-blocks-comment-icon-patch.js +++ b/packages/scratch-gui/src/lib/scratch-blocks-comment-icon-patch.js @@ -74,7 +74,10 @@ const isMetadataOnly = function (text) { // User comments may be merged with an inline marker (e.g. // `@ruby:comment_position:inline\n`) — those lines after the // marker do NOT start with `@ruby:`, so the comment stays visible. - const lines = text.split('\n').map((l) => l.trim()).filter((l) => l.length > 0); + const lines = text + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.length > 0); if (lines.length === 0) return false; return lines.every((l) => l.startsWith('@ruby:')); }; From 252611805a1039338a055fff50de96b5d3866c5f Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 9 May 2026 13:59:07 +0900 Subject: [PATCH 03/11] fix(converter): position attached comments near their topLevel block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attached comment x/y was always (0,0) by default. Blockly v11 ignored saved values and auto-positioned bubbles at the block anchor, so this went unnoticed. Blockly v12 treats x/y as workspace absolute coordinates, so every metadata bubble would render at the workspace origin in any viewer that doesn't apply the Smalruby hide-CSS — most notably scratch.mit.edu when opening a Smalruby-saved .sb3. Walk each comment's blockId up to the topLevel ancestor and arrange the comments in a vertical strip 350 px to the right of that block, 35 px apart. Value-block-attached comments use the same logic via the parent chain. User comments share the same arrangement so they sit next to the block they describe. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../target-applier.js | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/target-applier.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/target-applier.js index 04fdaa63703..6ad84bc6dca 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/target-applier.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/target-applier.js @@ -195,6 +195,45 @@ const TargetApplier = { target.blocks.createBlock(this._context.blocks[blockId]); }); + // === Smalruby: distribute attached comment x/y near their topLevel + // parent block. Blockly v12 treats stored x/y as workspace absolute + // coordinates, so leaving them at the converter's default (0, 0) + // makes every bubble stack at the workspace origin in any viewer + // that doesn't apply Smalruby's metadata-hide CSS (e.g. + // scratch.mit.edu). Group comments by their topLevel ancestor and + // arrange them in a vertical strip to the right of that block. === + const ANCHOR_DX = 350; + const ANCHOR_DY_STEP = 35; + const findTopLevel = (startBlockId) => { + const blocks = this._context.blocks; + let cur = blocks[startBlockId]; + let guard = 0; + while (cur && !cur.topLevel && cur.parent && guard < 1000) { + cur = blocks[cur.parent]; + guard++; + } + return cur && cur.topLevel ? cur : null; + }; + const commentsByTopLevel = new Map(); + Object.keys(this._context.comments).forEach(commentId => { + const comment = this._context.comments[commentId]; + if (!comment.blockId) return; // workspace-level comments untouched + const top = findTopLevel(comment.blockId); + if (!top) return; + const list = commentsByTopLevel.get(top.id) || []; + list.push(comment); + commentsByTopLevel.set(top.id, list); + }); + commentsByTopLevel.forEach((list, topId) => { + const top = this._context.blocks[topId]; + const baseX = (top && typeof top.x === 'number' ? top.x : 0) + ANCHOR_DX; + const baseY = (top && typeof top.y === 'number' ? top.y : 0); + list.forEach((comment, idx) => { + comment.x = baseX; + comment.y = baseY + idx * ANCHOR_DY_STEP; + }); + }); + Object.keys(this._context.comments).forEach(commentId => { const comment = this._context.comments[commentId]; target.createComment( From 76435c70f7c5ac40a2193fc55b9cc68ff76ab922 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 9 May 2026 14:04:08 +0900 Subject: [PATCH 04/11] chore: temporarily disable @ruby meta-comment CSS hide for verification Disable the CSS rule that hides metadata bubbles so we can visually verify the new x/y positioning (which is what scratch.mit.edu will show when opening a Smalruby-saved sb3). Will be reverted before merging. --- .../scratch-gui/src/lib/scratch-blocks-comment-icon-patch.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/scratch-gui/src/lib/scratch-blocks-comment-icon-patch.js b/packages/scratch-gui/src/lib/scratch-blocks-comment-icon-patch.js index 4e7f8154633..3fc96babcdd 100644 --- a/packages/scratch-gui/src/lib/scratch-blocks-comment-icon-patch.js +++ b/packages/scratch-gui/src/lib/scratch-blocks-comment-icon-patch.js @@ -55,7 +55,10 @@ const ensureMetaCommentHideStyle = function () { if (document.getElementById('smalruby-hide-meta-comments')) return; const style = document.createElement('style'); style.id = 'smalruby-hide-meta-comments'; - style.textContent = 'g.blocklyComment[data-smalruby-meta="true"]{display:none!important;}'; + // TEMPORARY: CSS hide disabled so we can visually verify bubble x/y + // placement (matches how scratch.mit.edu would render the saved sb3). + // Re-enable before merging. + style.textContent = '/* g.blocklyComment[data-smalruby-meta="true"]{display:none!important;} */'; document.head.appendChild(style); }; From d70612c62640fb2ff0d28d6d013c35df3f33e28d Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 9 May 2026 23:48:40 +0900 Subject: [PATCH 05/11] Revert "fix(converter): position attached comments near their topLevel block" This reverts commit 252611805a1039338a055fff50de96b5d3866c5f. --- .../target-applier.js | 39 ------------------- 1 file changed, 39 deletions(-) diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/target-applier.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/target-applier.js index 6ad84bc6dca..04fdaa63703 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/target-applier.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/target-applier.js @@ -195,45 +195,6 @@ const TargetApplier = { target.blocks.createBlock(this._context.blocks[blockId]); }); - // === Smalruby: distribute attached comment x/y near their topLevel - // parent block. Blockly v12 treats stored x/y as workspace absolute - // coordinates, so leaving them at the converter's default (0, 0) - // makes every bubble stack at the workspace origin in any viewer - // that doesn't apply Smalruby's metadata-hide CSS (e.g. - // scratch.mit.edu). Group comments by their topLevel ancestor and - // arrange them in a vertical strip to the right of that block. === - const ANCHOR_DX = 350; - const ANCHOR_DY_STEP = 35; - const findTopLevel = (startBlockId) => { - const blocks = this._context.blocks; - let cur = blocks[startBlockId]; - let guard = 0; - while (cur && !cur.topLevel && cur.parent && guard < 1000) { - cur = blocks[cur.parent]; - guard++; - } - return cur && cur.topLevel ? cur : null; - }; - const commentsByTopLevel = new Map(); - Object.keys(this._context.comments).forEach(commentId => { - const comment = this._context.comments[commentId]; - if (!comment.blockId) return; // workspace-level comments untouched - const top = findTopLevel(comment.blockId); - if (!top) return; - const list = commentsByTopLevel.get(top.id) || []; - list.push(comment); - commentsByTopLevel.set(top.id, list); - }); - commentsByTopLevel.forEach((list, topId) => { - const top = this._context.blocks[topId]; - const baseX = (top && typeof top.x === 'number' ? top.x : 0) + ANCHOR_DX; - const baseY = (top && typeof top.y === 'number' ? top.y : 0); - list.forEach((comment, idx) => { - comment.x = baseX; - comment.y = baseY + idx * ANCHOR_DY_STEP; - }); - }); - Object.keys(this._context.comments).forEach(commentId => { const comment = this._context.comments[commentId]; target.createComment( From b60f16ee140cec5deaef604a24afac481c01cbdf Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sat, 9 May 2026 23:54:29 +0900 Subject: [PATCH 06/11] fix(converter): position attached comments at their anchor block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the previous (rejected) approach that placed every comment sequentially below the topLevel block. Instead, walk each comment's blockId up the parent chain to the nearest non-value (statement/hat) block, count its hops from the topLevel ancestor, and seed the comment's x/y at (topLevel.x + 350, topLevel.y + depth * 48). Why this matches Blockly's natural anchor: - Statement-block comments land near the matching block in the chain (e.g. the 11 array-literal annotations no longer share a single y). - Value-block comments inherit the position of their enclosing statement, which is where Blockly would render them anyway. - Multiple comments anchored to the same statement still cluster, but near that block — not at the workspace origin. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../target-applier.js | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/target-applier.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/target-applier.js index 04fdaa63703..37933aa9c3b 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/target-applier.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/target-applier.js @@ -195,6 +195,69 @@ const TargetApplier = { target.blocks.createBlock(this._context.blocks[blockId]); }); + // === Smalruby: align attached comment x/y with their anchor + // block so the saved sb3 places bubbles where they visually + // appear in Smalruby. + // + // Background: Blockly v12 treats `target.comments[id].x/y` as + // workspace absolute coordinates. The converter creates fresh + // comments at the default (0, 0). Blockly v12 renders bubbles at + // each block's natural anchor in the workspace, but does not + // write that natural anchor back to the VM data model — so a + // .sb3 export keeps (0, 0) for every bubble. Reopening the file + // in any viewer that doesn't apply Smalruby's metadata-hide + // CSS (most importantly scratch.mit.edu) then stacks every + // bubble at the workspace origin. + // + // Approximate the anchor: walk up the parent chain to the + // nearest non-value block (so a comment on `operator_join` + // inherits the position of its enclosing `looks_sayforsecs`), + // count the hops from the topLevel ancestor, and emit a + // (topLevel.x + offset, topLevel.y + depth * step) coordinate. + // Multiple comments anchored to the same non-value block + // cluster at the same y; that matches what Blockly already + // renders for stacked value-block annotations. === + const ANCHOR_DX = 350; + const ANCHOR_DY_STEP = 48; + const blockTypes = this._context.blockTypes || {}; + const isValueType = (t) => t && t.indexOf('value') === 0; + const findAnchorBlock = (startId) => { + const blocks = this._context.blocks; + let cur = blocks[startId]; + let guard = 0; + while (cur && guard < 500) { + const t = blockTypes[cur.id]; + if (!isValueType(t)) return cur; + if (!cur.parent) return cur; + cur = blocks[cur.parent]; + guard++; + } + return cur || null; + }; + const findTopLevelAndDepth = (startBlock) => { + const blocks = this._context.blocks; + let cur = startBlock; + let depth = 0; + let guard = 0; + while (cur && !cur.topLevel && cur.parent && guard < 500) { + cur = blocks[cur.parent]; + if (!isValueType(blockTypes[cur?.id])) depth++; + guard++; + } + return { topLevel: cur && cur.topLevel ? cur : null, depth }; + }; + Object.values(this._context.comments).forEach(comment => { + if (!comment.blockId) return; + const anchor = findAnchorBlock(comment.blockId); + if (!anchor) return; + const { topLevel, depth } = findTopLevelAndDepth(anchor); + if (!topLevel) return; + const baseX = (typeof topLevel.x === 'number' ? topLevel.x : 0) + ANCHOR_DX; + const baseY = typeof topLevel.y === 'number' ? topLevel.y : 0; + comment.x = baseX; + comment.y = baseY + depth * ANCHOR_DY_STEP; + }); + Object.keys(this._context.comments).forEach(commentId => { const comment = this._context.comments[commentId]; target.createComment( From 1c24119e1b1d09c3c4b1adf45eef50677f8d5803 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sun, 10 May 2026 00:42:28 +0900 Subject: [PATCH 07/11] feat(converter): fan out comments that share an anchor block When several comments anchor to the same non-value block (e.g. the multiple value-block annotations on a single looks_sayforsecs), the prior block-anchor layout placed them all at the same coordinate and they visually merged into one bubble in viewers without the metadata-hide CSS. Add per-anchor index (24, 16) offsets so each subsequent comment slides diagonally down-right from its peers. Single-anchor comments are untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ruby-to-blocks-converter/target-applier.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/target-applier.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/target-applier.js index 37933aa9c3b..8f1e6f4e4fd 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/target-applier.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/target-applier.js @@ -246,16 +246,28 @@ const TargetApplier = { } return { topLevel: cur && cur.topLevel ? cur : null, depth }; }; + // Hybrid layout: comments primarily land at their anchor's + // (depth × ANCHOR_DY_STEP) offset, but when several comments + // share the same anchor block (e.g. multiple value-block + // annotations on a single looks_sayforsecs) we fan them out + // with ANCHOR_OVERLAP_DX / ANCHOR_OVERLAP_DY so the bubbles + // don't visually merge in viewers that don't apply the + // metadata-hide CSS. + const ANCHOR_OVERLAP_DX = 24; + const ANCHOR_OVERLAP_DY = 16; + const anchorCounts = new Map(); Object.values(this._context.comments).forEach(comment => { if (!comment.blockId) return; const anchor = findAnchorBlock(comment.blockId); if (!anchor) return; const { topLevel, depth } = findTopLevelAndDepth(anchor); if (!topLevel) return; + const idx = anchorCounts.get(anchor.id) || 0; + anchorCounts.set(anchor.id, idx + 1); const baseX = (typeof topLevel.x === 'number' ? topLevel.x : 0) + ANCHOR_DX; const baseY = typeof topLevel.y === 'number' ? topLevel.y : 0; - comment.x = baseX; - comment.y = baseY + depth * ANCHOR_DY_STEP; + comment.x = baseX + idx * ANCHOR_OVERLAP_DX; + comment.y = baseY + depth * ANCHOR_DY_STEP + idx * ANCHOR_OVERLAP_DY; }); Object.keys(this._context.comments).forEach(commentId => { From bb1983bdb5b9707b59801996e3ef0fc3911d821d Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sun, 10 May 2026 10:04:16 +0900 Subject: [PATCH 08/11] fix(comment-icon-patch): capture Blockly's natural anchor into VM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the previous depth*48 approximation in target-applier — it diverged from Blockly's actual rendering for deep chains, so the deferred setBubbleLocation snap (whose distance heuristic only suppresses ≤100px drift) would jump those bubbles to the approximated coordinate, often off-screen. Instead, after the comment icon's bubble is constructed at its natural anchor, capture that position via getRelativeToSurfaceXY() and write it directly to target.comments[id].x/y. The capture runs once on the next tick and once at the end of the post-load suppress window, so we get the final layout regardless of which timing wins. This makes the saved sb3 reflect exactly where the bubble visually appeared in Smalruby — viewers without the metadata-hide CSS (e.g. scratch.mit.edu) open with bubbles next to their attached blocks. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../target-applier.js | 75 ------------------- .../lib/scratch-blocks-comment-icon-patch.js | 40 ++++++++++ 2 files changed, 40 insertions(+), 75 deletions(-) diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/target-applier.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/target-applier.js index 8f1e6f4e4fd..04fdaa63703 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/target-applier.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/target-applier.js @@ -195,81 +195,6 @@ const TargetApplier = { target.blocks.createBlock(this._context.blocks[blockId]); }); - // === Smalruby: align attached comment x/y with their anchor - // block so the saved sb3 places bubbles where they visually - // appear in Smalruby. - // - // Background: Blockly v12 treats `target.comments[id].x/y` as - // workspace absolute coordinates. The converter creates fresh - // comments at the default (0, 0). Blockly v12 renders bubbles at - // each block's natural anchor in the workspace, but does not - // write that natural anchor back to the VM data model — so a - // .sb3 export keeps (0, 0) for every bubble. Reopening the file - // in any viewer that doesn't apply Smalruby's metadata-hide - // CSS (most importantly scratch.mit.edu) then stacks every - // bubble at the workspace origin. - // - // Approximate the anchor: walk up the parent chain to the - // nearest non-value block (so a comment on `operator_join` - // inherits the position of its enclosing `looks_sayforsecs`), - // count the hops from the topLevel ancestor, and emit a - // (topLevel.x + offset, topLevel.y + depth * step) coordinate. - // Multiple comments anchored to the same non-value block - // cluster at the same y; that matches what Blockly already - // renders for stacked value-block annotations. === - const ANCHOR_DX = 350; - const ANCHOR_DY_STEP = 48; - const blockTypes = this._context.blockTypes || {}; - const isValueType = (t) => t && t.indexOf('value') === 0; - const findAnchorBlock = (startId) => { - const blocks = this._context.blocks; - let cur = blocks[startId]; - let guard = 0; - while (cur && guard < 500) { - const t = blockTypes[cur.id]; - if (!isValueType(t)) return cur; - if (!cur.parent) return cur; - cur = blocks[cur.parent]; - guard++; - } - return cur || null; - }; - const findTopLevelAndDepth = (startBlock) => { - const blocks = this._context.blocks; - let cur = startBlock; - let depth = 0; - let guard = 0; - while (cur && !cur.topLevel && cur.parent && guard < 500) { - cur = blocks[cur.parent]; - if (!isValueType(blockTypes[cur?.id])) depth++; - guard++; - } - return { topLevel: cur && cur.topLevel ? cur : null, depth }; - }; - // Hybrid layout: comments primarily land at their anchor's - // (depth × ANCHOR_DY_STEP) offset, but when several comments - // share the same anchor block (e.g. multiple value-block - // annotations on a single looks_sayforsecs) we fan them out - // with ANCHOR_OVERLAP_DX / ANCHOR_OVERLAP_DY so the bubbles - // don't visually merge in viewers that don't apply the - // metadata-hide CSS. - const ANCHOR_OVERLAP_DX = 24; - const ANCHOR_OVERLAP_DY = 16; - const anchorCounts = new Map(); - Object.values(this._context.comments).forEach(comment => { - if (!comment.blockId) return; - const anchor = findAnchorBlock(comment.blockId); - if (!anchor) return; - const { topLevel, depth } = findTopLevelAndDepth(anchor); - if (!topLevel) return; - const idx = anchorCounts.get(anchor.id) || 0; - anchorCounts.set(anchor.id, idx + 1); - const baseX = (typeof topLevel.x === 'number' ? topLevel.x : 0) + ANCHOR_DX; - const baseY = typeof topLevel.y === 'number' ? topLevel.y : 0; - comment.x = baseX + idx * ANCHOR_OVERLAP_DX; - comment.y = baseY + depth * ANCHOR_DY_STEP + idx * ANCHOR_OVERLAP_DY; - }); - Object.keys(this._context.comments).forEach(commentId => { const comment = this._context.comments[commentId]; target.createComment( diff --git a/packages/scratch-gui/src/lib/scratch-blocks-comment-icon-patch.js b/packages/scratch-gui/src/lib/scratch-blocks-comment-icon-patch.js index 3fc96babcdd..3f706d05b5c 100644 --- a/packages/scratch-gui/src/lib/scratch-blocks-comment-icon-patch.js +++ b/packages/scratch-gui/src/lib/scratch-blocks-comment-icon-patch.js @@ -165,6 +165,46 @@ export const installCommentIconPatch = function (ScratchBlocks) { // Track the construction time so setBubbleLocation can detect // the deferred call from XML deserialization vs later calls. this.__smalrubyPostLoadUntil = Date.now() + POST_LOAD_SUPPRESS_MS; + + // Capture the natural anchor position into the VM data model so + // that a saved sb3 reflects where Blockly actually rendered each + // bubble. Without this, freshly converted comments stay at the + // converter's default (0, 0) in target.comments[]; viewers that + // don't apply Smalruby's metadata-hide CSS (most importantly + // scratch.mit.edu) then stack every bubble at the workspace + // origin when reopening the file. + // + // Run twice — once on the next tick (covers immediate render), + // and again at the end of the post-load suppress window + // (covers Blockly's own deferred layout pass) — so we capture + // the final natural anchor regardless of which timing wins. + const captureNaturalAnchor = () => { + try { + const bubble = typeof this.getBubble === 'function' ? this.getBubble() : null; + if (!bubble || typeof bubble.getRelativeToSurfaceXY !== 'function') return; + const xy = bubble.getRelativeToSurfaceXY(); + if (typeof xy?.x !== 'number' || typeof xy?.y !== 'number') return; + const block = sourceBlock; + if (!block || !block.id) return; + const vm = typeof window !== 'undefined' && window.smalruby ? window.smalruby.vm : null; + if (!vm) return; + // Locate the right target: prefer the editing target, + // fall back to scanning all targets for the comment. + const commentId = `${block.id}_comment`; + const targets = [vm.editingTarget, ...vm.runtime.targets].filter(Boolean); + for (const t of targets) { + if (t.comments && Object.prototype.hasOwnProperty.call(t.comments, commentId)) { + t.comments[commentId].x = xy.x; + t.comments[commentId].y = xy.y; + break; + } + } + } catch (_e) { + // non-fatal + } + }; + setTimeout(captureNaturalAnchor, 0); + setTimeout(captureNaturalAnchor, POST_LOAD_SUPPRESS_MS + 50); } setBubbleLocation(coord) { From 9927daebe3cb9d0ca0677653faacdfcb6310dc22 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sun, 10 May 2026 10:28:46 +0900 Subject: [PATCH 09/11] fix(converter): drop hardcoded (200, 0) on say/puts comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three call sites in looks.js explicitly passed x=200, y=0 to createComment for puts/say variants. With the natural-anchor capture in comment-icon-patch, this hardcoded coordinate caused those bubbles to be created at (200, 0) — far enough from the looks_sayforsecs's actual anchor that the deferred setBubbleLocation snap fired and pinned them at (200, 0) instead of the natural rendering position. Drop the hardcoded coords; the icon patch will write the true rendered position into the VM after the bubble settles, so saved sb3 files and scratch.mit.edu reopens place these bubbles next to the say block they describe. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../scratch-gui/src/lib/ruby-to-blocks-converter/looks.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/looks.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/looks.js index dea682ea326..7db2baab593 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/looks.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/looks.js @@ -110,7 +110,7 @@ const LooksConverter = { } else if (symbolVar) { converter._addTextInput(block, 'MESSAGE', symbolVar, 'Hello!'); block.comment = converter.createComment( - `@ruby:method:${methodName}`, block.id, 200, 0 + `@ruby:method:${methodName}`, block.id ); } else { converter._addTextInput( @@ -118,7 +118,7 @@ const LooksConverter = { converter._isNumber(arg) ? arg.toString() : arg, 'Hello!' ); block.comment = converter.createComment( - `@ruby:method:${methodName}`, block.id, 200, 0 + `@ruby:method:${methodName}`, block.id ); } converter._addNumberInput(block, 'SECS', 'math_number', 1, 1); @@ -169,7 +169,7 @@ const LooksConverter = { } } - block.comment = converter.createComment(commentText, block.id, 200, 0); + block.comment = converter.createComment(commentText, block.id); if (!firstBlock) { firstBlock = block; From f7e94e491ddab5da514c8a53ca4ff2000659863e Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sun, 10 May 2026 10:32:43 +0900 Subject: [PATCH 10/11] refactor(converter): drop redundant (0, 0, true) on _createComment calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All five extra arguments matched _createComment's defaults. Keeping just (text, blockId) makes the call sites consistent with the rest of the converter and clarifies that no specific position or pin state is intended — natural-anchor capture handles positioning. --- .../ast-handlers/control-flow.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/ast-handlers/control-flow.js b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/ast-handlers/control-flow.js index 75b3ba248d3..83c021be6c9 100644 --- a/packages/scratch-gui/src/lib/ruby-to-blocks-converter/ast-handlers/control-flow.js +++ b/packages/scratch-gui/src/lib/ruby-to-blocks-converter/ast-handlers/control-flow.js @@ -68,7 +68,7 @@ const ControlFlowHandlers = { comment.minimized = true; } } else { - const commentId = this._createComment(commentText, b.id, 0, 0, true); + const commentId = this._createComment(commentText, b.id); b.comment = commentId; } }); @@ -86,7 +86,7 @@ const ControlFlowHandlers = { comment.minimized = true; } } else { - const commentId = this._createComment(commentText, block.id, 0, 0, true); + const commentId = this._createComment(commentText, block.id); block.comment = commentId; } } else if (variableHint && !hasElsif && this._isBlock(block)) { @@ -96,7 +96,7 @@ const ControlFlowHandlers = { const existingComment = this._context.comments[block.comment]; if (existingComment) existingComment.text += `,${varCommentText}`; } else { - const commentId = this._createComment(varCommentText, block.id, 0, 0, true); + const commentId = this._createComment(varCommentText, block.id); block.comment = commentId; } } @@ -166,7 +166,7 @@ const ControlFlowHandlers = { ); // Add comment to condition block - const condCommentId = this._createComment(commentText, condBlock.id, 0, 0, true); + const condCommentId = this._createComment(commentText, condBlock.id); condBlock.comment = condCommentId; const elseStatement = convertWhen(index + 1); @@ -185,7 +185,7 @@ const ControlFlowHandlers = { comment.minimized = true; } } else { - const blockCommentId = this._createComment(commentText, block.id, 0, 0, true); + const blockCommentId = this._createComment(commentText, block.id); block.comment = blockCommentId; } } @@ -235,7 +235,7 @@ const ControlFlowHandlers = { const existingComment = this._context.comments[block.comment]; if (existingComment) existingComment.text += `,${varCommentText}`; } else { - const commentId = this._createComment(varCommentText, block.id, 0, 0, true); + const commentId = this._createComment(varCommentText, block.id); block.comment = commentId; } } @@ -303,7 +303,7 @@ const ControlFlowHandlers = { comment.minimized = true; } } else { - const commentId = this._createComment(commentText2, block.id, 0, 0, true); + const commentId = this._createComment(commentText2, block.id); block.comment = commentId; } } @@ -352,7 +352,7 @@ const ControlFlowHandlers = { if (variableHint) { commentText += `,@ruby:variable:${variableHint}`; } - const commentId = this._createComment(commentText, block.id, 0, 0, true); + const commentId = this._createComment(commentText, block.id); block.comment = commentId; } From 8c77f3a469aa6f77b1ac1d61c87461bdbd2ef6b2 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Sun, 10 May 2026 10:35:28 +0900 Subject: [PATCH 11/11] fix(comment-icon-patch): re-enable CSS hide for @ruby metadata bubbles Re-enable the rule that was temporarily disabled while verifying bubble x/y placement. Now that natural-anchor capture writes the true rendered position into the VM, the saved sb3 places bubbles correctly in scratch.mit.edu, and Smalruby itself can hide them with the CSS rule. --- .../scratch-gui/src/lib/scratch-blocks-comment-icon-patch.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/scratch-gui/src/lib/scratch-blocks-comment-icon-patch.js b/packages/scratch-gui/src/lib/scratch-blocks-comment-icon-patch.js index 3f706d05b5c..2f09db97199 100644 --- a/packages/scratch-gui/src/lib/scratch-blocks-comment-icon-patch.js +++ b/packages/scratch-gui/src/lib/scratch-blocks-comment-icon-patch.js @@ -55,10 +55,7 @@ const ensureMetaCommentHideStyle = function () { if (document.getElementById('smalruby-hide-meta-comments')) return; const style = document.createElement('style'); style.id = 'smalruby-hide-meta-comments'; - // TEMPORARY: CSS hide disabled so we can visually verify bubble x/y - // placement (matches how scratch.mit.edu would render the saved sb3). - // Re-enable before merging. - style.textContent = '/* g.blocklyComment[data-smalruby-meta="true"]{display:none!important;} */'; + style.textContent = 'g.blocklyComment[data-smalruby-meta="true"]{display:none!important;}'; document.head.appendChild(style); };