Callout: small print under a line

A Callout's text is fitted as large as the note allows, so a note with a
headline and its details ended up with all of it at one middling size. A
line can now be small print: it is drawn small under the line above it, the
first line stays big enough to read from far out, and the note holds more.

In the editor the new Small button marks the caret's line, or every line of
a selection, and takes the mark off again. In the text it is a line that
starts with six hashes and a space.

Why six hashes: every release so far already drops one to six leading
hashes from a line, so a note with small print opened on an older version
of the pack shows the same words with every line at one size, and no note
written before changes its look unless it used that exact mark. A dash was
the first idea, but a dash line is an ordinary line today and dash lists
would have shrunk.

The editor's bar was already squeezed on a narrow note. With one more
button it now drops its label and its hint under 345 px, and goes onto two
rows when the note is tall enough, so the buttons keep their size.

tests/fixtures/callout_released_notes.json holds what release 2.10.1 made
of 14 notes (dash lists, every heading mark, the example notes); the new
parser has to give the same. Help page and CHANGELOG entry included.
This commit is contained in:
ausboss
2026-10-11 17:10:24 -05:00
parent 7574ded256
commit 4609d7df86
6 changed files with 543 additions and 10 deletions
+7
View File
@@ -58,6 +58,13 @@ All notable changes to ComfyUI-AusBoss are documented here.
generation took over there. It never did: the run kept that edge as it
was, and only the preview was wrong. The preview now shows a blend only
where the run has one. The same for both Video Crop + Rotate + Pad nodes.
- **Callout 🆎: small print under a line.** Put the caret on a line and press
the new **Small** button while you edit, and that line is drawn small
under the line above it. A note can carry a first line big enough to read
from far out and the details beside it. In the text it is a line that
starts with six `#`. Notes written before look the same, and an older
version of the pack shows a note with small print with every line at one
size.
## 2.10.1 - 2026-10-09
+33 -5
View File
@@ -15,6 +15,7 @@ import {
arrowText,
fitFontSize,
parseBlocks,
toggleSmall,
} from "../shared/callout.mjs";
import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
import { WIDGET_FRAME, fillNodeHeight } from "../shared/panel_layout.mjs";
@@ -38,6 +39,11 @@ const FONT = '"IBM Plex Sans", "Inter", "Segoe UI", system-ui, -apple-system, sa
// height: a note could not be made shorter than its own text, and the text
// was then fitted to a card it had sized itself. Out of flow, the card takes
// its size from the node alone, in both renderers.
//
// The editor's bar is laid out for a note of ordinary width. On a narrow one
// it drops its "Arrow" label and its hint, and when the note is tall enough
// it goes onto two rows, so the buttons keep their size instead of being
// squeezed to slivers (the two @container rules).
function ensureCss() {
if (document.getElementById(CSS_ID)) return;
const style = document.createElement("style");
@@ -49,6 +55,8 @@ function ensureCss() {
.ausboss-callout-point{margin:0 0 .6em;padding-left:.7em;border-left:2px solid rgba(0,180,170,.7);}
.ausboss-callout-point:last-child{margin-bottom:0;}
.ausboss-callout-point b{color:#6fe0d6;font-weight:650;}
.ausboss-callout-small{margin-top:.3em;color:#9fb5b2;font-size:max(11px,.62em);font-weight:400;line-height:1.4;}
.ausboss-callout-small b{color:#8fd6cf;font-weight:600;}
.ausboss-callout-empty{color:#6f8987;font-size:14px;font-weight:400;}
.ausboss-callout-arrow{display:inline-block;width:1.2em;height:1.2em;margin:0 .1em;vertical-align:-.26em;filter:drop-shadow(0 0 .3em rgba(0,180,170,.55));}
.ausboss-callout-arrow svg{display:block;width:100%;height:100%;overflow:visible;}
@@ -59,7 +67,7 @@ function ensureCss() {
.ausboss-callout-card:hover .ausboss-callout-tools,.ausboss-callout.is-selected .ausboss-callout-tools{opacity:.95;}
.ausboss-callout-tool{height:22px;padding:0 8px;border:1px solid rgba(0,180,170,.45);border-radius:5px;background:rgba(8,14,16,.86);color:#9fd8d3;font:600 11px ${FONT};letter-spacing:.02em;cursor:pointer;}
.ausboss-callout-tool:hover{color:#fff;border-color:${BRAND};}
.ausboss-callout-editor{position:absolute;inset:0;box-sizing:border-box;display:flex;flex-direction:column;gap:6px;padding:6px;border:1px solid ${BRAND};border-radius:8px;background:rgba(5,10,11,.95);pointer-events:auto;}
.ausboss-callout-editor{position:absolute;inset:0;box-sizing:border-box;display:flex;flex-direction:column;gap:6px;padding:6px;border:1px solid ${BRAND};border-radius:8px;background:rgba(5,10,11,.95);pointer-events:auto;container-type:size;}
.ausboss-callout-edit{flex:1;min-height:0;box-sizing:border-box;width:100%;padding:6px 8px;resize:none;border:none;background:transparent;color:#e6f4f3;font:15px/1.45 ${FONT};outline:none;}
.ausboss-callout-arrowbar{flex:none;display:flex;align-items:center;gap:4px;}
.ausboss-callout-arrowbar span{margin-right:4px;color:#6f8987;font:11px ${FONT};}
@@ -69,6 +77,10 @@ function ensureCss() {
.ausboss-callout-pick{width:26px;height:24px;padding:3px;border:1px solid rgba(0,180,170,.35);border-radius:5px;background:rgba(8,14,16,.9);cursor:pointer;}
.ausboss-callout-pick:hover{border-color:${BRAND};background:rgba(0,180,170,.14);}
.ausboss-callout-pick svg{display:block;width:100%;height:100%;}
.ausboss-callout-size{flex:none;height:24px;margin-left:4px;padding:0 7px;border:1px solid rgba(0,180,170,.35);border-radius:5px;background:rgba(8,14,16,.9);color:#9fd8d3;font:600 11px ${FONT};cursor:pointer;}
.ausboss-callout-size:hover{border-color:${BRAND};background:rgba(0,180,170,.14);}
@container (max-width:345px){.ausboss-callout-arrowbar>span:first-child,.ausboss-callout-link:not(.has-node){display:none;}}
@container (max-width:345px) and (min-height:112px){.ausboss-callout-arrowbar{flex-wrap:wrap;}.ausboss-callout-pick{flex:none;}}
`;
document.head.appendChild(style);
}
@@ -154,11 +166,15 @@ function renderText(state) {
for (const lines of blocks) {
const point = el("div", "ausboss-callout-point");
lines.forEach((line, index) => {
if (index) point.append(document.createElement("br"));
// Small print is a block of its own under the line above it. Normal
// lines are told apart by a break, as before; a block needs none.
const into = line.small ? el("div", "ausboss-callout-small") : point;
if (index && !line.small && !lines[index - 1].small) point.append(document.createElement("br"));
for (const piece of line) {
if (piece.arrow) point.append(arrowSpan(state, piece.arrow, piece.link));
else point.append(piece.bold ? el("b", "", piece.text) : document.createTextNode(piece.text));
if (piece.arrow) into.append(arrowSpan(state, piece.arrow, piece.link));
else into.append(piece.bold ? el("b", "", piece.text) : document.createTextNode(piece.text));
}
if (line.small) point.append(into);
});
state.inner.append(point);
}
@@ -244,7 +260,7 @@ function startEdit(state) {
const wrap = el("div", "ausboss-callout-editor");
const area = el("textarea", "ausboss-callout-edit");
area.value = state.valueWidget.value ?? "";
area.placeholder = "One idea per paragraph. **Bold** a word to stress it. Add an arrow with the buttons below.";
area.placeholder = "One idea per paragraph. **Bold** a word to stress it. The buttons below add an arrow or make a line small.";
area.spellcheck = true;
const bar = el("div", "ausboss-callout-arrowbar");
bar.append(el("span", "", "Arrow"));
@@ -285,6 +301,18 @@ function startEdit(state) {
});
bar.append(pick);
}
// Small print for the line the caret is on, or every line of a selection.
const small = el("button", "ausboss-callout-size", "Small");
small.type = "button";
small.title = "Make this line small print, for the details under the line above. Press again to make it normal.";
small.addEventListener("mousedown", (event) => event.preventDefault());
small.addEventListener("click", () => {
const next = toggleSmall(area.value, area.selectionStart, area.selectionEnd);
area.setRangeText(next.insert, next.from, next.to, "preserve");
area.setSelectionRange(next.selectionStart, next.selectionEnd);
area.focus();
});
bar.append(small);
bar.append(linkBox);
showLink();
wrap.append(area, bar);
+10
View File
@@ -13,6 +13,10 @@ wires.
keep the text. **Esc** drops what you typed.
- A blank line starts a new point. Each point gets a thin teal line beside it.
- Put two stars around a word to stress it: `**Resize is on.**`
- For the details, make a line **small print**: put the caret on it and press
**Small** under the text box. It is drawn small under the line above, so
the first line stays big enough to read from far out. Press **Small** again
to make it normal.
- A line that starts with `##` is read the same as one that doesn't, so notes
written for the core Markdown Note paste in as they are.
- Drag the corner to change the size. The text grows or shrinks to fit.
@@ -41,6 +45,12 @@ deleted the arrow stays, faded, and does nothing.
- The text is stored in the node's one hidden `text` input, so saving,
undo and copy work like any other node. Nothing else is saved with it.
- In the text, small print is a line that starts with six `#` and a space:
`###### Off, your words go in as typed.` Small print is 62% of the note's
text size and never under 11 px.
- Notes written before small print look the same. An older version of the
pack drops those six `#` like any other heading mark, so a note with small
print still reads right there, with every line at one size.
- The text size is the largest whole pixel size, from 12 to 30, that fits the
note. A very long text at the smallest size is cut off at the bottom: make
the note taller.
+46 -4
View File
@@ -7,6 +7,12 @@
// hand, or -> and <-) turns into one of the pack's teal arrows. An arrow
// followed at once by #12 is linked to node 12: the text keeps the link, so
// editing the words around it never moves it.
//
// A line that starts with six hashes ("###### ") is small print: the details
// under the line above it. Six, because every release before this one already
// drops one to six leading hashes, so a note with small print still reads
// right there, all at one size, and no note written before changes its look
// unless it used that one mark.
export const FONT_MIN = 12;
export const FONT_MAX = 30;
@@ -41,8 +47,14 @@ export const ARROW_CHOICES = [
["upleft", "↖️"], ["upright", "↗️"], ["downleft", "↙️"], ["downright", "↘️"],
];
// A heading mark at the start of a line, and the one that means small print.
const HEADING_RE = /^\s*#{1,6}\s+/;
const SMALL_RE = /^\s*#{6}\s+(?=\S)/;
export const SMALL_MARK = "###### ";
// Text -> points -> lines -> pieces. A piece is { text, bold } or
// { arrow, bold }.
// { arrow, bold }. A line of small print is the same list of pieces with
// `small: true` set on it.
export function parseBlocks(text) {
const blocks = [];
let lines = [];
@@ -51,14 +63,44 @@ export function parseBlocks(text) {
lines = [];
};
for (const raw of String(text ?? "").replace(/\r\n?/g, "\n").split("\n")) {
const line = raw.replace(/^\s*#{1,6}\s+/, "").trim();
if (!line) flush();
else lines.push(pieces(line));
const line = raw.replace(HEADING_RE, "").trim();
if (!line) { flush(); continue; }
const parts = pieces(line);
if (SMALL_RE.test(raw)) parts.small = true;
lines.push(parts);
}
flush();
return blocks;
}
// The editor's Small button. Every written line the selection touches becomes
// small print; if they all are already, they go back to normal. Returns the
// text that replaces `from`..`to`, and where the selection sits afterwards.
export function toggleSmall(text, start = 0, end = start) {
const value = String(text ?? "");
const low = Math.max(0, Math.min(start, end));
const high = Math.min(value.length, Math.max(start, end));
const from = low === 0 ? 0 : value.lastIndexOf("\n", low - 1) + 1;
const stop = value.indexOf("\n", high);
const to = stop < 0 ? value.length : stop;
const lines = value.slice(from, to).split("\n");
const written = lines.filter((line) => line.trim());
const allSmall = written.length > 0 && written.every((line) => SMALL_RE.test(line));
const insert = lines
.map((line) => {
if (!line.trim()) return line;
const bare = line.replace(HEADING_RE, "");
return allSmall ? bare : SMALL_MARK + bare;
})
.join("\n");
const grown = insert.length - (to - from);
// A caret stays on its letter; a selection covers the lines it changed.
const caret = Math.max(from, Math.min(from + insert.length, low + grown));
return low === high
? { from, to, insert, selectionStart: caret, selectionEnd: caret }
: { from, to, insert, selectionStart: from, selectionEnd: from + insert.length };
}
function pieces(line) {
const out = [];
let last = 0;
+91 -1
View File
@@ -1,7 +1,14 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { ARROW_ANGLE, ARROW_CHOICES, arrowText, fitFontSize, parseBlocks } from "../js/shared/callout.mjs";
import {
ARROW_ANGLE, ARROW_CHOICES, SMALL_MARK, arrowText, fitFontSize, parseBlocks, toggleSmall,
} from "../js/shared/callout.mjs";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
test("a blank line starts a new point and ## is ignored", () => {
const blocks = parseBlocks("## One\nsecond line\n\n\n## Two");
@@ -75,3 +82,86 @@ test("arrowText writes the link the parser reads", () => {
assert.equal(arrowText("\u27A1\uFE0F", ""), "\u27A1\uFE0F");
assert.deepEqual(parseBlocks(arrowText("\u2197\uFE0F", "4"))[0][0], [{ arrow: "upright", link: "4", bold: false }]);
});
test("a line that starts with six hashes is small print under the line above", () => {
const [point] = parseBlocks("**Auto prompt** writes the prompt.\n###### Off, your words go in as typed.\n###### It adds two seconds.");
assert.equal(point.length, 3);
assert.equal(point[0].small, undefined);
assert.equal(point[1].small, true);
assert.equal(point[2].small, true);
// The mark is not part of the text, and bold and arrows work inside it.
assert.deepEqual([...point[1]], [{ text: "Off, your words go in as typed.", bold: false }]);
const [[, linked]] = parseBlocks("Top\n###### see ➡️#20 for the **text**");
assert.equal(linked.small, true);
assert.deepEqual([...linked], [
{ text: "see ", bold: false },
{ arrow: "right", bold: false, link: "20" },
{ text: " for the ", bold: false },
{ text: "text", bold: true },
]);
// Small print can open a point, and a blank line still ends one.
const blocks = parseBlocks("###### a footnote\n\nNext point");
assert.equal(blocks.length, 2);
assert.equal(blocks[0][0].small, true);
assert.equal(blocks[1][0].small, undefined);
});
test("only six hashes and a space mean small print", () => {
const small = (text) => parseBlocks(text).flat().map((line) => Boolean(line.small));
assert.deepEqual(small("# a\n## b\n### c\n#### d\n##### e"), [false, false, false, false, false]);
assert.deepEqual(small("####### seven"), [false]);
assert.deepEqual(small("######none"), [false]);
assert.deepEqual(small("- a dash\n– a long dash\n-# a chat mark"), [false, false, false]);
assert.deepEqual(small(" ###### indented"), [true]);
// The mark with nothing after it is a blank line, as it always was.
assert.deepEqual(parseBlocks("one\n###### \ntwo").length, 2);
});
test("a note written before small print reads exactly as the release before read it", () => {
// The fixture holds what release 2.10.1 made of each text: dash lists,
// every heading mark but the six-hash one, and the pack's example notes.
const notes = JSON.parse(readFileSync(join(ROOT, "tests", "fixtures", "callout_released_notes.json"), "utf-8"));
assert.ok(notes.length >= 12);
for (const { text, blocks } of notes) {
const now = parseBlocks(text);
assert.deepEqual(JSON.parse(JSON.stringify(now)), blocks, text);
assert.ok(now.flat().every((line) => !line.small), text);
}
});
test("a release before small print shows the same words, all at one size", () => {
// What those releases did with a line: drop one to six leading hashes.
const released = (line) => line.replace(/^\s*#{1,6}\s+/, "").trim();
const text = `Top line\n${SMALL_MARK}the details`;
const now = parseBlocks(text)[0].map((line) => line.map((piece) => piece.text).join(""));
assert.deepEqual(text.split("\n").map(released), now);
});
test("the Small button marks the caret's line and takes the mark off again", () => {
const apply = (text, start, end = start) => {
const next = toggleSmall(text, start, end);
return { text: text.slice(0, next.from) + next.insert + text.slice(next.to), start: next.selectionStart, end: next.selectionEnd };
};
const text = "Top line\nthe details\n\nNext";
// Caret in "the de|tails": the line gets the mark and the caret stays on its letter.
const on = apply(text, 15);
assert.equal(on.text, "Top line\n###### the details\n\nNext");
assert.equal(on.text.slice(on.start, on.start + 5), "tails");
assert.equal(on.start, on.end);
const off = apply(on.text, on.start);
assert.equal(off.text, text);
assert.equal(off.start, 15);
// A selection over several lines: every written line, and the blank one is left alone.
const many = apply(text, 2, text.length);
assert.equal(many.text, "###### Top line\n###### the details\n\n###### Next");
assert.deepEqual([many.start, many.end], [0, many.text.length]);
assert.equal(apply(many.text, 0, many.text.length).text, text);
// Lines that are only partly small all become small; another heading mark is replaced.
assert.equal(apply("###### a\n## b", 0, 14).text, "###### a\n###### b");
// The first and the last line, a caret at either end, and an empty box.
assert.equal(apply("one\ntwo", 0).text, "###### one\ntwo");
assert.equal(apply("one\ntwo", 7).text, "one\n###### two");
assert.deepEqual(apply("", 0), { text: "", start: 0, end: 0 });
// A caret before the mark never lands in front of the line.
assert.equal(apply("###### x", 2).start, 0);
});
+356
View File
@@ -0,0 +1,356 @@
[
{
"text": "Steps:\n- load the picture\n- press run",
"blocks": [
[
[
{
"text": "Steps:",
"bold": false
}
],
[
{
"text": "- load the picture",
"bold": false
}
],
[
{
"text": "- press run",
"bold": false
}
]
]
]
},
{
"text": "– a line with a long dash\nand the next line",
"blocks": [
[
[
{
"text": "– a line with a long dash",
"bold": false
}
],
[
{
"text": "and the next line",
"bold": false
}
]
]
]
},
{
"text": "# One\n## Two\n### Three\n#### Four\n##### Five",
"blocks": [
[
[
{
"text": "One",
"bold": false
}
],
[
{
"text": "Two",
"bold": false
}
],
[
{
"text": "Three",
"bold": false
}
],
[
{
"text": "Four",
"bold": false
}
],
[
{
"text": "Five",
"bold": false
}
]
]
]
},
{
"text": "####### seven hashes stay as typed",
"blocks": [
[
[
{
"text": "####### seven hashes stay as typed",
"bold": false
}
]
]
]
},
{
"text": "######no space after the hashes",
"blocks": [
[
[
{
"text": "######no space after the hashes",
"bold": false
}
]
]
]
},
{
"text": "-# a line some chat apps draw small",
"blocks": [
[
[
{
"text": "-# a line some chat apps draw small",
"bold": false
}
]
]
]
},
{
"text": " an indented line\n\tand one after a tab",
"blocks": [
[
[
{
"text": "an indented line",
"bold": false
}
],
[
{
"text": "and one after a tab",
"bold": false
}
]
]
]
},
{
"text": "a **big** idea ⬆️#12 -> there\r\nsecond line\r\n\r\nthird <- back",
"blocks": [
[
[
{
"text": "a ",
"bold": false
},
{
"text": "big",
"bold": true
},
{
"text": " idea ",
"bold": false
},
{
"arrow": "up",
"bold": false,
"link": "12"
},
{
"text": " ",
"bold": false
},
{
"arrow": "right",
"bold": false
},
{
"text": " there",
"bold": false
}
],
[
{
"text": "second line",
"bold": false
}
]
],
[
[
{
"text": "third ",
"bold": false
},
{
"arrow": "left",
"bold": false
},
{
"text": " back",
"bold": false
}
]
]
]
},
{
"text": "###### \nonly the mark above, then text",
"blocks": [
[
[
{
"text": "only the mark above, then text",
"bold": false
}
]
]
]
},
{
"text": "**Resize is on.** The picture is made smaller first.\n\nTurn it off to keep the size.",
"blocks": [
[
[
{
"text": "Resize is on.",
"bold": true
},
{
"text": " The picture is made smaller first.",
"bold": false
}
]
],
[
[
{
"text": "Turn it off to keep the size.",
"bold": false
}
]
]
]
},
{
"text": "1. first\n2. second\n\n* a star\n+ a plus\n> a quote",
"blocks": [
[
[
{
"text": "1. first",
"bold": false
}
],
[
{
"text": "2. second",
"bold": false
}
]
],
[
[
{
"text": "* a star",
"bold": false
}
],
[
{
"text": "+ a plus",
"bold": false
}
],
[
{
"text": "> a quote",
"bold": false
}
]
]
]
},
{
"text": "⬅️#7 Switch a result off to skip it.",
"blocks": [
[
[
{
"arrow": "left",
"bold": false,
"link": "7"
},
{
"text": " Switch a result off to skip it.",
"bold": false
}
]
]
]
},
{
"text": "⬇️#3 Load your picture here.",
"blocks": [
[
[
{
"arrow": "down",
"bold": false,
"link": "3"
},
{
"text": " Load your picture here.",
"bold": false
}
]
]
]
},
{
"text": "⬇️#6 Move over the picture: things light up and say their name. **Click** one to pick it.\n\nWrong thing? The **‹ ›** arrows under the picture swap it.",
"blocks": [
[
[
{
"arrow": "down",
"bold": false,
"link": "6"
},
{
"text": " Move over the picture: things light up and say their name. ",
"bold": false
},
{
"text": "Click",
"bold": true
},
{
"text": " one to pick it.",
"bold": false
}
]
],
[
[
{
"text": "Wrong thing? The ",
"bold": false
},
{
"text": "‹ ›",
"bold": true
},
{
"text": " arrows under the picture swap it.",
"bold": false
}
]
]
]
}
]