Skip to content
Merged
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
24 changes: 23 additions & 1 deletion packages/scratch-gui/src/containers/ruby-tab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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) {
Expand All @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -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(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
});
Expand All @@ -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)) {
Expand All @@ -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;
}
}
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
}
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,15 +110,15 @@ 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(
block, 'MESSAGE',
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);
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
118 changes: 118 additions & 0 deletions packages/scratch-gui/src/lib/scratch-blocks-comment-icon-patch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <head> 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<user text>`) — 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:
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;

Expand Down
Loading