diff --git a/packages/scratch-gui/src/containers/ruby-tab.jsx b/packages/scratch-gui/src/containers/ruby-tab.jsx index 8a8a3b91fee..03b75e411fc 100644 --- a/packages/scratch-gui/src/containers/ruby-tab.jsx +++ b/packages/scratch-gui/src/containers/ruby-tab.jsx @@ -136,6 +136,15 @@ 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 +260,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 +274,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 +293,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 +652,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/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; } 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/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; 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..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 @@ -36,6 +36,69 @@ * `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 +110,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; @@ -98,6 +162,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) { @@ -149,12 +253,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;