diff --git a/CHANGELOG.md b/CHANGELOG.md index 73da243..4feb9c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/js/callout/index.js b/js/callout/index.js index a2b8e40..367e4a0 100644 --- a/js/callout/index.js +++ b/js/callout/index.js @@ -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); diff --git a/js/docs/AUSBOSS_NODES_Callout.md b/js/docs/AUSBOSS_NODES_Callout.md index d2f6571..46dd559 100644 --- a/js/docs/AUSBOSS_NODES_Callout.md +++ b/js/docs/AUSBOSS_NODES_Callout.md @@ -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. diff --git a/js/shared/callout.mjs b/js/shared/callout.mjs index 68aa232..9af0f3d 100644 --- a/js/shared/callout.mjs +++ b/js/shared/callout.mjs @@ -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; diff --git a/tests/callout.test.mjs b/tests/callout.test.mjs index 63dc6a8..345842d 100644 --- a/tests/callout.test.mjs +++ b/tests/callout.test.mjs @@ -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); +}); diff --git a/tests/fixtures/callout_released_notes.json b/tests/fixtures/callout_released_notes.json new file mode 100644 index 0000000..297d0d0 --- /dev/null +++ b/tests/fixtures/callout_released_notes.json @@ -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 + } + ] + ] + ] + } +]